//! 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; const TIMEOUT: Duration = Duration::from_secs(60); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); struct Temporary(PathBuf); impl Temporary { fn new() -> Result { 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, stdout: Vec, stderr: Vec, } fn drain(mut pipe: impl Read) -> Result, 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, cancelled: &AtomicBool, ) -> Result { 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, 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, 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) -> Result { 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 { #[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 { 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, pub(crate) status: FileStatus, } const WORKING_PATH_LIMIT: usize = 100_000; fn working_checked(command: Command, cancelled: &dyn Fn() -> bool) -> Result, String> { crate::blame::run(command, &[], cancelled)? .ok_or_else(|| "Cannot read working-file repository metadata".into()) } fn working_paths(bytes: &[u8]) -> Result, 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 { 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, conflicted: bool, private: PathBuf, _temporary: Temporary, } #[derive(Default)] pub(crate) struct WorkingFileReader { baseline: Option, } impl WorkingFileReader { pub(crate) fn read( &mut self, root: &Path, buffers: &[(PathBuf, Arc)], cancelled: &dyn Fn() -> bool, ) -> Result, 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 { 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, }) } #[cfg(test)] fn working_file_changes( root: &Path, buffers: &[(PathBuf, Arc)], cancelled: &dyn Fn() -> bool, ) -> Result, String> { WorkingFileReader::default().read(root, buffers, cancelled) } fn working_file_changes_cached( root: &Path, buffers: &[(PathBuf, Arc)], cancelled: &dyn Fn() -> bool, cache: &mut Option, ) -> Result, 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> = BTreeMap::new(); let mut added: BTreeMap> = 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, object: Option<(PathBuf, String)>, // Conflicts retain their Git blob identity, but selected contents come from jj. projection: Option<(PathBuf, String)>, notice: Option, } type Snapshot = BTreeMap; struct PreparedComparison { id: u64, left: Snapshot, right: Snapshot, files: Vec<(PathBuf, Option, FileStatus)>, before: String, after: String, working_after: bool, working_anchor: Option, pinned_commit: Option, pinned_checkout: Option, editable_after: bool, } pub struct Repository { root: PathBuf, kind: RepoKind, store: PathBuf, temporary: Temporary, private: Option<(PathBuf, PathBuf)>, // workspace and Git object store remotes: Vec, remote_refs: BTreeMap<(String, String), String>, loaded: BTreeSet, registered: BTreeSet, comparison: Option, cancelled: Arc, } impl Repository { pub fn open(root: &Path, cancelled: Arc) -> Result { let root = fs::canonicalize(root).map_err(|e| e.to_string())?; let discovered = root .ancestors() .find(|p| p.join(".jj").exists() || p.join(".git").exists()) .ok_or("No Jujutsu or Git working-tree repository found")?; let (root, kind, store) = if discovered.join(".jj").exists() { let mut cmd = jj_command(discovered); cmd.arg("root"); let output = checked(run(cmd, vec![], &cancelled)?)?; let actual = path_bytes(output.strip_suffix(b"\n").unwrap_or(&output))?; let store = store_for_jj(&actual)?; (actual, RepoKind::Jujutsu, store) } else { let actual = discovered.to_path_buf(); 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, 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, 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, notices: &mut Vec, ) -> 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| stringify(b.name() ++ "@" ++ 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, mut bookmarks, remote_bookmarks): ( String, String, String, Vec, Vec, ) = serde_json::from_str(line) .map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?; bookmarks.extend(remote_bookmarks); 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 in bookmarks { entries.push(Revision { target: target.clone(), label: name, 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, 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 { 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 { 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 { self.load_remote(remote, None)?; self.catalog() } fn pull_request_remote( &mut self, name: &str, ) -> Result<(String, String, Option), 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 ")? } 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 { 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.", &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.", &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 resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> { let id = match target { 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, 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 { 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 { 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 { 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> { let stores: BTreeSet<_> = snapshot .values() .filter(|file| file.projection.is_none()) .filter_map(|file| file.object.as_ref().map(|(store, _)| store.clone())) .collect(); for store in stores { let mut request = Vec::new(); for file in snapshot.values() { if let Some((source, id)) = &file.object && source == &store && file.projection.is_none() { request.extend_from_slice(id.as_bytes()); request.push(b'\n'); } } let mut cmd = git_command(&store); cmd.args(["cat-file", "--batch"]); let data = checked(run(cmd, request, &self.cancelled)?)?; let mut offset = 0; for (path, file) in snapshot.iter_mut() { if file.projection.is_some() || file .object .as_ref() .is_none_or(|(source, _)| source != &store) { continue; } self.check_cancelled()?; let end = offset + data[offset..] .iter() .position(|b| *b == b'\n') .ok_or("Truncated Git object header")?; let header = std::str::from_utf8(&data[offset..end]) .map_err(|_| "Invalid Git object header")?; let size: usize = header .rsplit(' ') .next() .ok_or("Missing Git object size")? .parse() .map_err(|_| "Invalid Git object size")?; if size > FILE_LIMIT { return Err(format!( "{} exceeds the 16 MiB comparison file limit", path.display() )); } offset = end + 1; if size >= data.len().saturating_sub(offset) { return Err("Truncated Git object content".into()); } file.bytes = data[offset..offset + size].to_vec(); file.object = None; offset += size + 1; } } let mut total = 0; for (path, file) in snapshot.iter_mut() { let Some((root, id)) = &file.projection else { continue; }; 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")?); let bytes = self.jj( root, &["file", "show", "-r", &revset, "-T", "", "--", &pattern], )?; if bytes.len() > FILE_LIMIT { return Err(format!( "{} exceeds the 16 MiB comparison file limit", path.display() )); } total += bytes.len(); if total > OUTPUT_LIMIT { return Err( "Jujutsu conflict projection exceeds the 64 MiB comparison limit".into(), ); } file.bytes = bytes; file.object = None; file.projection = None; } Ok(()) } fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result { 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() > FILE_LIMIT as u64 { return Err(format!( "{} exceeds the 16 MiB comparison file limit", path.display() )); } let bytes = fs::File::open(&full) .and_then(|f| { let mut bytes = Vec::new(); f.take(FILE_LIMIT as u64 + 1).read_to_end(&mut bytes)?; Ok(bytes) }) .map_err(|e| format!("{}: {e}", path.display()))?; if bytes.len() > FILE_LIMIT { return Err(format!( "{} grew beyond the 16 MiB comparison file limit", path.display() )); } let bytes = if !bytes.contains(&0) && std::str::from_utf8(&bytes).is_ok() && let Some(text) = overlays.get(&path) { if text.len() > FILE_LIMIT { return Err(format!( "{} exceeds the 16 MiB comparison file limit", path.display() )); } text.as_bytes().to_vec() } else { 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 > OUTPUT_LIMIT { return Err("Working snapshot exceeds the 64 MiB comparison limit".into()); } snapshot.insert( path, FileData { mode, bytes, object: None, projection: None, notice, }, ); } Ok(snapshot) } fn snapshot(&mut self, target: &RevisionTarget) -> Result { 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 { 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, 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, 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 { 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 { self.check_cancelled()?; let resolved = if mode == ComparisonMode::BranchChanges && before.target != RevisionTarget::Working && after.target != RevisionTarget::Working { let (_, before_id) = self.resolve(&before.target)?; let (_, after_id) = self.resolve(&after.target)?; let base = self.merge_base(&before_id, &after_id)?; let label = format!( "merge base of {} and {} · {base}", before.label, after.label ); Some(( RevisionTarget::Commit(base), RevisionTarget::Commit(after_id), label, )) } else { None }; let (before_target, after_target, before_label) = resolved.as_ref().map_or( (&before.target, &after.target, 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 = 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 mut prepared = PreparedComparison { id: comparison_id, 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()); } self.comparison = Some(prepared); Ok(comparison) } /// Follow Working forward when enabled, or update a pinned After's eligibility. /// Pinned sources and path catalogs never change when checkout eligibility does. pub fn refresh_checkout( &mut self, comparison_id: u64, new_id: u64, follow_working: bool, buffers: &[(PathBuf, String)], ) -> Result, 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, String> { let Some(prepared) = self .comparison .as_ref() .filter(|prepared| prepared.id == comparison_id) else { return Ok(None); }; self.check_cancelled()?; 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()); } Ok(comparison) }); if result.is_ok() { prepared.id = new_id; } self.comparison = Some(prepared); return result.map(Some); } if !self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())? { return Ok(None); } let right = self.working_snapshot(buffers)?; 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 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()); } Ok(comparison) }); if result.is_ok() { prepared.id = new_id; prepared.working_anchor = anchor; } else { prepared.right = previous_right; prepared.files = previous_files; } self.comparison = Some(prepared); result.map(Some) } fn build_comparison( &self, prepared: &mut PreparedComparison, same: bool, ) -> Result { let left = &mut prepared.left; let right = &mut prepared.right; // Frozen checkout bytes have no object identity. Compare them in a single // batch; immutable blobs and deferred conflict projections retain IDs. let frozen = |file: &FileData| { file.object.is_none() && matches!(file.mode.as_str(), "100644" | "100755" | "120000") }; if !same && (left.values().any(frozen) || right.values().any(frozen)) { self.materialize(left)?; self.materialize(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 { 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 { 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 { 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 = 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 { 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 { 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))] mod tests { use super::*; fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String { let mut command = Command::new(program); if program == "jj" { command.args([ "--config=user.name=\"Picker Author\"", "--config=user.email=\"picker@example.test\"", "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", ]); } let output = command .args(args) .current_dir(root) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") .env("JJ_CONFIG", "/dev/null") .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().into() } fn picker_git_fixture() -> Temporary { let temp = Temporary::new().unwrap(); fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]); fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]); fixture_command( &temp.0, "git", &["config", "user.email", "picker@example.test"], ); temp } fn changes_by_path( root: &Path, buffers: &[(PathBuf, Arc)], ) -> BTreeMap { working_file_changes(root, buffers, &|| false) .unwrap() .into_iter() .map(|change| (change.path.clone(), change)) .collect() } #[test] fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() { use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt}; let temp = picker_git_fixture(); let root = &temp.0; for (name, contents) in [ (".gitignore", "ignored*\n"), ("modified", "original\r\n"), ("unchanged", "same\n"), ("removed", "deleted\n"), ("old", "rename content\n"), ("duplicate-one", "duplicate\n"), ("duplicate-two", "duplicate\n"), ("mode", "mode\n"), ("binary", "before\0binary"), ] { fs::write(root.join(name), contents).unwrap(); } let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec())); let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") { Ok(()) => Some(non_utf8), Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None, Err(error) => panic!("cannot create byte filename: {error}"), }; fixture_command(root, "git", &["add", "--all"]); fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); fs::write(root.join("modified"), "staged\n").unwrap(); fixture_command(root, "git", &["add", "--", "modified"]); fs::write(root.join("modified"), "unstaged\n").unwrap(); fs::write(root.join("binary"), "after\0binary").unwrap(); if let Some(name) = &non_utf8 { fs::write(root.join(name), "after\n").unwrap(); } fs::remove_file(root.join("removed")).unwrap(); let renamed = "-new [file]\n\t\".txt"; fs::rename(root.join("old"), root.join(renamed)).unwrap(); fs::remove_file(root.join("duplicate-one")).unwrap(); fs::remove_file(root.join("duplicate-two")).unwrap(); fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap(); fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap(); fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap(); fs::write(root.join("added"), "added\n").unwrap(); fs::write(root.join("ignored"), "ignored\n").unwrap(); fs::write(root.join("ignored-tracked"), "forced\n").unwrap(); fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]); std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap(); let index = fs::read(root.join(".git/index")).unwrap(); let head = fs::read(root.join(".git/HEAD")).unwrap(); let disk = changes_by_path(root, &[]); for name in ["modified", "binary", "mode"] { assert_eq!(disk[Path::new(name)].status, FileStatus::Modified); } if let Some(name) = &non_utf8 { assert_eq!(disk[name].status, FileStatus::Modified); } assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed); assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed); assert_eq!( disk[Path::new(renamed)].old_path.as_deref(), Some(Path::new("old")) ); for name in [ "added", "ignored-tracked", "new-duplicate-one", "new-duplicate-two", ] { assert_eq!(disk[Path::new(name)].status, FileStatus::Added); } for name in ["duplicate-one", "duplicate-two"] { assert_eq!(disk[Path::new(name)].status, FileStatus::Removed); } for name in ["unchanged", "old", "ignored", "link"] { assert!(!disk.contains_key(Path::new(name)), "{name}"); } let overlays = changes_by_path( root, &[ (root.join("modified"), "original\n".into()), (root.join("unchanged"), "dirty\n".into()), (root.join("removed"), "deleted\n".into()), (root.join(renamed), "dirty renamed\n".into()), (root.join("mode"), "mode\n".into()), (root.join("ignored-buffer"), "ignored\n".into()), ], ); assert!(!overlays.contains_key(Path::new("modified"))); assert!(!overlays.contains_key(Path::new("ignored-buffer"))); assert_eq!( overlays[Path::new("unchanged")].status, FileStatus::Modified ); assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed); assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed); assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified); let aliases = Temporary::new().unwrap(); let alias = aliases.0.join("checkout"); std::os::unix::fs::symlink(root, &alias).unwrap(); let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]); assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified); fixture_command(root, "git", &["config", "core.filemode", "0"]); assert!( !changes_by_path(root, &[(root.join("mode"), "mode\n".into())]) .contains_key(Path::new("mode")) ); fixture_command(root, "git", &["config", "core.autocrlf", "1"]); fs::write(root.join("unchanged"), "same\r\n").unwrap(); assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged"))); fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]); fixture_command(root, "git", &["config", "core.filemode", "true"]); fs::write(root.join("unchanged"), "same\n").unwrap(); assert_eq!(fs::read(root.join(".git/index")).unwrap(), index); assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head); assert_eq!( fs::read_to_string(root.join("modified")).unwrap(), "unstaged\n" ); assert!(!root.join("removed").exists()); } #[test] fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() { let temp = picker_git_fixture(); let root = &temp.0; fs::create_dir(root.join("sub")).unwrap(); fs::write(root.join("sub/outgoing"), "out\n").unwrap(); fs::write(root.join("incoming"), "in\n").unwrap(); fixture_command(root, "git", &["add", "--all"]); fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap(); fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap(); let changes = changes_by_path(&root.join("sub"), &[]); assert_eq!(changes.len(), 2); assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed); assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added); assert_eq!(changes[Path::new("incoming")].old_path, None); std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap(); assert!(changes_by_path(&root.join("linked"), &[]).is_empty()); assert!(changes_by_path(&root.join(".git"), &[]).is_empty()); let unrelated = Temporary::new().unwrap(); assert!(changes_by_path(&unrelated.0, &[]).is_empty()); let worktree = temp.0.join("worktree"); fixture_command( root, "git", &["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"], ); fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap(); let changes = changes_by_path(&worktree.join("sub"), &[]); assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified); } #[test] fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() { let temp = picker_git_fixture(); let root = &temp.0; fs::write(root.join(".gitignore"), "ignored\n").unwrap(); fs::write(root.join("tracked"), "tracked\n").unwrap(); fs::write(root.join("untracked"), "untracked\n").unwrap(); fs::write(root.join("ignored"), "ignored\n").unwrap(); fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]); let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]); for name in [".gitignore", "tracked", "untracked", "named"] { assert_eq!(changes[Path::new(name)].status, FileStatus::Added); } assert!(!changes.contains_key(Path::new("ignored"))); assert!(!root.join("named").exists()); fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); fs::write( root.join(".gitattributes"), "tracked filter=evil diff=evil\n", ) .unwrap(); let evil = "sh -c 'touch executed; cat'"; for key in [ "filter.evil.clean", "filter.evil.process", "diff.evil.command", "diff.evil.textconv", ] { fixture_command(root, "git", &["config", key, evil]); } fixture_command(root, "git", &["config", "filter.evil.required", "true"]); fs::write(root.join("tracked"), "changed\n").unwrap(); assert_eq!( changes_by_path(root, &[])[Path::new("tracked")].status, FileStatus::Modified ); assert_eq!( changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())]) .get(Path::new("tracked")), None ); assert!(!root.join("executed").exists()); assert!( working_file_changes(root, &[], &|| true) .unwrap_err() .contains("cancelled") ); assert!( working_file_changes( root, &[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())], &|| false ) .unwrap_err() .contains("16 MiB") ); } #[test] fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() { for colocate in ["--colocate", "--no-colocate"] { let temp = Temporary::new().unwrap(); let root = &temp.0; fixture_command(root, "jj", &["git", "init", colocate, "."]); fs::write(root.join("file"), "baseline\n").unwrap(); assert_eq!( changes_by_path(root, &[])[Path::new("file")].status, FileStatus::Added ); fs::write(root.join(".gitignore"), "ignored\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Baseline"]); fixture_command(root, "jj", &["new"]); let operation = fixture_command( root, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id", ], ); let parent = fixture_command( root, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@-", "-T", "commit_id", ], ); let store = store_for_jj(root).unwrap(); let head = fs::read(store.join("HEAD")).unwrap(); let index = fs::read(store.join("index")).ok(); fs::write(root.join("file"), "disk\n").unwrap(); fs::write(root.join("new"), "new\n").unwrap(); fs::write(root.join("ignored"), "ignored\n").unwrap(); let changes = changes_by_path(root, &[]); assert_eq!(changes[Path::new("file")].status, FileStatus::Modified); assert_eq!(changes[Path::new("new")].status, FileStatus::Added); assert!(!changes.contains_key(Path::new("ignored"))); assert!( !changes_by_path(root, &[(root.join("file"), "baseline\n".into())]) .contains_key(Path::new("file")) ); assert_eq!(fs::read(store.join("HEAD")).unwrap(), head); assert_eq!(fs::read(store.join("index")).ok(), index); assert_eq!( fixture_command( root, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id" ] ), operation ); fixture_command(root, "jj", &["new", "root()"]); fs::write(root.join("sibling"), "sibling\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Sibling"]); fixture_command(root, "jj", &["new", &parent, "@"]); assert!(changes_by_path(root, &[]).is_empty()); } } #[test] fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() { for colocate in ["--colocate", "--no-colocate"] { let temp = Temporary::new().unwrap(); let root = &temp.0; fixture_command(root, "jj", &["git", "init", colocate, "."]); fs::write(root.join("conflicted"), "base\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Base"]); let base = fixture_command( root, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); fixture_command(root, "jj", &["new"]); fs::write(root.join("conflicted"), "left\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Left"]); let left = fixture_command( root, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); fixture_command(root, "jj", &["new", &base]); fs::write(root.join("conflicted"), "right\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Right"]); let right = fixture_command( root, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); fixture_command(root, "jj", &["new", &left, &right]); fixture_command(root, "jj", &["new"]); let projected = fs::read_to_string(root.join("conflicted")).unwrap(); assert!(projected.contains("<<<<<<<")); let operation = fixture_command( root, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id", ], ); let mut reader = WorkingFileReader::default(); assert!(reader.read(root, &[], &|| false).unwrap().is_empty()); fs::write(root.join("conflicted"), "resolved in working file\n").unwrap(); let changes = reader.read(root, &[], &|| false).unwrap(); assert_eq!(changes.len(), 1); assert_eq!(changes[0].path, Path::new("conflicted")); assert_eq!(changes[0].status, FileStatus::Modified); assert!( reader .read( root, &[(root.join("conflicted"), projected.into())], &|| false ) .unwrap() .is_empty() ); assert_eq!( fs::read_to_string(root.join("conflicted")).unwrap(), "resolved in working file\n" ); assert_eq!( fixture_command( root, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id" ] ), operation ); } } #[test] fn cancellation_kills_pipe_holding_children_and_drains_both_streams() { let temp = Temporary::new().unwrap(); let ready = temp.0.join("ready"); let cancelled = Arc::new(AtomicBool::new(false)); let worker_cancelled = Arc::clone(&cancelled); let gate = ready.clone(); let (sent, received) = mpsc::channel(); let worker = thread::spawn(move || { let mut command = Command::new("sh"); command .args([ "-c", "sleep 600 & printf ready > \"$1\"; wait", "vamp-diff-test", ]) .arg(gate); let result = run(command, vec![], &worker_cancelled).map(|_| ()); sent.send(result).unwrap(); }); // File handshake, not a wall-clock scheduling assumption. while !ready.exists() { if let Ok(result) = received.try_recv() { panic!("Process exited before the child was ready: {result:?}"); } thread::yield_now(); } cancelled.store(true, Ordering::Release); assert!(received.recv().unwrap().unwrap_err().contains("cancelled")); worker.join().unwrap(); let mut command = Command::new("sh"); command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]); let result = run(command, vec![], &AtomicBool::new(false)).unwrap(); assert_eq!(result.code, Some(0)); assert_eq!(result.stdout.len(), 50_000); assert_eq!(result.stderr.len(), 60_000); } }