Files
vamp/src/repository.rs
T
hl-valdemar b9f29d258c fix: support large files in comparisons
Replace the 16 MiB comparison rejection with independent bounded per-side loading and frozen Working blobs. Bound diff computation, defer large inline highlighting, index source-row navigation, and expose actual load failures. Verified the real 52.3 MB parser through repository/session APIs and terminal rendering with EOF/start navigation.
2026-10-17 00:46:15 +02:00

3031 lines
112 KiB
Rust

//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, PullRequestPage,
RepoKind, Revision, RevisionTarget, line_hunks,
};
use std::{
collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString},
fs,
io::{Read, Write},
path::{Component, Path, PathBuf},
process::{Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
const OUTPUT_LIMIT: usize = 64 * 1024 * 1024;
const FILE_LIMIT: usize = 16 * 1024 * 1024;
// Comparison payloads are independent of picker/gutter annotation budgets.
const COMPARISON_FILE_LIMIT: usize = 64 * 1024 * 1024;
const COMPARISON_LINE_LIMIT: usize = 4_000_000;
const WORKING_SNAPSHOT_LIMIT: usize = 512 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(60);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf);
impl Temporary {
fn new() -> Result<Self, String> {
for _ in 0..100 {
let path = std::env::temp_dir().join(format!(
"vamp-diff-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
match builder.create(&path) {
Ok(()) => return Ok(Self(path)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.to_string()),
}
}
Err("Cannot allocate private comparison directory".into())
}
}
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
pub(crate) struct ProcessOutput {
code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
fn drain(mut pipe: impl Read) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
let mut chunk = [0; 8192];
let mut overflow = false;
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
if bytes.len() + n <= OUTPUT_LIMIT && !overflow {
bytes.extend_from_slice(&chunk[..n]);
} else {
overflow = true;
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.to_string()),
}
}
if overflow {
Err(format!(
"Subprocess output exceeds {} MiB",
OUTPUT_LIMIT / 1024 / 1024
))
} else {
Ok(bytes)
}
}
fn terminate(child: &mut std::process::Child) {
#[cfg(unix)]
unsafe {
unsafe extern "C" {
fn kill(pid: i32, signal: i32) -> i32;
}
kill(-(child.id() as i32), 9);
}
let _ = child.kill();
let _ = child.wait();
}
pub(crate) fn run(
mut command: Command,
input: Vec<u8>,
cancelled: &AtomicBool,
) -> Result<ProcessOutput, String> {
if cancelled.load(Ordering::Acquire) {
return Err("Diff operation cancelled".into());
}
// An inherited object/index override must never redirect a private fetch.
for key in [
"GIT_DIR",
"GIT_COMMON_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
] {
command.env_remove(key);
}
command.env("GIT_NO_REPLACE_OBJECTS", "1");
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
#[cfg(not(unix))]
return Err(
"Repository comparisons require Unix process-group cancellation on this platform".into(),
);
#[cfg(unix)]
{
let mut child = command
.spawn()
.map_err(|e| format!("Cannot start repository command: {e}"))?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let mut stdin = child.stdin.take().unwrap();
let (send, receive) = mpsc::channel();
let out = send.clone();
let err = send.clone();
let readers = [
thread::spawn(move || {
let _ = out.send((0, drain(stdout)));
}),
thread::spawn(move || {
let _ = err.send((1, drain(stderr)));
}),
thread::spawn(move || {
let result = stdin
.write_all(&input)
.map(|_| Vec::new())
.map_err(|e| e.to_string());
drop(stdin);
let _ = send.send((2, result));
}),
];
let started = Instant::now();
let mut results: [Option<Result<Vec<u8>, String>>; 3] = [None, None, None];
let mut status = None;
let failure = loop {
while let Ok((i, result)) = receive.try_recv() {
results[i] = Some(result);
}
if cancelled.load(Ordering::Acquire) {
break Some("Diff operation cancelled".to_string());
}
if started.elapsed() >= TIMEOUT {
break Some("Repository command timed out after 60 seconds".to_string());
}
if status.is_none() {
match child.try_wait() {
Ok(value) => status = value,
Err(e) => break Some(e.to_string()),
}
}
if status.is_some() && results.iter().all(Option::is_some) {
break None;
}
thread::sleep(Duration::from_millis(10));
};
if failure.is_some() {
terminate(&mut child);
}
for reader in readers {
let _ = reader.join();
}
if let Some(error) = failure {
return Err(error);
}
let stdout = results[0].take().unwrap()?;
let stderr = results[1].take().unwrap()?;
results[2].take().unwrap()?;
Ok(ProcessOutput {
code: status.unwrap().code(),
stdout,
stderr,
})
}
}
pub(crate) fn checked(output: ProcessOutput) -> Result<Vec<u8>, String> {
if output.code == Some(0) {
Ok(output.stdout)
} else {
Err(format!(
"Repository command failed ({}): {}",
output.code.map_or("signal".into(), |n| n.to_string()),
String::from_utf8_lossy(&output.stderr).trim()
))
}
}
fn text(bytes: Vec<u8>) -> Result<String, String> {
String::from_utf8(bytes).map_err(|_| "Repository command returned non-UTF-8 metadata".into())
}
fn git_command(store: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.args([
"--no-pager",
"-c",
"core.hooksPath=/dev/null",
"-c",
"core.fsmonitor=false",
])
.arg("--git-dir")
.arg(store)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0");
cmd
}
pub(crate) fn jj_command(root: &Path) -> Command {
let mut cmd = Command::new("jj");
cmd.arg("--repository")
.arg(root)
.args([
"--ignore-working-copy",
"--at-operation=@",
"--no-pager",
"--color=never",
])
.current_dir(root);
cmd
}
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Non-UTF-8 repository path")?,
))
}
}
pub(crate) fn safe_path(path: &Path) -> Result<(), String> {
if path.as_os_str().is_empty()
|| path
.components()
.any(|c| !matches!(c, Component::Normal(_)))
{
return Err(format!("Unsafe repository path: {}", path.display()));
}
if path
.components()
.any(|c| c.as_os_str() == ".git" || c.as_os_str() == ".jj")
{
return Err(format!(
"Repository metadata cannot be compared: {}",
path.display()
));
}
Ok(())
}
pub(crate) fn store_for_jj(root: &Path) -> Result<PathBuf, String> {
let mut repo = root.join(".jj/repo");
if repo.is_file() {
let pointer = fs::read(&repo).map_err(|e| e.to_string())?;
repo = root
.join(".jj")
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
}
let store = repo.join("store");
let target = fs::read(store.join("git_target"))
.map_err(|e| format!("Only Git-backed Jujutsu stores are supported: {e}"))?;
fs::canonicalize(store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?))
.map_err(|e| e.to_string())
}
pub(crate) fn jj_file_pattern(path: &str) -> String {
let mut pattern = String::from("root-file:\"");
for ch in path.chars() {
match ch {
'\\' => pattern.push_str("\\\\"),
'"' => pattern.push_str("\\\""),
'\0'..='\u{1f}' | '\u{7f}' => {
use std::fmt::Write as _;
write!(&mut pattern, "\\x{:02x}", ch as u32).unwrap();
}
_ => pattern.push(ch),
}
}
pattern.push('"');
pattern
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct WorkingFileChange {
pub(crate) path: PathBuf,
pub(crate) old_path: Option<PathBuf>,
pub(crate) status: FileStatus,
}
const WORKING_PATH_LIMIT: usize = 100_000;
fn working_checked(command: Command, cancelled: &dyn Fn() -> bool) -> Result<Vec<u8>, String> {
crate::blame::run(command, &[], cancelled)?
.ok_or_else(|| "Cannot read working-file repository metadata".into())
}
fn working_paths(bytes: &[u8]) -> Result<Vec<PathBuf>, String> {
let mut paths = Vec::new();
for path in bytes
.split(|byte| *byte == 0)
.filter(|path| !path.is_empty())
{
if paths.len() == WORKING_PATH_LIMIT {
return Err("Working-file catalog exceeds 100000 paths".into());
}
paths.push(path_bytes(path)?);
}
Ok(paths)
}
fn working_safe(root: &Path, path: &Path) -> Result<bool, String> {
if safe_path(path).is_err() {
return Ok(false);
}
let mut full = root.to_path_buf();
for component in path.components() {
full.push(component);
match fs::symlink_metadata(&full) {
Ok(metadata) if metadata.file_type().is_symlink() => return Ok(false),
Ok(_) => {}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) => {}
Err(error) => return Err(format!("{}: {error}", full.display())),
}
}
Ok(true)
}
struct WorkingBaseline {
root: PathBuf,
parent: String,
store: PathBuf,
settings: Vec<String>,
conflicted: bool,
private: PathBuf,
_temporary: Temporary,
}
#[derive(Default)]
pub(crate) struct WorkingFileReader {
baseline: Option<WorkingBaseline>,
}
impl WorkingFileReader {
pub(crate) fn read(
&mut self,
root: &Path,
buffers: &[(PathBuf, Arc<str>)],
cancelled: &dyn Fn() -> bool,
) -> Result<Vec<WorkingFileChange>, String> {
working_file_changes_cached(root, buffers, cancelled, &mut self.baseline)
}
}
fn working_safe_config(command: &mut Command) {
command
.env_remove("GIT_CONFIG_PARAMETERS")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_CONFIG_COUNT", "0");
}
fn working_private_command(private: &Path, checkout: &Path) -> Command {
let mut command = git_command(private);
command
.arg("--literal-pathspecs")
.arg("--work-tree")
.arg(checkout)
.current_dir(checkout);
working_safe_config(&mut command);
command
}
fn working_private(
checkout: &Path,
store: &Path,
cancelled: &dyn Fn() -> bool,
) -> Result<(Temporary, PathBuf), String> {
let temporary = Temporary::new()?;
let private = temporary.0.join("store");
let mut format = crate::blame::git(checkout);
format
.arg("--git-dir")
.arg(store)
.args(["rev-parse", "--show-object-format"]);
let format = text(working_checked(format, cancelled)?)?;
let mut init = Command::new("git");
init.args(["init", "--bare", "--quiet", "--template="])
.arg(format!("--object-format={}", format.trim()))
.arg(&private);
working_safe_config(&mut init);
working_checked(init, cancelled)?;
let mut objects = crate::blame::git(checkout);
objects.arg("--git-dir").arg(store).args([
"rev-parse",
"--path-format=absolute",
"--git-path",
"objects",
]);
let objects = working_checked(objects, cancelled)?;
let mut alternate = vec![b'"'];
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
alternate.push(byte);
} else {
write!(&mut alternate, "\\{byte:03o}").unwrap();
}
}
alternate.extend_from_slice(b"\"\n");
fs::write(private.join("objects/info/alternates"), alternate).map_err(|e| e.to_string())?;
Ok((temporary, private))
}
fn working_conflict_baseline(
root: &Path,
store: &Path,
parent: &str,
cancelled: &dyn Fn() -> bool,
) -> Result<WorkingBaseline, String> {
let (temporary, private) = working_private(root, store, cancelled)?;
let mut empty = working_private_command(&private, root);
empty.args(["read-tree", "--empty"]);
working_checked(empty, cancelled)?;
let mut list = jj_command(root);
list.args([
"file",
"list",
"-r",
parent,
"-T",
"\"[\" ++ json(path) ++ \",\" ++ json(file_type) ++ \",\" ++ json(executable) ++ \"]\\n\"",
]);
let bytes = working_checked(list, cancelled)?;
let mut index = Vec::new();
let mut size = 0usize;
let mut count = 0usize;
for line in bytes
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
{
if cancelled() {
return Err("Working-file query cancelled".into());
}
count += 1;
if count > WORKING_PATH_LIMIT {
return Err("Working-file conflict baseline exceeds 100000 paths".into());
}
let (path, kind, executable): (String, String, bool) = serde_json::from_slice(line)
.map_err(|_| "Invalid Jujutsu working-file conflict metadata")?;
if !matches!(kind.as_str(), "file" | "conflict") || safe_path(Path::new(&path)).is_err() {
continue;
}
let mut show = jj_command(root);
show.args([
"file",
"show",
"-r",
parent,
"-T",
"",
"--",
&jj_file_pattern(&path),
]);
let contents = working_checked(show, cancelled)?;
size += contents.len();
if contents.len() > FILE_LIMIT || size > OUTPUT_LIMIT {
return Err(
"Working-file conflict baseline exceeds 16 MiB per file or 64 MiB total".into(),
);
}
let mut hash = working_private_command(&private, root);
hash.args(["hash-object", "--no-filters", "-w", "--stdin"]);
let object = crate::blame::run(hash, &contents, cancelled)?
.ok_or("Cannot store private Jujutsu working-file projection")?;
let object = text(object)?;
write!(
&mut index,
"{} {}\t",
if executable { "100755" } else { "100644" },
object.trim()
)
.unwrap();
index.extend_from_slice(path.as_bytes());
index.push(0);
}
let mut update = working_private_command(&private, root);
update.args(["update-index", "-z", "--index-info"]);
crate::blame::run(update, &index, cancelled)?
.ok_or("Cannot build private Jujutsu working-file index")?;
Ok(WorkingBaseline {
root: root.to_path_buf(),
parent: parent.to_owned(),
store: store.to_path_buf(),
settings: Vec::new(),
conflicted: true,
private,
_temporary: temporary,
})
}
fn working_file_changes_cached(
root: &Path,
buffers: &[(PathBuf, Arc<str>)],
cancelled: &dyn Fn() -> bool,
cache: &mut Option<WorkingBaseline>,
) -> Result<Vec<WorkingFileChange>, String> {
use crate::blame::{git, git_without_literal_pathspecs, run_status};
if cancelled() {
return Err("Working-file query cancelled".into());
}
let size = buffers.iter().try_fold(0usize, |size, (path, contents)| {
size.checked_add(contents.len())?
.checked_add(path.as_os_str().len())
});
if buffers.len() > 1024 || size.is_none_or(|size| size > OUTPUT_LIMIT) {
return Err("Working-file buffers exceed 64 MiB or 1024 files".into());
}
let anchored;
let absolute = if root.is_absolute() {
root
} else {
anchored = std::env::current_dir()
.map_err(|e| e.to_string())?
.join(root);
anchored.as_path()
};
if absolute
.components()
.any(|part| matches!(part.as_os_str().to_str(), Some(".git" | ".jj")))
{
*cache = None;
return Ok(vec![]);
}
let Some(checkout) = absolute
.ancestors()
.find(|path| path.join(".jj").exists() || path.join(".git").exists())
else {
*cache = None;
return Ok(vec![]);
};
if absolute
.ancestors()
.take_while(|path| *path != checkout)
.any(|path| {
fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
})
|| fs::symlink_metadata(checkout).is_ok_and(|metadata| metadata.file_type().is_symlink())
{
*cache = None;
return Ok(vec![]);
}
let picker = fs::canonicalize(absolute).map_err(|e| e.to_string())?;
let lexical_checkout = checkout;
let checkout = fs::canonicalize(checkout).map_err(|e| e.to_string())?;
if cache.as_ref().is_some_and(|cached| cached.root != checkout) {
*cache = None;
}
let prefix = picker.strip_prefix(&checkout).map_err(|e| e.to_string())?;
let is_jj = checkout.join(".jj").exists();
let (store, baseline, conflicted) = if is_jj {
let store = match store_for_jj(&checkout) {
Ok(store) => store,
Err(_) => {
*cache = None;
return Ok(vec![]);
}
};
let mut parents = jj_command(&checkout);
parents.args([
"log",
"--no-graph",
"-r",
"@-",
"-T",
"\"[\" ++ json(commit_id) ++ \",\" ++ json(conflict) ++ \"]\\n\"",
]);
let bytes = working_checked(parents, cancelled)?;
let mut ids = bytes
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty());
let Some(parent) = ids.next() else {
*cache = None;
return Ok(vec![]);
};
// ponytail: merged jj parents need a merged-tree baseline; match the gutter ceiling.
if ids.next().is_some() {
*cache = None;
return Ok(vec![]);
}
let (parent, conflicted): (String, bool) = serde_json::from_slice(parent)
.map_err(|_| "Invalid Jujutsu working-file parent metadata")?;
(
store,
(!parent.bytes().all(|byte| byte == b'0')).then_some(parent),
conflicted,
)
} else {
let mut discover = git(&checkout);
discover.args(["rev-parse", "--absolute-git-dir"]);
let bytes = working_checked(discover, cancelled)?;
let store = path_bytes(bytes.strip_suffix(b"\n").unwrap_or(&bytes))?;
let mut head = git(&checkout);
head.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
let (code, bytes) = run_status(head, &[], cancelled)?;
let baseline = match code {
Some(0) => Some(text(bytes)?.trim().to_owned()),
Some(1) => None,
_ => return Err("Cannot resolve Git working-file HEAD".into()),
};
(store, baseline, false)
};
let mut settings = Vec::new();
for key in ["core.autocrlf", "core.filemode"] {
let mut config = git(&checkout);
config.arg("--git-dir").arg(&store).arg("config");
if key == "core.filemode" {
config.arg("--bool");
}
config.args(["--get", key]);
let (code, bytes) = run_status(config, &[], cancelled)?;
match code {
Some(0) => {
let value = text(bytes)?;
let value = value.strip_suffix('\n').unwrap_or(&value);
settings.push(format!("{key}={value}"));
}
Some(1) => {}
_ => return Err("Cannot read working-file Git settings".into()),
}
}
let parent = baseline.as_deref().unwrap_or("");
if cache.as_ref().is_none_or(|cached| {
cached.root != checkout
|| cached.store != store
|| cached.parent != parent
|| cached.settings != settings
|| cached.conflicted != conflicted
}) {
*cache = None;
let mut cached = if conflicted {
working_conflict_baseline(&checkout, &store, parent, cancelled)?
} else {
let (temporary, private) = working_private(&checkout, &store, cancelled)?;
let mut read = working_private_command(&private, &checkout);
for setting in &settings {
read.arg("-c").arg(setting);
}
read.arg("read-tree")
.arg(baseline.as_deref().unwrap_or("--empty"));
working_checked(read, cancelled)?;
WorkingBaseline {
root: checkout.clone(),
parent: parent.to_owned(),
store: store.clone(),
settings: Vec::new(),
conflicted: false,
private,
_temporary: temporary,
}
};
cached.settings = settings.clone();
*cache = Some(cached);
}
let private = cache.as_ref().unwrap().private.as_path();
let check_mode = !settings
.iter()
.any(|setting| setting == "core.filemode=false");
let command = || {
let mut command = working_private_command(private, &checkout);
for setting in &settings {
command.arg("-c").arg(setting);
}
command
};
// Refresh only the private stat cache; dirty paths keep their baseline object IDs.
let mut refresh = command();
refresh.args(["update-index", "-q", "--refresh", "--ignore-missing"]);
working_checked(refresh, cancelled)?;
let mut tree = command();
tree.args(["ls-files", "--stage", "-z"]);
let bytes = working_checked(tree, cancelled)?;
let mut entries = BTreeMap::new();
for entry in bytes
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty())
{
let tab = entry
.iter()
.position(|byte| *byte == b'\t')
.ok_or("Invalid working-file tree")?;
let fields = std::str::from_utf8(&entry[..tab]).map_err(|_| "Invalid working-file tree")?;
let mut fields = fields.split(' ');
let mode = fields.next().ok_or("Missing working-file mode")?.to_owned();
let object = fields
.next()
.ok_or("Missing working-file object")?
.to_owned();
entries.insert(path_bytes(&entry[tab + 1..])?, (mode, object));
if entries.len() > WORKING_PATH_LIMIT {
return Err("Working-file catalog exceeds 100000 paths".into());
}
}
let mut changes = BTreeMap::new();
if baseline.is_some() {
let mut diff = command();
diff.args([
"diff",
"--name-status",
"-z",
"--no-ext-diff",
"--no-textconv",
"--ignore-submodules=all",
"--find-renames=100%",
"--",
]);
// A baseline-populated private index makes diff compare tracked files to disk.
// No unstaged file is read merely to construct a Rust snapshot.
let bytes = working_checked(diff, cancelled)?;
let mut records = bytes
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty());
while let Some(status) = records.next() {
let path = path_bytes(records.next().ok_or("Truncated working-file status")?)?;
let status = match status.first() {
Some(b'D') => FileStatus::Removed,
Some(b'A') => FileStatus::Added,
Some(b'M' | b'T' | b'U') => FileStatus::Modified,
Some(b'R') => {
changes.insert(path, FileStatus::Removed);
let path = path_bytes(records.next().ok_or("Truncated working-file rename")?)?;
changes.insert(path, FileStatus::Added);
continue;
}
_ => return Err("Unsupported working-file status".into()),
};
changes.insert(path, status);
}
}
let mut candidates = git(&checkout);
candidates
.arg("--git-dir")
.arg(&store)
.arg("--work-tree")
.arg(&checkout)
.args([
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
]);
let mut candidates = working_paths(&working_checked(candidates, cancelled)?)?;
if is_jj {
let mut tracked = jj_command(&checkout);
tracked.args(["file", "list", "-r", "@", "-T", "json(path) ++ \"\\n\""]);
let bytes = working_checked(tracked, cancelled)?;
for line in bytes
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
{
let path: String =
serde_json::from_slice(line).map_err(|_| "Invalid Jujutsu working-file path")?;
candidates.push(PathBuf::from(path));
if candidates.len() > WORKING_PATH_LIMIT {
return Err("Working-file catalog exceeds 100000 paths".into());
}
}
}
for path in candidates {
if cancelled() {
return Err("Working-file query cancelled".into());
}
if !entries.contains_key(&path)
&& fs::symlink_metadata(checkout.join(&path)).is_ok_and(|metadata| metadata.is_file())
{
changes.insert(path, FileStatus::Added);
}
if changes.len() > WORKING_PATH_LIMIT {
return Err("Working-file catalog exceeds 100000 paths".into());
}
}
// Pair exact content identities only when both ends are unique. This includes unstaged
// moves whose destination is untracked, which `git diff` alone cannot see.
let mut removed: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
let mut added: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
for (path, status) in &changes {
if !working_safe(&checkout, path)? {
continue;
}
if *status == FileStatus::Removed
&& let Some((mode, object)) = entries.get(path)
&& matches!(mode.as_str(), "100644" | "100755")
{
removed
.entry(object.clone())
.or_default()
.push(path.clone());
}
}
if !removed.is_empty() {
let mut paths = Vec::new();
let mut input = Vec::new();
let mut total = 0u64;
for (path, status) in &changes {
if cancelled() {
return Err("Working-file query cancelled".into());
}
if *status != FileStatus::Added || !working_safe(&checkout, path)? {
continue;
}
let metadata = fs::symlink_metadata(checkout.join(path)).map_err(|e| e.to_string())?;
if !metadata.is_file() {
continue;
}
if metadata.len() > FILE_LIMIT as u64 {
return Err(format!(
"{}: working-file rename candidate exceeds 16 MiB",
path.display()
));
}
total += metadata.len();
if total > OUTPUT_LIMIT as u64 {
return Err("Working-file rename candidates exceed 64 MiB".into());
}
input.push(b'"');
for &byte in path.as_os_str().as_encoded_bytes() {
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
input.push(byte);
} else {
write!(&mut input, "\\{byte:03o}").unwrap();
}
}
input.extend_from_slice(b"\"\n");
paths.push(path);
}
if !paths.is_empty() {
let mut hash = command();
// Only built-in newline conversion is configured in this isolated store.
hash.args(["hash-object", "--stdin-paths"]);
let bytes = crate::blame::run(hash, &input, cancelled)?
.ok_or("Cannot hash working-file rename candidates")?;
let hashes = text(bytes)?;
let mut hashes = hashes.lines();
for path in paths {
let object = hashes.next().ok_or("Truncated working-file hashes")?;
added
.entry(object.to_owned())
.or_default()
.push(path.clone());
}
if hashes.next().is_some() {
return Err("Unexpected working-file hashes".into());
}
}
}
let mut renames = BTreeMap::new();
for (object, old) in removed {
if old.len() == 1
&& let Some(new) = added.get(&object).filter(|paths| paths.len() == 1)
&& old[0].starts_with(prefix)
&& new[0].starts_with(prefix)
{
changes.remove(&old[0]);
changes.insert(new[0].clone(), FileStatus::Renamed);
renames.insert(new[0].clone(), old[0].clone());
}
}
for (path, contents) in buffers {
if cancelled() {
return Err("Working-file query cancelled".into());
}
let anchored;
let absolute = if path.is_absolute() {
path.as_path()
} else {
anchored = std::env::current_dir()
.map_err(|error| error.to_string())?
.join(path);
anchored.as_path()
};
let resolved;
let path = match absolute
.strip_prefix(&checkout)
.or_else(|_| absolute.strip_prefix(lexical_checkout))
{
Ok(path) => path,
Err(_) => {
resolved = match crate::editor::path_identity(absolute) {
Ok(path) => path,
Err(_) => continue,
};
let Ok(path) = resolved.strip_prefix(&checkout) else {
continue;
};
path
}
};
if !path.starts_with(prefix) || !working_safe(&checkout, path)? {
continue;
}
if contents.len() > FILE_LIMIT {
return Err(format!(
"{}: working-file buffer exceeds 16 MiB",
path.display()
));
}
// A dirty buffer does not resurrect a missing tracked file or destroy a rename.
if matches!(
changes.get(path),
Some(FileStatus::Removed | FileStatus::Renamed)
) {
continue;
}
if let Some((mode, object)) = entries.get(path) {
if !matches!(mode.as_str(), "100644" | "100755") {
continue;
}
let mut cat = command();
cat.args(["cat-file", "blob", object]);
let bytes = working_checked(cat, cancelled)?;
if bytes.len() > FILE_LIMIT {
return Err(format!(
"{}: working-file baseline exceeds 16 MiB",
path.display()
));
}
let same = std::str::from_utf8(&bytes).is_ok_and(|before| {
crate::editor::normalize_newlines(before)
== crate::editor::normalize_newlines(contents)
});
let mode_changed = check_mode
&& fs::symlink_metadata(checkout.join(path)).is_ok_and(|metadata| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
(metadata.permissions().mode() & 0o111 != 0) != (mode == "100755")
}
#[cfg(not(unix))]
{
false
}
});
if same && !mode_changed {
changes.remove(path);
} else {
changes.insert(path.to_path_buf(), FileStatus::Modified);
}
} else if changes.get(path) != Some(&FileStatus::Added) {
let mut ignored = git_without_literal_pathspecs(&checkout);
ignored
.arg("--git-dir")
.arg(&store)
.arg("--work-tree")
.arg(&checkout)
.args(["check-ignore", "--no-index", "-q", "--"])
.arg(path);
match run_status(ignored, &[], cancelled)?.0 {
Some(1) => {
changes.insert(path.to_path_buf(), FileStatus::Added);
}
Some(0) => {}
_ => return Err("Cannot read working-file ignore rules".into()),
}
}
}
if changes.len() > WORKING_PATH_LIMIT {
return Err("Working-file catalog exceeds 100000 paths".into());
}
let mut result = Vec::with_capacity(changes.len());
for (path, status) in changes {
if cancelled() {
return Err("Working-file query cancelled".into());
}
if !path.starts_with(prefix) || !working_safe(&checkout, &path)? {
continue;
}
if status == FileStatus::Removed
&& entries
.get(&path)
.is_some_and(|(mode, _)| !matches!(mode.as_str(), "100644" | "100755"))
{
continue;
}
let old_path = renames
.remove(&path)
.and_then(|old| old.strip_prefix(prefix).ok().map(Path::to_path_buf));
let status = if status == FileStatus::Renamed && old_path.is_none() {
FileStatus::Added
} else {
status
};
result.push(WorkingFileChange {
path: path
.strip_prefix(prefix)
.map_err(|e| e.to_string())?
.to_path_buf(),
old_path,
status,
});
}
Ok(result)
}
struct Remote {
name: String,
url: String,
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct FileData {
mode: String,
bytes: Vec<u8>,
object: Option<(PathBuf, String)>,
// Conflicts retain their Git blob identity, but selected contents come from jj.
projection: Option<(PathBuf, String)>,
notice: Option<String>,
}
type Snapshot = BTreeMap<PathBuf, FileData>;
struct PreparedComparison {
id: u64,
selected: [Revision; 2],
selected_ids: [Option<String>; 2],
revisions: [String; 2],
mode: ComparisonMode,
left: Snapshot,
right: Snapshot,
files: Vec<(PathBuf, Option<PathBuf>, FileStatus)>,
before: String,
after: String,
working_after: bool,
working_anchor: Option<String>,
pinned_commit: Option<String>,
pinned_checkout: Option<String>,
editable_after: bool,
}
#[derive(Debug)]
pub enum CheckoutRefresh {
Checkout(Comparison),
Bookmark(Comparison),
}
pub struct Repository {
root: PathBuf,
kind: RepoKind,
store: PathBuf,
temporary: Temporary,
private: Option<(PathBuf, PathBuf)>, // workspace and Git object store
remotes: Vec<Remote>,
remote_refs: BTreeMap<(String, String), String>,
loaded: BTreeSet<String>,
registered: BTreeSet<String>,
comparison: Option<PreparedComparison>,
cancelled: Arc<AtomicBool>,
}
/// Discover the canonical checkout root without opening a comparison repository.
pub fn discover_root(path: &Path) -> Result<PathBuf, String> {
discover_root_with_cancellation(path, &AtomicBool::new(false))
}
fn discover_root_with_cancellation(path: &Path, cancelled: &AtomicBool) -> Result<PathBuf, String> {
let path = fs::canonicalize(path).map_err(|error| error.to_string())?;
let root = path
.ancestors()
.find(|path| path.join(".jj").exists() || path.join(".git").exists())
.ok_or("No Jujutsu or Git working-tree repository found")?;
if root.join(".jj").exists() {
let mut command = jj_command(root);
command.arg("root");
let output = checked(run(command, vec![], cancelled)?)?;
let root = path_bytes(output.strip_suffix(b"\n").unwrap_or(&output))?;
fs::canonicalize(root).map_err(|error| error.to_string())
} else {
Ok(root.to_path_buf())
}
}
impl Repository {
pub fn open(root: &Path, cancelled: Arc<AtomicBool>) -> Result<Self, String> {
let actual = discover_root_with_cancellation(root, &cancelled)?;
let (root, kind, store) = if actual.join(".jj").exists() {
let store = store_for_jj(&actual)?;
(actual, RepoKind::Jujutsu, store)
} else {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(&actual)
.args(["rev-parse", "--absolute-git-dir"])
.env("GIT_OPTIONAL_LOCKS", "0");
let output = checked(run(cmd, vec![], &cancelled)?)?;
let store = path_bytes(output.strip_suffix(b"\n").unwrap_or(&output))?;
(actual, RepoKind::Git, store)
};
Ok(Self {
root,
kind,
store,
temporary: Temporary::new()?,
private: None,
remotes: vec![],
remote_refs: BTreeMap::new(),
loaded: BTreeSet::new(),
registered: BTreeSet::new(),
comparison: None,
cancelled,
})
}
fn git(&self, store: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
let mut cmd = git_command(store);
cmd.args(args).current_dir(&self.root);
checked(run(cmd, vec![], &self.cancelled)?)
}
fn jj(&self, root: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
let mut cmd = jj_command(root);
cmd.args(args);
checked(run(cmd, vec![], &self.cancelled)?)
}
fn check_cancelled(&self) -> Result<(), String> {
if self.cancelled.load(Ordering::Acquire) {
Err("Diff operation cancelled".into())
} else {
Ok(())
}
}
fn revisions(
&self,
root: &Path,
store: &Path,
entries: &mut Vec<Revision>,
notices: &mut Vec<String>,
) -> Result<(), String> {
if self.kind == RepoKind::Jujutsu {
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "]\n""#;
let revset = if root == self.root {
"all()"
} else {
"::remote_bookmarks() | ::remote_tags()"
};
let output =
text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?;
for line in output.lines() {
let (id, change, description, bookmarks, remote_names, remotes): (
String,
String,
String,
Vec<String>,
Vec<String>,
Vec<String>,
) = serde_json::from_str(line)
.map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?;
let target = RevisionTarget::Commit(id.clone());
entries.push(Revision {
target: target.clone(),
label: change.clone(),
description: description.trim_end_matches('\n').into(),
category: Category::Revisions,
});
for (name, remote) in bookmarks.into_iter().map(|name| (name, None)).chain(
remote_names
.into_iter()
.zip(remotes)
.map(|(name, remote)| (name, Some(remote))),
) {
let label = remote
.as_ref()
.map_or_else(|| name.clone(), |remote| format!("{name}@{remote}"));
entries.push(Revision {
target: if root == self.root {
RevisionTarget::Bookmark { name, remote }
} else {
target.clone()
},
label,
description: format!("{change} {id} {}", description.trim_end()),
category: Category::Bookmarks,
});
}
}
} else {
let output = self.git(
store,
&[
"log",
"--no-color",
"--no-show-signature",
"--no-decorate",
"--all",
"--format=%H%x00%B%x00",
],
)?;
let mut records = output.split(|b| *b == 0);
while let Some(id) = records.next() {
let id = std::str::from_utf8(id)
.map_err(|_| "Invalid Git commit ID")?
.trim();
if id.is_empty() {
continue;
}
let description = records.next().ok_or("Truncated Git log")?;
entries.push(Revision {
target: RevisionTarget::Commit(id.into()),
label: id.into(),
description: String::from_utf8_lossy(description).trim_end().into(),
category: Category::Commits,
});
}
let references = text(self.git(
store,
&[
"for-each-ref",
"--format=%(refname)",
"refs/heads",
"refs/remotes",
"refs/tags",
],
)?)?;
let mut request = Vec::new();
for reference in references.lines() {
request.extend_from_slice(reference.as_bytes());
request.extend_from_slice(b"^{commit}\n");
}
let mut cmd = git_command(store);
cmd.args(["cat-file", "--batch-check"]);
let resolved = text(checked(run(cmd, request, &self.cancelled)?)?)?;
let mut records = resolved.lines();
for reference in references.lines() {
let record = records.next().ok_or("Truncated Git reference metadata")?;
let mut fields = record.split_whitespace();
let id = fields.next().ok_or("Missing Git reference object ID")?;
if fields.next() != Some("commit") {
notices.push(format!(
"{reference} does not point to a commit; non-commit tags cannot be compared"
));
continue;
}
let category = if reference.starts_with("refs/tags/") {
Category::Tags
} else {
Category::Branches
};
let label = reference
.strip_prefix("refs/heads/")
.or_else(|| reference.strip_prefix("refs/remotes/"))
.or_else(|| reference.strip_prefix("refs/tags/"))
.unwrap_or(reference);
entries.push(Revision {
target: RevisionTarget::Commit(id.into()),
label: label.into(),
description: format!("{reference} {id}"),
category,
});
}
}
Ok(())
}
fn discover_remotes(&mut self) -> Result<(), String> {
let mut remotes = vec![];
if self.kind == RepoKind::Jujutsu {
for line in text(self.jj(&self.root, &["git", "remote", "list"])?)?.lines() {
let (name, url) = line
.split_once(char::is_whitespace)
.ok_or("Malformed Jujutsu remote metadata")?;
remotes.push(Remote {
name: name.into(),
url: url.trim_start().into(),
});
}
} else {
for name in text(self.git(&self.store, &["remote"])?)?.lines() {
let url = text(self.git(&self.store, &["remote", "get-url", name])?)?
.trim_end_matches('\n')
.to_owned();
remotes.push(Remote {
name: name.into(),
url,
});
}
}
for remote in &mut remotes {
if !remote.url.contains(':') && !Path::new(&remote.url).is_absolute() {
remote.url = self.root.join(&remote.url).to_string_lossy().into_owned();
}
}
self.remotes = remotes;
Ok(())
}
fn advertised(&self, url: &str) -> Result<Vec<(String, String)>, String> {
let mut cmd = Command::new("git");
cmd.args(["ls-remote", "--refs", "--heads", "--tags", "--", url])
.current_dir(&self.root)
.env("GIT_TERMINAL_PROMPT", "0");
let output = text(checked(run(cmd, vec![], &self.cancelled)?)?)?;
output
.lines()
.map(|line| {
let (id, reference) = line
.split_once('\t')
.ok_or("Malformed remote reference metadata")?;
if !matches!(id.len(), 40 | 64)
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
|| !(reference.starts_with("refs/heads/")
|| reference.starts_with("refs/tags/"))
{
return Err("Invalid remote reference metadata".into());
}
Ok((reference.into(), id.into()))
})
.collect()
}
pub fn catalog(&mut self) -> Result<Catalog, String> {
self.check_cancelled()?;
self.discover_remotes()?;
let category = if self.kind == RepoKind::Jujutsu {
Category::Revisions
} else {
Category::Commits
};
let mut entries = vec![Revision {
target: RevisionTarget::Working,
label: "Working checkout".into(),
description:
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
category,
}];
let mut notices = vec![];
self.revisions(&self.root, &self.store, &mut entries, &mut notices)?;
if let Some((root, store)) = &self.private {
self.revisions(root, store, &mut entries, &mut notices)?;
}
for remote in &self.remotes {
for &view in self.kind.categories() {
let history = view == category;
entries.push(Revision {
target: if history {
RevisionTarget::FetchRemote(remote.name.clone())
} else {
RevisionTarget::ListRemote(remote.name.clone())
},
label: format!(
"Load {} from {}",
if history { "history" } else { "refs" },
remote.name
),
description: if history {
"Fetch reachable history into a private comparison repository"
} else {
"List remote references without fetching history"
}
.into(),
category: view,
});
}
for ((name, reference), id) in &self.remote_refs {
if name != &remote.name {
continue;
}
let tag = reference.starts_with("refs/tags/");
let label = reference
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
.unwrap();
if !tag || self.kind == RepoKind::Git {
entries.push(Revision {
target: RevisionTarget::Remote {
remote: name.clone(),
reference: reference.clone(),
},
label: format!("{label}@{name}"),
description: id.clone(),
category: if tag {
Category::Tags
} else if self.kind == RepoKind::Jujutsu {
Category::Bookmarks
} else {
Category::Branches
},
});
}
}
}
self.check_cancelled()?;
let mut seen = BTreeSet::new();
entries.retain(|entry| {
let key = format!("{:?}|{:?}|{}", entry.target, entry.category, entry.label);
seen.insert(key)
});
Ok(Catalog {
kind: self.kind,
entries,
notice: if notices.is_empty() {
None
} else {
Some(notices.join("; "))
},
})
}
fn ensure_private(&mut self) -> Result<(), String> {
if self.private.is_some() {
return Ok(());
}
let root = self.temporary.0.join("repository");
if self.kind == RepoKind::Jujutsu {
let mut cmd = Command::new("jj");
cmd.args([
"--no-pager",
"--color=never",
"git",
"init",
"--no-colocate",
])
.arg(&root);
checked(run(cmd, vec![], &self.cancelled)?)?;
let store = store_for_jj(&root)?;
self.private = Some((root, store));
} else {
let format = text(self.git(&self.store, &["rev-parse", "--show-object-format"])?)?;
let mut cmd = Command::new("git");
cmd.args(["init", "--bare"])
.arg(format!("--object-format={}", format.trim()))
.arg(&root);
checked(run(cmd, vec![], &self.cancelled)?)?;
self.private = Some((root.clone(), root));
}
// Only the private store gains alternates; active refs and objects stay untouched.
let objects = self.git(
&self.store,
&[
"rev-parse",
"--path-format=absolute",
"--git-path",
"objects",
],
)?;
let mut alternate = vec![b'"'];
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
alternate.push(byte);
} else {
write!(&mut alternate, "\\{byte:03o}").unwrap();
}
}
alternate.extend_from_slice(b"\"\n");
let store = &self.private.as_ref().unwrap().1;
fs::write(store.join("objects/info/alternates"), alternate).map_err(|e| e.to_string())?;
Ok(())
}
fn load_remote(&mut self, name: &str, reference: Option<&str>) -> Result<(), String> {
if self.remotes.is_empty() {
self.discover_remotes()?;
}
let url = self
.remotes
.iter()
.find(|r| r.name == name)
.ok_or_else(|| format!("Unknown configured remote: {name}"))?
.url
.clone();
self.ensure_private()?;
let (root, store) = self.private.as_ref().unwrap();
if self.kind == RepoKind::Jujutsu {
if !self.registered.contains(name) {
self.jj(root, &["git", "remote", "add", "--", name, &url])?;
self.registered.insert(name.into());
}
self.jj(
root,
&[
"git",
"fetch",
"--remote",
&format!("exact:{name}"),
"--branch",
&reference.map_or_else(
|| Ok("glob:*".to_owned()),
|reference| {
reference
.strip_prefix("refs/heads/")
.map(|branch| format!("exact:{branch}"))
.ok_or("Jujutsu remote selection requires a bookmark")
},
)?,
],
)?;
} else if let Some(reference) = reference {
self.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{reference}:refs/vamp-selected/{name}/{reference}"),
],
)?;
} else {
self.git(
store,
&[
"fetch",
"--no-write-fetch-head",
"--",
&url,
&format!("+refs/heads/*:refs/remotes/{name}/*"),
&format!("+refs/tags/*:refs/vamp-tags/{name}/*"),
],
)?;
}
if reference.is_none() {
self.loaded.insert(name.into());
let refs = self.advertised(&url)?;
self.remote_refs.retain(|(remote, _), _| remote != name);
for (reference, id) in refs {
self.remote_refs.insert((name.into(), reference), id);
}
}
Ok(())
}
pub fn list_remote(&mut self, name: &str) -> Result<Catalog, String> {
self.discover_remotes()?;
let remote = self
.remotes
.iter()
.find(|remote| remote.name == name)
.ok_or_else(|| format!("Unknown configured remote: {name}"))?;
let refs = self.advertised(&remote.url)?;
self.remote_refs.retain(|(remote, _), _| remote != name);
self.remote_refs.extend(
refs.into_iter()
.map(|(reference, id)| ((name.into(), reference), id)),
);
self.catalog()
}
pub fn fetch_remote(&mut self, remote: &str) -> Result<Catalog, String> {
self.load_remote(remote, None)?;
self.catalog()
}
fn pull_request_remote(
&mut self,
name: &str,
) -> Result<(String, String, Option<String>), String> {
self.discover_remotes()?;
let remote = if name.is_empty() {
self.remotes
.iter()
.find(|remote| remote.name == "origin")
.or_else(|| (self.remotes.len() == 1).then(|| &self.remotes[0]))
.ok_or("Choose a remote with :pull_requests <remote>")?
} else {
self.remotes
.iter()
.find(|remote| remote.name == name)
.ok_or_else(|| format!("Unknown configured remote: {name}"))?
};
let mut config = git_command(&self.store);
config.args([
"config",
"--get",
&format!("remote.{}.vamp-forge", remote.name),
]);
let output = run(config, vec![], &self.cancelled)?;
let provider = if output.code == Some(1) {
None
} else {
Some(text(checked(output)?)?.trim().to_owned())
};
Ok((remote.name.clone(), remote.url.clone(), provider))
}
pub fn pull_request_page(
&mut self,
name: &str,
closed: bool,
page: u32,
) -> Result<PullRequestPage, String> {
let (remote, url, provider) = self.pull_request_remote(name)?;
let page = crate::forge::page(
&url,
provider.as_deref(),
closed,
page,
&self.root,
&self.cancelled,
)
.map_err(|error| error.replace("remote.<name>", &format!("remote.{remote}")))?;
Ok(PullRequestPage {
kind: self.kind,
remote: remote.clone(),
has_more: page.has_more,
entries: page
.entries
.into_iter()
.map(|pull| {
(
Revision {
target: RevisionTarget::PullRequest {
remote: remote.clone(),
number: pull.number,
},
label: format!("#{} {}", pull.number, pull.title),
description: pull.description,
category: if pull.closed {
Category::Closed
} else {
Category::Open
},
},
pull.created_at,
)
})
.collect(),
})
}
pub fn resolve_pull_request(
&mut self,
revision: Revision,
) -> Result<(Revision, Revision), String> {
let RevisionTarget::PullRequest { remote, number } = &revision.target else {
return Err("Select a pull request".into());
};
let (remote, url, provider) = self.pull_request_remote(remote)?;
let pull = crate::forge::resolve(
&url,
provider.as_deref(),
*number,
&self.root,
&self.cancelled,
)
.map_err(|error| error.replace("remote.<name>", &format!("remote.{remote}")))?;
let revisions = pull.revisions?;
let before = Revision {
target: RevisionTarget::RemoteCommit {
remote: remote.clone(),
reference: revisions.base_ref.clone(),
id: revisions.base_sha,
},
label: revisions.base_ref,
description: String::new(),
category: revision.category,
};
let after = Revision {
target: RevisionTarget::RemoteCommit {
remote,
reference: revisions.head_ref,
id: revisions.head_sha,
},
..revision
};
Ok((before, after))
}
fn bookmark_ids(&self, targets: [&RevisionTarget; 2]) -> Result<[Option<String>; 2], String> {
let mut ids = [None, None];
if !targets
.iter()
.any(|target| matches!(target, RevisionTarget::Bookmark { .. }))
{
return Ok(ids);
}
if self.kind != RepoKind::Jujutsu {
return Err("Bookmarks require a Jujutsu repository".into());
}
// Resolve both endpoints from one operation, comparing raw names rather
// than interpreting bookmark names as revsets or splitting on '@'.
let template = r#""[" ++ json(name) ++ "," ++ json(remote) ++ "," ++ json(conflict) ++ "," ++ if(normal_target, json(normal_target.commit_id()), "null") ++ "]\n""#;
let output = text(self.jj(
&self.root,
&["bookmark", "list", "--all-remotes", "-T", template],
)?)?;
for line in output.lines() {
let (name, remote, conflict, id): (String, Option<String>, bool, Option<String>) =
serde_json::from_str(line)
.map_err(|e| format!("Cannot decode Jujutsu bookmark: {e}"))?;
for (index, target) in targets.iter().enumerate() {
if let RevisionTarget::Bookmark {
name: selected_name,
remote: selected_remote,
} = target
&& selected_name == &name
&& selected_remote == &remote
{
if conflict {
return Err(format!("Bookmark {name} is conflicted"));
}
ids[index] = id.clone();
}
}
}
for (index, target) in targets.iter().enumerate() {
if let RevisionTarget::Bookmark { name, remote } = target
&& ids[index].is_none()
{
let label = remote
.as_ref()
.map_or_else(|| name.clone(), |remote| format!("{name}@{remote}"));
return Err(format!("Bookmark {label} is missing or deleted"));
}
}
Ok(ids)
}
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
let id = match target {
RevisionTarget::Bookmark { .. } => {
return Ok((
self.store.clone(),
self.bookmark_ids([target, target])?[0].take().unwrap(),
));
}
RevisionTarget::Commit(id) | RevisionTarget::RemoteCommit { id, .. } => {
if !matches!(id.len(), 40 | 64)
|| (self.kind == RepoKind::Jujutsu && id.len() != 40)
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
{
return Err(
"Invalid immutable commit ID (expected a full hexadecimal object ID)"
.into(),
);
}
if id.bytes().all(|b| b == b'0') && self.kind == RepoKind::Jujutsu {
return Ok((self.store.clone(), id.clone()));
}
id.clone()
}
RevisionTarget::Remote { remote, reference } => self
.remote_refs
.get(&(remote.clone(), reference.clone()))
.ok_or_else(|| {
format!("Remote reference is no longer advertised: {reference}@{remote}")
})?
.clone(),
RevisionTarget::Working => return Err("Working checkout is not a commit".into()),
RevisionTarget::FetchRemote(_)
| RevisionTarget::ListRemote(_)
| RevisionTarget::PullRequest { .. } => {
return Err("Select a revision, not a history loader".into());
}
};
if let Some(resolved) = self.resolve_stored(&id)? {
return Ok(resolved);
}
if let RevisionTarget::Remote { remote, reference } = target {
self.load_remote(remote, Some(reference))?;
if let Some(resolved) = self.resolve_stored(&id)? {
return Ok(resolved);
}
}
if let RevisionTarget::RemoteCommit {
remote, reference, ..
} = target
{
if !reference.starts_with("refs/") {
return Err("Invalid pull request reference".into());
}
self.git(&self.store, &["check-ref-format", reference])?;
self.discover_remotes()?;
let url = self
.remotes
.iter()
.find(|entry| &entry.name == remote)
.ok_or_else(|| format!("Unknown configured remote: {remote}"))?
.url
.clone();
self.ensure_private()?;
let store = &self.private.as_ref().unwrap().1;
// Closed PR refs can disappear; servers may still serve their immutable commits.
if self
.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{id}:refs/vamp-pull-request/selected"),
],
)
.is_err()
{
self.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{reference}:refs/vamp-pull-request/selected"),
],
)
.map_err(|error| {
format!("Pull request commit unavailable; reopen :pull_requests: {error}")
})?;
}
if let Some(resolved) = self.resolve_stored(&id)? {
return Ok(resolved);
}
return Err(
"Pull request changed or its commit is unavailable; reopen :pull_requests".into(),
);
}
Err(format!(
"Commit {id} is unavailable locally or in loaded remote history"
))
}
fn resolve_stored(&self, id: &str) -> Result<Option<(PathBuf, String)>, String> {
for store in
std::iter::once(&self.store).chain(self.private.as_ref().map(|(_, store)| store))
{
let mut cmd = git_command(store);
cmd.args(["rev-parse", "--verify", &format!("{id}^{{commit}}")])
.current_dir(&self.root);
let output = run(cmd, vec![], &self.cancelled)?;
if output.code == Some(0) {
return Ok(Some((store.clone(), text(output.stdout)?.trim().into())));
}
if !matches!(output.code, Some(1 | 128)) {
checked(output)?;
}
}
Ok(None)
}
fn conflicted_snapshot(&self, root: &Path, store: &Path, id: &str) -> Result<Snapshot, String> {
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
let template = r#""[" ++ json(path) ++ "," ++ json(conflict) ++ "]\n""#;
let entries = text(self.jj(root, &["file", "list", "-r", &revset, "-T", template])?)?;
let mut blobs = self.git_snapshot(store, id)?;
let mut snapshot = BTreeMap::new();
for entry in entries.lines() {
self.check_cancelled()?;
let (path, conflict): (String, bool) = serde_json::from_str(entry)
.map_err(|e| format!("Cannot decode Jujutsu conflict entry: {e}"))?;
let path = PathBuf::from(path);
safe_path(&path)?;
// jj's logical file list excludes its private merge-tree encoding.
let mut file = blobs
.remove(&path)
.ok_or_else(|| format!("Missing Jujutsu tree entry: {}", path.display()))?;
if conflict {
file.projection = Some((root.to_path_buf(), id.into()));
file.notice = Some(
"Unresolved Jujutsu conflict: showing CLI-rendered conflict projection".into(),
);
}
snapshot.insert(path, file);
}
Ok(snapshot)
}
fn commit_snapshot(&self, store: &Path, id: &str) -> Result<Snapshot, String> {
if id.bytes().all(|b| b == b'0') {
return Ok(BTreeMap::new());
}
// Jujutsu stores unresolved merge trees specially; do not show their raw Git encoding.
if self.kind == RepoKind::Jujutsu {
let root = if store == self.store {
&self.root
} else {
&self.private.as_ref().unwrap().0
};
let conflict = text(self.jj(
root,
&[
"log",
"--no-graph",
"-r",
&format!("present(commit_id({}))", serde_json::to_string(id).unwrap()),
"-T",
"conflict",
],
)?)?;
if conflict.trim() == "true" {
return self.conflicted_snapshot(root, store, id);
}
}
self.git_snapshot(store, id)
}
fn git_snapshot(&self, store: &Path, id: &str) -> Result<Snapshot, String> {
let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?;
let mut snapshot = BTreeMap::new();
for record in output.split(|b| *b == 0).filter(|r| !r.is_empty()) {
let tab = record
.iter()
.position(|b| *b == b'\t')
.ok_or("Malformed Git tree")?;
let header =
std::str::from_utf8(&record[..tab]).map_err(|_| "Malformed Git tree header")?;
let fields: Vec<_> = header.split(' ').collect();
if fields.len() != 3 {
return Err("Malformed Git tree entry".into());
}
let path = path_bytes(&record[tab + 1..])?;
safe_path(&path)?;
let (object, notice) = if fields[1] == "commit" {
(
None,
Some(format!(
"Submodule commit {} (contents are not traversed)",
fields[2]
)),
)
} else if fields[1] == "blob" {
(Some((store.to_path_buf(), fields[2].to_owned())), None)
} else {
return Err(format!("Unsupported tree entry type: {}", fields[1]));
};
snapshot.insert(
path,
FileData {
mode: fields[0].into(),
bytes: vec![],
object,
projection: None,
notice,
},
);
}
Ok(snapshot)
}
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
for (path, file) in snapshot.iter_mut() {
self.check_cancelled()?;
if let Some((root, id)) = &file.projection {
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
let pattern =
jj_file_pattern(path.to_str().ok_or("Non-UTF-8 Jujutsu conflict path")?);
file.bytes = self
.jj(
root,
&["file", "show", "-r", &revset, "-T", "", "--", &pattern],
)
.map_err(|error| format!("{}: {error}", path.display()))?;
} else if let Some((store, id)) = &file.object {
let size = text(self.git(store, &["cat-file", "-s", id])?)?
.trim()
.parse::<usize>()
.map_err(|_| "Invalid Git object size")?;
if size > COMPARISON_FILE_LIMIT {
return Err(format!(
"{} exceeds the 64 MiB per-side comparison file limit",
path.display()
));
}
// One blob per subprocess output: never concatenate both sides or
// copy a cat-file batch into a second allocation.
file.bytes = self.git(store, &["cat-file", "blob", id])?;
} else {
continue;
}
if file.bytes.len() > COMPARISON_FILE_LIMIT {
return Err(format!(
"{} exceeds the 64 MiB per-side comparison file limit",
path.display()
));
}
file.object = None;
file.projection = None;
}
Ok(())
}
fn working_snapshot(&mut self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
self.ensure_private()?;
let store = self.private.as_ref().unwrap().1.clone();
// Freeze each file once, then let Git ingest them in one batch. Keeping
// object identities makes catalog comparison lazy even against Working.
let frozen = Temporary::new()?;
let mut frozen_paths = Vec::new();
let mut hash_input = Vec::new();
let mut tracked = BTreeSet::new();
let mut submodules = BTreeSet::new();
if self.kind == RepoKind::Git {
for record in self
.git(&self.store, &["ls-files", "--stage", "-z"])?
.split(|b| *b == 0)
.filter(|p| !p.is_empty())
{
let tab = record
.iter()
.position(|b| *b == b'\t')
.ok_or("Malformed Git index entry")?;
let path = path_bytes(&record[tab + 1..])?;
if record.starts_with(b"160000 ") {
submodules.insert(path.clone());
}
tracked.insert(path);
}
} else {
let output = text(self.jj(
&self.root,
&[
"file",
"list",
"-T",
"\"[\" ++ json(path) ++ \",\" ++ json(file_type) ++ \"]\\n\"",
],
)?)?;
for line in output.lines() {
let (path, kind): (String, String) =
serde_json::from_str(line).map_err(|e| e.to_string())?;
let path = PathBuf::from(path);
if kind == "git-submodule" {
submodules.insert(path.clone());
}
tracked.insert(path);
}
}
let mut paths = tracked;
if self.kind == RepoKind::Git {
for path in self
.git(
&self.store,
&["ls-files", "--others", "--exclude-standard", "-z"],
)?
.split(|b| *b == 0)
.filter(|p| !p.is_empty())
{
paths.insert(path_bytes(path)?);
}
} else {
let excluded = submodules.clone();
let checkout = self.root.clone();
let walker = ignore::WalkBuilder::new(&self.root)
.hidden(false)
.ignore(false)
.parents(false)
.require_git(false)
.follow_links(false)
.filter_entry(move |entry| {
entry.file_name() != OsStr::new(".git")
&& entry.file_name() != OsStr::new(".jj")
&& !entry
.path()
.strip_prefix(&checkout)
.is_ok_and(|path| excluded.contains(path))
})
.build();
for entry in walker {
self.check_cancelled()?;
let entry = entry.map_err(|e| e.to_string())?;
if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
paths.insert(
entry
.path()
.strip_prefix(&self.root)
.map_err(|e| e.to_string())?
.to_path_buf(),
);
}
}
}
let overlays: BTreeMap<_, _> = buffers
.iter()
.filter_map(|(path, text)| {
let relative = path.strip_prefix(&self.root).ok()?;
if safe_path(relative).is_err()
|| !path.is_absolute()
|| text.as_bytes().contains(&0)
{
return None;
}
// Reject symlinks in every component, not just the final file.
let mut full = self.root.clone();
for component in relative.components() {
full.push(component);
let metadata = fs::symlink_metadata(&full).ok()?;
if metadata.file_type().is_symlink() {
return None;
}
}
fs::symlink_metadata(&full)
.ok()?
.is_file()
.then_some((relative.to_path_buf(), text))
})
.collect();
paths.extend(overlays.keys().cloned());
let mut total = 0;
let mut snapshot = BTreeMap::new();
for path in paths {
self.check_cancelled()?;
safe_path(&path)?;
let full = self.root.join(&path);
// Never follow an intermediate symlink outside the checkout.
let mut parent = full.parent();
let mut blocked = false;
while let Some(p) = parent {
if p == self.root {
break;
}
if fs::symlink_metadata(p).is_ok_and(|m| m.file_type().is_symlink()) {
blocked = true;
break;
}
parent = p.parent();
}
if blocked {
continue;
} // The parent symlink itself is the on-disk replacement.
let metadata = match fs::symlink_metadata(&full) {
Ok(m) => m,
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::NotADirectory =>
{
continue;
}
Err(e) => return Err(format!("{}: {e}", path.display())),
};
if metadata.is_dir() {
if submodules.contains(&path) || full.join(".git").exists() {
let notice = if full.join(".git").exists() {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(&full)
.args(["rev-parse", "--verify", "HEAD"])
.env("GIT_OPTIONAL_LOCKS", "0");
let output = run(cmd, vec![], &self.cancelled)?;
if output.code == Some(0) {
format!(
"Submodule commit {} (contents are not traversed)",
text(output.stdout)?.trim()
)
} else {
"Submodule revision is unavailable; contents are not traversed".into()
}
} else {
"Submodule directory is not initialized; contents are not traversed".into()
};
snapshot.insert(
path,
FileData {
mode: "160000".into(),
bytes: vec![],
object: None,
projection: None,
notice: Some(notice),
},
);
}
continue;
}
let (mode, bytes, notice) = if metadata.file_type().is_symlink() {
let target = fs::read_link(&full).map_err(|e| e.to_string())?;
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt;
target.as_os_str().as_bytes().to_vec()
};
#[cfg(not(unix))]
let bytes = target.to_string_lossy().as_bytes().to_vec();
("120000".into(), bytes, None)
} else if metadata.is_file() {
if metadata.len() > COMPARISON_FILE_LIMIT as u64 {
return Err(format!(
"{} exceeds the 64 MiB per-side comparison file limit",
path.display()
));
}
let mut source = fs::File::open(&full)
.map_err(|e| format!("{}: {e}", path.display()))?
.take(COMPARISON_FILE_LIMIT as u64 + 1);
let mut bytes = Vec::with_capacity(metadata.len() as usize);
let mut chunk = [0; 64 * 1024];
loop {
self.check_cancelled()?;
let count = source
.read(&mut chunk)
.map_err(|e| format!("{}: {e}", path.display()))?;
if count == 0 {
break;
}
if bytes.len() + count > COMPARISON_FILE_LIMIT {
return Err(format!(
"{} grew beyond the 64 MiB per-side comparison file limit",
path.display()
));
}
bytes.extend_from_slice(&chunk[..count]);
}
if !bytes.contains(&0)
&& std::str::from_utf8(&bytes).is_ok()
&& let Some(text) = overlays.get(&path)
{
if text.len() > COMPARISON_FILE_LIMIT {
return Err(format!(
"{} exceeds the 64 MiB per-side comparison file limit",
path.display()
));
}
bytes.clear();
bytes.extend_from_slice(text.as_bytes());
}
#[cfg(unix)]
let executable = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
};
#[cfg(not(unix))]
let executable = false;
(
if executable { "100755" } else { "100644" }.into(),
bytes,
None,
)
} else {
(
"unsupported".into(),
vec![],
Some("Unsupported on-disk file type; contents are not read".into()),
)
};
total += bytes.len();
if total > WORKING_SNAPSHOT_LIMIT {
return Err("Working snapshot exceeds the 512 MiB comparison limit".into());
}
let object = if notice.is_none() {
let name = frozen_paths.len().to_string();
fs::write(frozen.0.join(&name), &bytes)
.map_err(|e| format!("{}: {e}", path.display()))?;
writeln!(&mut hash_input, "{name}").unwrap();
frozen_paths.push(path.clone());
Some((store.clone(), String::new()))
} else {
None
};
snapshot.insert(
path,
FileData {
mode,
bytes: vec![],
object,
projection: None,
notice,
},
);
}
if !frozen_paths.is_empty() {
let mut hash = git_command(&store);
hash.args(["hash-object", "--no-filters", "-w", "--stdin-paths"])
.current_dir(&frozen.0);
let hashes = text(checked(run(hash, hash_input, &self.cancelled)?)?)?;
let mut hashes = hashes.lines();
for path in frozen_paths {
self.check_cancelled()?;
let id = hashes.next().ok_or("Truncated Working snapshot hashes")?;
snapshot.get_mut(&path).unwrap().object = Some((store.clone(), id.into()));
}
if hashes.next().is_some() {
return Err("Unexpected Working snapshot hashes".into());
}
}
Ok(snapshot)
}
fn snapshot_revision(
&mut self,
target: &RevisionTarget,
snapshot: &Snapshot,
) -> Result<String, String> {
if let RevisionTarget::Commit(id) = target {
return Ok(id.clone());
}
self.ensure_private()?;
let store = &self.private.as_ref().unwrap().1;
let mut input = b"vamp-working-snapshot-v2\0".to_vec();
for (path, file) in snapshot {
self.check_cancelled()?;
for field in [
path.as_os_str().as_encoded_bytes(),
file.mode.as_bytes(),
file.object
.as_ref()
.map_or("", |(_, id)| id.as_str())
.as_bytes(),
file.notice.as_deref().unwrap_or("").as_bytes(),
] {
input.extend_from_slice(&(field.len() as u64).to_be_bytes());
input.extend_from_slice(field);
}
}
let mut command = git_command(store);
command.args(["hash-object", "--no-filters", "--stdin"]);
let id = text(checked(run(command, input, &self.cancelled)?)?)?;
Ok(format!("working:{}", id.trim()))
}
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
if *target == RevisionTarget::Working {
self.working_snapshot(&[])
} else {
let (store, id) = self.resolve(target)?;
self.commit_snapshot(&store, &id)
}
}
fn merge_base(&mut self, before: &str, after: &str) -> Result<String, String> {
const JJ_ROOT: &str = "0000000000000000000000000000000000000000";
if self.kind == RepoKind::Jujutsu && (before == JJ_ROOT || after == JJ_ROOT) {
return Ok(JJ_ROOT.into());
}
let store = self
.private
.as_ref()
.map_or(&self.store, |(_, store)| store);
let mut command = git_command(store);
command.args(["merge-base", "--all", before, after]);
let output = run(command, vec![], &self.cancelled)?;
if output.code == Some(1) && output.stdout.is_empty() {
// Every jj history has the synthetic root, absent from Git's graph.
return if self.kind == RepoKind::Jujutsu {
Ok(JJ_ROOT.into())
} else {
Err(
"Branch changes: no merge base; use Snapshots to compare unrelated histories"
.into(),
)
};
}
let bases = text(checked(output).map_err(|e| format!("Cannot resolve merge base: {e}"))?)?;
let mut bases = bases.lines();
let base = bases.next().ok_or("Branch changes: no merge base")?;
if bases.next().is_some() {
return Err(
"Branch changes: multiple merge bases; select Snapshots or different endpoints"
.into(),
);
}
Ok(base.into())
}
fn pinned_checkout(&self, id: &str) -> Result<Option<String>, String> {
if self.kind == RepoKind::Git {
return Ok(Some(id.into()));
}
// Private remote history need not exist in the active jj operation.
let revset = format!("present(commit_id({}))", serde_json::to_string(id).unwrap());
let identity = text(self.jj(
&self.root,
&["log", "--no-graph", "-r", &revset, "-T", "change_id"],
)?)?;
let identity = identity.trim();
Ok((!identity.is_empty()).then(|| identity.into()))
}
fn working_anchor(&self) -> Result<Option<String>, String> {
if self.kind == RepoKind::Jujutsu {
return Ok(Some(
text(self.jj(
&self.root,
&["log", "--no-graph", "-r", "@", "-T", "change_id"],
)?)?
.trim()
.into(),
));
}
let mut command = git_command(&self.store);
command.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
let output = run(command, vec![], &self.cancelled)?;
if output.code == Some(1) {
return Ok(None); // An unborn checkout has no commit anchor yet.
}
Ok(Some(text(checked(output)?)?.trim().into()))
}
fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> {
if before == after || after.is_none() {
return Ok(false);
}
let Some(before) = before else {
return Ok(true);
};
if self.kind == RepoKind::Jujutsu {
let revset = format!(
"ancestors(@) & change_id({})",
serde_json::to_string(before).unwrap()
);
return Ok(!self
.jj(
&self.root,
&["log", "--no-graph", "-r", &revset, "-T", "change_id"],
)?
.is_empty());
}
let mut command = git_command(&self.store);
command.args(["merge-base", "--is-ancestor", before, after.unwrap()]);
let output = run(command, vec![], &self.cancelled)?;
if output.code == Some(1) {
Ok(false)
} else {
checked(output).map(|_| true)
}
}
/// Freeze comparison sources and return path/status metadata only.
/// A new comparison replaces sources retained for the previous request ID.
pub fn compare(
&mut self,
comparison_id: u64,
before: &Revision,
after: &Revision,
mode: ComparisonMode,
buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> {
self.check_cancelled()?;
let bookmarks = self.bookmark_ids([&before.target, &after.target])?;
let mut selected_ids = bookmarks.clone();
for (index, revision) in [before, after].iter().enumerate() {
if revision.target != RevisionTarget::Working && selected_ids[index].is_none() {
selected_ids[index] = Some(self.resolve(&revision.target)?.1);
}
}
let targets = selected_ids
.clone()
.map(|id| id.map_or(RevisionTarget::Working, RevisionTarget::Commit));
let resolved = if mode == ComparisonMode::BranchChanges
&& before.target != RevisionTarget::Working
&& after.target != RevisionTarget::Working
{
let base = self.merge_base(
selected_ids[0].as_deref().unwrap(),
selected_ids[1].as_deref().unwrap(),
)?;
let label = format!(
"merge base of {} and {} · {base}",
before.label, after.label
);
Some((RevisionTarget::Commit(base), targets[1].clone(), label))
} else {
None
};
let (before_target, after_target, before_label) = resolved.as_ref().map_or(
(&targets[0], &targets[1], before.label.as_str()),
|(before, after, label)| (before, after, label.as_str()),
);
let working_after = *after_target == RevisionTarget::Working;
let checkout = self.working_anchor()?;
let pinned = if working_after {
None
} else {
let (store, id) = self.resolve(after_target)?;
Some((store, id))
};
let pinned_checkout = pinned
.as_ref()
.map(|(_, id)| self.pinned_checkout(id))
.transpose()?
.flatten();
let editable_after =
working_after || (pinned_checkout.is_some() && pinned_checkout == checkout);
let left = self.snapshot(before_target)?;
let same = bookmarks.iter().all(Option::is_none)
&& before_target == after_target
&& (!working_after || buffers.is_empty());
let right = if same {
BTreeMap::new()
} else if working_after {
self.working_snapshot(buffers)?
} else {
let (store, id) = pinned.as_ref().unwrap();
self.commit_snapshot(store, id)?
};
let before_revision = self.snapshot_revision(before_target, &left)?;
let after_revision = if same {
before_revision.clone()
} else {
self.snapshot_revision(after_target, &right)?
};
let mut prepared = PreparedComparison {
id: comparison_id,
selected: [before.clone(), after.clone()],
selected_ids,
revisions: [before_revision, after_revision],
mode,
left,
right,
files: vec![],
before: before_label.into(),
after: after.label.clone(),
working_after,
working_anchor: working_after.then(|| checkout.clone()).flatten(),
pinned_commit: pinned.map(|(_, id)| id),
pinned_checkout,
editable_after,
};
let comparison = self.build_comparison(&mut prepared, same)?;
if self.working_anchor()? != checkout {
return Err("Checkout moved during comparison".into());
}
if self.bookmark_ids([&before.target, &after.target])? != bookmarks {
return Err("Bookmark moved during comparison".into());
}
self.comparison = Some(prepared);
Ok(comparison)
}
/// Resolve active bookmarks, follow Working forward when enabled, or update
/// pinned After eligibility without changing its frozen contents.
pub fn refresh_checkout(
&mut self,
comparison_id: u64,
new_id: u64,
follow_working: bool,
buffers: &[(PathBuf, String)],
) -> Result<Option<CheckoutRefresh>, String> {
let result = self.refresh_checkout_inner(comparison_id, new_id, follow_working, buffers);
if result.is_err()
&& let Some(prepared) = self.comparison.as_mut()
&& prepared.id == comparison_id
&& !prepared.working_after
{
// The frontend revokes on error; a recovered checkout must emit a
// fresh promotion even if its identity did not change meanwhile.
prepared.editable_after = false;
}
result
}
fn refresh_checkout_inner(
&mut self,
comparison_id: u64,
new_id: u64,
follow_working: bool,
buffers: &[(PathBuf, String)],
) -> Result<Option<CheckoutRefresh>, String> {
let Some(prepared) = self
.comparison
.as_ref()
.filter(|prepared| prepared.id == comparison_id)
else {
return Ok(None);
};
self.check_cancelled()?;
let bookmarks =
self.bookmark_ids([&prepared.selected[0].target, &prepared.selected[1].target])?;
if bookmarks
.iter()
.enumerate()
.any(|(index, id)| id.is_some() && id != &prepared.selected_ids[index])
{
return self
.refresh_bookmarks(new_id, bookmarks, follow_working, buffers)
.map(|comparison| Some(CheckoutRefresh::Bookmark(comparison)));
}
if prepared.working_after && !follow_working {
return Ok(None);
}
if prepared.pinned_checkout.is_none()
&& let Some(id) = prepared.pinned_commit.as_deref()
{
// A privately loaded endpoint may later be imported into active jj.
// Retry only its frozen commit ID, never the mutable remote alias.
let identity = self.pinned_checkout(id)?;
self.comparison.as_mut().unwrap().pinned_checkout = identity;
}
let prepared = self.comparison.as_ref().unwrap();
let anchor = self.working_anchor()?;
if !prepared.working_after {
let editable = prepared.pinned_checkout.is_some() && prepared.pinned_checkout == anchor;
if editable == prepared.editable_after {
return Ok(None);
}
// No live contents enter the frozen comparison, including dirty
// frontend buffer overlays.
let mut prepared = self.comparison.take().unwrap();
prepared.editable_after = editable;
let comparison = self.comparison_catalog(&prepared);
let result = self.check_cancelled().and_then(|()| {
if self.working_anchor()? != anchor {
return Err("Checkout moved during comparison".into());
}
if self
.bookmark_ids([&prepared.selected[0].target, &prepared.selected[1].target])?
!= bookmarks
{
return Err("Bookmark moved during comparison".into());
}
Ok(comparison)
});
if result.is_ok() {
prepared.id = new_id;
}
self.comparison = Some(prepared);
return result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)));
}
if !self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())? {
return Ok(None);
}
let right = self.working_snapshot(buffers)?;
let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?;
let mut prepared = self.comparison.take().unwrap();
let previous_right = std::mem::replace(&mut prepared.right, right);
let previous_files = std::mem::take(&mut prepared.files);
let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision);
let result = self
.build_comparison(&mut prepared, false)
.and_then(|comparison| {
self.check_cancelled()?;
if self.working_anchor()? != anchor {
return Err("Working checkout moved during comparison".into());
}
if self
.bookmark_ids([&prepared.selected[0].target, &prepared.selected[1].target])?
!= bookmarks
{
return Err("Bookmark moved during comparison".into());
}
Ok(comparison)
});
if result.is_ok() {
prepared.id = new_id;
prepared.working_anchor = anchor;
} else {
prepared.right = previous_right;
prepared.files = previous_files;
prepared.revisions[1] = previous_revision;
}
self.comparison = Some(prepared);
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
}
fn refresh_bookmarks(
&mut self,
new_id: u64,
bookmarks: [Option<String>; 2],
follow_working: bool,
buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> {
let prepared = self.comparison.as_ref().unwrap();
let mut ids = prepared.selected_ids.clone();
for (index, id) in bookmarks.iter().enumerate() {
if id.is_some() {
ids[index] = id.clone();
}
}
let branch =
prepared.mode == ComparisonMode::BranchChanges && ids.iter().all(Option::is_some);
let label = prepared.selected[0].label.clone();
let after_label = prepared.selected[1].label.clone();
let working_after = prepared.working_after;
let changed_before = ids[0] != prepared.selected_ids[0];
let changed_after = ids[1] != prepared.selected_ids[1];
let anchor = self.working_anchor()?;
let advance_working = working_after
&& follow_working
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
let mut revisions = prepared.revisions.clone();
let (left, before_label) = if branch {
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?;
let label = format!("merge base of {label} and {after_label} · {base}");
revisions[0] = base.clone();
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
} else if changed_before {
revisions[0] = ids[0].clone().unwrap();
(
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
label,
)
} else {
(None, label)
};
let right = if advance_working {
Some(self.working_snapshot(buffers)?)
} else if changed_after {
Some(self.snapshot(&RevisionTarget::Commit(ids[1].clone().unwrap()))?)
} else {
None
};
if let Some(right) = &right {
revisions[1] = if advance_working {
self.snapshot_revision(&RevisionTarget::Working, right)?
} else {
ids[1].clone().unwrap()
};
}
let pinned_checkout = ids[1]
.as_deref()
.map(|id| self.pinned_checkout(id))
.transpose()?
.flatten();
let mut prepared = self.comparison.take().unwrap();
let previous_left = left.map(|left| std::mem::replace(&mut prepared.left, left));
let previous_right = right.map(|right| std::mem::replace(&mut prepared.right, right));
let previous_files = std::mem::take(&mut prepared.files);
let previous_before = std::mem::replace(&mut prepared.before, before_label);
let previous_editable = prepared.editable_after;
let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions);
prepared.editable_after =
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
let result = self
.build_comparison(&mut prepared, false)
.and_then(|comparison| {
self.check_cancelled()?;
if self.working_anchor()? != anchor {
return Err("Checkout moved during comparison".into());
}
if self
.bookmark_ids([&prepared.selected[0].target, &prepared.selected[1].target])?
!= bookmarks
{
return Err("Bookmark moved during comparison".into());
}
Ok(comparison)
});
if result.is_ok() {
prepared.id = new_id;
prepared.selected_ids = ids;
prepared.pinned_commit = prepared.selected_ids[1].clone();
prepared.pinned_checkout = pinned_checkout;
if advance_working {
prepared.working_anchor = anchor;
}
} else {
if let Some(left) = previous_left {
prepared.left = left;
}
if let Some(right) = previous_right {
prepared.right = right;
}
prepared.files = previous_files;
prepared.before = previous_before;
prepared.editable_after = previous_editable;
prepared.revisions = previous_revisions;
}
self.comparison = Some(prepared);
result
}
fn build_comparison(
&self,
prepared: &mut PreparedComparison,
same: bool,
) -> Result<Comparison, String> {
let left = &mut prepared.left;
let right = &mut prepared.right;
let right_view = if same { &*left } else { &*right };
let mut files = vec![];
let paths: BTreeSet<_> = left.keys().chain(right_view.keys()).collect();
for path in paths {
self.check_cancelled()?;
let a = left.get(path);
let b = right_view.get(path);
if a.map(file_identity) == b.map(file_identity) {
continue;
}
files.push(ChangedFile {
path: path.clone(),
old_path: None,
status: if a.is_none() {
FileStatus::Added
} else if b.is_none() {
FileStatus::Removed
} else {
FileStatus::Modified
},
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
});
}
// Match only identical data; modified-content moves remain add/remove pairs.
let mut candidates = BTreeMap::new();
for (i, file) in files.iter().enumerate() {
if file.status == FileStatus::Removed {
candidates
.entry(file_identity(&left[&file.path]))
.or_insert_with(Vec::new)
.push(i);
}
}
let mut removed = BTreeSet::new();
for i in 0..files.len() {
if files[i].status != FileStatus::Added {
continue;
}
if let Some(j) = candidates
.get_mut(&file_identity(&right_view[&files[i].path]))
.and_then(Vec::pop)
{
files[i].status = FileStatus::Renamed;
files[i].old_path = Some(files[j].path.clone());
removed.insert(j);
}
}
prepared.files = files
.into_iter()
.enumerate()
.filter_map(|(i, file)| {
(!removed.contains(&i)).then_some((file.path, file.old_path, file.status))
})
.collect();
Ok(self.comparison_catalog(prepared))
}
fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison {
let files = prepared
.files
.iter()
.map(|(path, old_path, status)| ChangedFile {
path: path.clone(),
old_path: old_path.clone(),
status: *status,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
})
.collect();
let editable_paths = if prepared.editable_after {
prepared
.files
.iter()
.filter(|(path, _, _)| {
prepared
.right
.get(path)
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
})
.map(|(path, _, _)| path.clone())
.collect()
} else {
BTreeSet::new()
};
Comparison {
revisions: prepared.revisions.clone(),
before: prepared.before.clone(),
after: prepared.after.clone(),
files,
root: self.root.clone(),
editable_paths,
}
}
/// Prepare one catalog entry using its original comparison's frozen sources.
pub fn load_file(
&mut self,
comparison_id: u64,
file_index: usize,
) -> Result<ChangedFile, String> {
self.check_cancelled()?;
let prepared = self
.comparison
.as_ref()
.filter(|comparison| comparison.id == comparison_id)
.ok_or("Comparison is no longer available")?;
let (path, old_path, status) = prepared
.files
.get(file_index)
.ok_or("Changed file is no longer available")?;
let before_path = old_path.as_ref().unwrap_or(path);
let mut left = BTreeMap::new();
let mut right = BTreeMap::new();
let before = prepared.left.get(before_path);
let after = prepared.right.get(path);
if let Some(data) = before.filter(|data| data.object.is_some() || data.projection.is_some())
{
left.insert(before_path.clone(), clone_file_data(data));
}
if let Some(data) = after.filter(|data| data.object.is_some() || data.projection.is_some())
{
right.insert(path.clone(), clone_file_data(data));
}
self.materialize(&mut left)?;
self.materialize(&mut right)?;
let mut file = self.changed_file(
path,
left.get(before_path).or(before),
right.get(path).or(after),
)?;
file.status = *status;
file.old_path = old_path.clone();
Ok(file)
}
fn changed_file(
&self,
path: &Path,
before: Option<&FileData>,
after: Option<&FileData>,
) -> Result<ChangedFile, String> {
let status = if before.is_none() {
FileStatus::Added
} else if after.is_none() {
FileStatus::Removed
} else {
FileStatus::Modified
};
let a = before.map_or(&[][..], |f| f.bytes.as_slice());
let b = after.map_or(&[][..], |f| f.bytes.as_slice());
let mut notices: Vec<String> = before
.into_iter()
.chain(after)
.filter_map(|f| f.notice.clone())
.collect();
if before.into_iter().chain(after).any(|f| f.mode == "120000") {
notices.push("Symbolic link: showing link target, never following it".into());
}
if let (Some(a), Some(b)) = (before, after)
&& a.mode != b.mode
{
notices.push(format!("File mode changed: {} → {}", a.mode, b.mode));
}
let binary = a.contains(&0)
|| b.contains(&0)
|| std::str::from_utf8(a).is_err()
|| std::str::from_utf8(b).is_err();
let (before_lines, after_lines, hunks) = if binary {
notices.push("Binary or non-UTF-8 content; text diff is unavailable".into());
(vec![], vec![], vec![])
} else {
// Bound line-header allocations as well as payload bytes. The real
// generated Honey parser has more than 1.5 million lines.
for bytes in [a, b] {
let count = bytes.iter().filter(|&&byte| byte == b'\n').count()
+ usize::from(!bytes.is_empty() && !bytes.ends_with(b"\n"));
if count > COMPARISON_LINE_LIMIT {
return Err(format!(
"{} exceeds the 4000000-line per-side comparison limit",
path.display()
));
}
self.check_cancelled()?;
}
if (!a.is_empty() && !a.ends_with(b"\n")) || (!b.is_empty() && !b.ends_with(b"\n")) {
notices.push(format!(
"EOF newline: Before {}, After {}",
eof_label(before),
eof_label(after)
));
}
let before_lines = lines(a);
let after_lines = lines(b);
let hunks = line_hunks(
&before_lines,
a.ends_with(b"\n"),
&after_lines,
b.ends_with(b"\n"),
);
(before_lines, after_lines, hunks)
};
notices.dedup();
Ok(ChangedFile {
path: path.to_path_buf(),
old_path: None,
status,
loaded: true,
before: before_lines,
after: after_lines,
before_eof: a.ends_with(b"\n"),
after_eof: b.ends_with(b"\n"),
hunks,
notice: if notices.is_empty() {
None
} else {
Some(notices.join("; "))
},
})
}
}
fn file_identity(file: &FileData) -> (&str, Option<&str>, &[u8], Option<&str>) {
(
&file.mode,
file.object.as_ref().map(|(_, id)| id.as_str()),
&file.bytes,
file.notice.as_deref(),
)
}
fn clone_file_data(file: &FileData) -> FileData {
FileData {
mode: file.mode.clone(),
bytes: file.bytes.clone(),
object: file.object.clone(),
projection: file.projection.clone(),
notice: file.notice.clone(),
}
}
fn lines(bytes: &[u8]) -> Vec<String> {
if bytes.is_empty() {
return vec![];
}
let text = std::str::from_utf8(bytes).unwrap();
let text = text.strip_suffix('\n').unwrap_or(text);
text.split('\n').map(str::to_owned).collect()
}
fn eof_label(file: Option<&FileData>) -> &'static str {
match file {
None => "absent",
Some(f) if f.bytes.is_empty() => "empty file",
Some(f) if f.bytes.ends_with(b"\n") => "present",
Some(_) => "missing",
}
}
#[cfg(all(test, unix))]
#[path = "../tests/unit/repository.rs"]
mod tests;