//! Read-only active-repository discovery and isolated revision comparison. use crate::diff::{ Catalog, Category, ChangedFile, Comparison, FileStatus, Hunk, RepoKind, Revision, RevisionTarget, }; 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); } } 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(); } 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, }) } } 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 } 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")?, )) } } 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(()) } 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()) } struct Remote { name: String, url: String, } #[derive(PartialEq, Eq, PartialOrd, Ord)] struct FileData { mode: String, bytes: Vec, notice: Option, } type Snapshot = BTreeMap; 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, 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(), 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: format!("{change} {id}"), 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 { entries.push(Revision { target: RevisionTarget::FetchRemote(remote.name.clone()), label: format!("Load history from {}", remote.name), description: "Fetch reachable history into a private comparison repository".into(), category, }); match self.advertised(&remote.url) { Ok(refs) => { self.remote_refs.retain(|(name, _), _| name != &remote.name); for (reference, id) in refs { let tag = reference.starts_with("refs/tags/"); let name = reference .strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" }) .unwrap(); if !tag || self.kind == RepoKind::Git { entries.push(Revision { target: RevisionTarget::Remote { remote: remote.name.clone(), reference: reference.clone(), }, label: format!("{name}@{}", remote.name), description: id.clone(), category: if tag { Category::Tags } else if self.kind == RepoKind::Jujutsu { Category::Bookmarks } else { Category::Branches }, }); } self.remote_refs .insert((remote.name.clone(), reference), id); } } Err(e) => notices.push(format!("{}: {e}", remote.name)), } } 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)); } Ok(()) } fn load_remote(&mut self, name: &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", "--fetch-tags=all", "--", name, &url], )?; self.registered.insert(name.into()); } self.jj( root, &[ "git", "fetch", "--remote", &format!("exact:{name}"), "--branch", "glob:*", ], )?; } else { self.git( store, &[ "fetch", "--no-write-fetch-head", "--", &url, &format!("+refs/heads/*:refs/remotes/{name}/*"), &format!("+refs/tags/*:refs/vamp-tags/{name}/*"), ], )?; } 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 fetch_remote(&mut self, remote: &str) -> Result { self.load_remote(remote)?; self.catalog() } fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> { let id = match target { RevisionTarget::Commit(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 } => { if !self.loaded.contains(remote) { self.load_remote(remote)?; } 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(_) => { return Err("Select a revision, not a history loader".into()); } }; 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((store.clone(), text(output.stdout)?.trim().into())); } if !matches!(output.code, Some(1 | 128)) { checked(output)?; } } Err(format!( "Commit {id} is unavailable locally or in loaded remote history" )) } fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result { let revset = format!("commit_id({})", serde_json::to_string(id).unwrap()); let template = r#""[" ++ json(path) ++ "," ++ json(file_type) ++ "," ++ json(executable) ++ "," ++ json(conflict) ++ "]\n""#; let entries = text(self.jj(root, &["file", "list", "-r", &revset, "-T", template])?)?; let mut snapshot = BTreeMap::new(); let mut total = 0; for entry in entries.lines() { self.check_cancelled()?; let (path, kind, executable, conflict): (String, String, bool, bool) = serde_json::from_str(entry) .map_err(|e| format!("Cannot decode Jujutsu conflict entry: {e}"))?; let path_buf = PathBuf::from(&path); safe_path(&path_buf)?; let mut notice = conflict.then(|| { "Unresolved Jujutsu conflict: showing CLI-rendered conflict projection".to_owned() }); let mode = if kind == "symlink" { "120000" } else if kind == "git-submodule" { "160000" } else if executable { "100755" } else { "100644" }; let bytes = if matches!(kind.as_str(), "file" | "symlink" | "conflict") { // Use an exact fileset and jj's own conflict materialization. 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('"'); self.jj( root, &["file", "show", "-r", &revset, "-T", "", "--", &pattern], )? } else { notice = Some(format!( "Unsupported {kind} content in a conflicted revision; nested contents are not traversed" )); vec![] }; if bytes.len() > FILE_LIMIT { return Err(format!( "{} exceeds the 16 MiB comparison file limit", path_buf.display() )); } total += bytes.len(); if total > OUTPUT_LIMIT { return Err( "Jujutsu conflict projection exceeds the 64 MiB comparison limit".into(), ); } snapshot.insert( path_buf, FileData { mode: mode.into(), bytes, notice, }, ); } 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!("commit_id({})", serde_json::to_string(id).unwrap()), "-T", "conflict", ], )?)?; if conflict.trim() == "true" { return self.conflicted_snapshot(root, id); } } let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?; let mut entries = vec![]; let mut request = Vec::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)?; if fields[1] == "commit" { entries.push(( path, fields[0].to_owned(), None, Some(format!( "Submodule commit {} (contents are not traversed)", fields[2] )), )); } else if fields[1] == "blob" { request.extend_from_slice(fields[2].as_bytes()); request.push(b'\n'); entries.push((path, fields[0].to_owned(), Some(fields[2].to_owned()), None)); } else { return Err(format!("Unsupported tree entry type: {}", fields[1])); } } let mut cmd = git_command(store); cmd.arg("cat-file").arg("--batch"); let data = checked(run(cmd, request, &self.cancelled)?)?; let mut offset = 0; let mut snapshot = BTreeMap::new(); for (path, mode, object, notice) in entries { self.check_cancelled()?; let bytes = if object.is_some() { 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()); } let bytes = data[offset..offset + size].to_vec(); offset += size + 1; bytes } else { vec![] }; snapshot.insert( path, FileData { mode, bytes, notice, }, ); } Ok(snapshot) } fn working_snapshot(&self) -> 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 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![], 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() )); } #[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, 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) } } pub fn compare(&mut self, before: &Revision, after: &Revision) -> Result { let left = self.snapshot(&before.target)?; let right = if before.target == after.target { None } else { Some(self.snapshot(&after.target)?) }; let right = right.as_ref().unwrap_or(&left); let mut files = vec![]; let paths: BTreeSet<_> = left.keys().chain(right.keys()).collect(); for path in paths { self.check_cancelled()?; let a = left.get(path); let b = right.get(path); if a == b { continue; } files.push(self.changed_file(path, a, b)?); } // Match only identical data; modified-content moves remain add/remove pairs. let mut candidates: BTreeMap<&FileData, Vec> = BTreeMap::new(); for (i, file) in files.iter().enumerate() { if file.status == FileStatus::Removed { candidates.entry(&left[&file.path]).or_default().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(&right[&files[i].path]) .and_then(Vec::pop) { let old_path = files[j].path.clone(); let mut renamed = self.changed_file( &files[i].path, left.get(&old_path), right.get(&files[i].path), )?; renamed.status = FileStatus::Renamed; renamed.old_path = Some(old_path); files[i] = renamed; removed.insert(j); } } let files = files .into_iter() .enumerate() .filter_map(|(i, f)| (!removed.contains(&i)).then_some(f)) .collect(); Ok(Comparison { before: before.label.clone(), after: after.label.clone(), files, }) } 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_path = self.temporary.0.join("before"); let after_path = self.temporary.0.join("after"); fs::write(&before_path, a).map_err(|e| e.to_string())?; fs::write(&after_path, b).map_err(|e| e.to_string())?; let mut cmd = Command::new("git"); cmd.args([ "--no-pager", "diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--text", "--unified=0", "--", ]) .arg(&before_path) .arg(&after_path) .current_dir(&self.temporary.0); let output = run(cmd, vec![], &self.cancelled)?; if !matches!(output.code, Some(0 | 1)) { return Err(format!( "Cannot calculate diff hunks: {}", String::from_utf8_lossy(&output.stderr) )); } let patch = text(output.stdout)?; let mut hunks = vec![]; for line in patch.lines().filter(|l| l.starts_with("@@ ")) { hunks.push(parse_hunk(line)?); } (lines(a), lines(b), hunks) }; notices.dedup(); Ok(ChangedFile { path: path.to_path_buf(), old_path: None, status, before: before_lines, after: after_lines, hunks, notice: if notices.is_empty() { None } else { Some(notices.join("; ")) }, }) } } 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", } } fn parse_hunk(line: &str) -> Result { fn range(value: &str) -> Result<(usize, usize), String> { let (start, len) = value[1..].split_once(',').unwrap_or((&value[1..], "1")); let start: usize = start.parse().map_err(|_| "Invalid diff hunk start")?; let len: usize = len.parse().map_err(|_| "Invalid diff hunk length")?; Ok(( if len == 0 { start } else { start .checked_sub(1) .ok_or("Invalid nonempty diff hunk start")? }, len, )) } let mut fields = line.split_whitespace(); if fields.next() != Some("@@") { return Err("Invalid diff hunk header".into()); } let (before_start, before_len) = range(fields.next().ok_or("Missing Before hunk range")?)?; let (after_start, after_len) = range(fields.next().ok_or("Missing After hunk range")?)?; Ok(Hunk { before_start, before_len, after_start, after_len, }) } #[cfg(all(test, unix))] mod tests { use super::*; #[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); } }