Files
vamp/src/repository.rs
T

1588 lines
58 KiB
Rust

//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
Catalog, Category, ChangedFile, Comparison, FileStatus, 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<Self, String> {
for _ in 0..100 {
let path = std::env::temp_dir().join(format!(
"vamp-diff-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
match builder.create(&path) {
Ok(()) => return Ok(Self(path)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.to_string()),
}
}
Err("Cannot allocate private comparison directory".into())
}
}
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
struct ProcessOutput {
code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
fn drain(mut pipe: impl Read) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
let mut chunk = [0; 8192];
let mut overflow = false;
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
if bytes.len() + n <= OUTPUT_LIMIT && !overflow {
bytes.extend_from_slice(&chunk[..n]);
} else {
overflow = true;
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.to_string()),
}
}
if overflow {
Err(format!(
"Subprocess output exceeds {} MiB",
OUTPUT_LIMIT / 1024 / 1024
))
} else {
Ok(bytes)
}
}
fn terminate(child: &mut std::process::Child) {
#[cfg(unix)]
unsafe {
unsafe extern "C" {
fn kill(pid: i32, signal: i32) -> i32;
}
kill(-(child.id() as i32), 9);
}
let _ = child.kill();
let _ = child.wait();
}
fn run(
mut command: Command,
input: Vec<u8>,
cancelled: &AtomicBool,
) -> Result<ProcessOutput, String> {
if cancelled.load(Ordering::Acquire) {
return Err("Diff operation cancelled".into());
}
// An inherited object/index override must never redirect a private fetch.
for key in [
"GIT_DIR",
"GIT_COMMON_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
] {
command.env_remove(key);
}
command.env("GIT_NO_REPLACE_OBJECTS", "1");
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
#[cfg(not(unix))]
return Err(
"Repository comparisons require Unix process-group cancellation on this platform".into(),
);
#[cfg(unix)]
{
let mut child = command
.spawn()
.map_err(|e| format!("Cannot start repository command: {e}"))?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let mut stdin = child.stdin.take().unwrap();
let (send, receive) = mpsc::channel();
let out = send.clone();
let err = send.clone();
let readers = [
thread::spawn(move || {
let _ = out.send((0, drain(stdout)));
}),
thread::spawn(move || {
let _ = err.send((1, drain(stderr)));
}),
thread::spawn(move || {
let result = stdin
.write_all(&input)
.map(|_| Vec::new())
.map_err(|e| e.to_string());
drop(stdin);
let _ = send.send((2, result));
}),
];
let started = Instant::now();
let mut results: [Option<Result<Vec<u8>, String>>; 3] = [None, None, None];
let mut status = None;
let failure = loop {
while let Ok((i, result)) = receive.try_recv() {
results[i] = Some(result);
}
if cancelled.load(Ordering::Acquire) {
break Some("Diff operation cancelled".to_string());
}
if started.elapsed() >= TIMEOUT {
break Some("Repository command timed out after 60 seconds".to_string());
}
if status.is_none() {
match child.try_wait() {
Ok(value) => status = value,
Err(e) => break Some(e.to_string()),
}
}
if status.is_some() && results.iter().all(Option::is_some) {
break None;
}
thread::sleep(Duration::from_millis(10));
};
if failure.is_some() {
terminate(&mut child);
}
for reader in readers {
let _ = reader.join();
}
if let Some(error) = failure {
return Err(error);
}
let stdout = results[0].take().unwrap()?;
let stderr = results[1].take().unwrap()?;
results[2].take().unwrap()?;
Ok(ProcessOutput {
code: status.unwrap().code(),
stdout,
stderr,
})
}
}
fn checked(output: ProcessOutput) -> Result<Vec<u8>, String> {
if output.code == Some(0) {
Ok(output.stdout)
} else {
Err(format!(
"Repository command failed ({}): {}",
output.code.map_or("signal".into(), |n| n.to_string()),
String::from_utf8_lossy(&output.stderr).trim()
))
}
}
fn text(bytes: Vec<u8>) -> Result<String, String> {
String::from_utf8(bytes).map_err(|_| "Repository command returned non-UTF-8 metadata".into())
}
fn git_command(store: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.args([
"--no-pager",
"-c",
"core.hooksPath=/dev/null",
"-c",
"core.fsmonitor=false",
])
.arg("--git-dir")
.arg(store)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0");
cmd
}
fn jj_command(root: &Path) -> Command {
let mut cmd = Command::new("jj");
cmd.arg("--repository")
.arg(root)
.args([
"--ignore-working-copy",
"--at-operation=@",
"--no-pager",
"--color=never",
])
.current_dir(root);
cmd
}
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Non-UTF-8 repository path")?,
))
}
}
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<PathBuf, String> {
let mut repo = root.join(".jj/repo");
if repo.is_file() {
let pointer = fs::read(&repo).map_err(|e| e.to_string())?;
repo = root
.join(".jj")
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
}
let store = repo.join("store");
let target = fs::read(store.join("git_target"))
.map_err(|e| format!("Only Git-backed Jujutsu stores are supported: {e}"))?;
fs::canonicalize(store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?))
.map_err(|e| e.to_string())
}
struct Remote {
name: String,
url: String,
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct FileData {
mode: String,
bytes: Vec<u8>,
object: Option<(PathBuf, String)>,
notice: Option<String>,
}
type Snapshot = BTreeMap<PathBuf, FileData>;
struct PreparedComparison {
id: u64,
left: Snapshot,
right: Snapshot,
files: Vec<(PathBuf, Option<PathBuf>, FileStatus)>,
}
pub struct Repository {
root: PathBuf,
kind: RepoKind,
store: PathBuf,
temporary: Temporary,
private: Option<(PathBuf, PathBuf)>, // workspace and Git object store
remotes: Vec<Remote>,
remote_refs: BTreeMap<(String, String), String>,
loaded: BTreeSet<String>,
registered: BTreeSet<String>,
comparison: Option<PreparedComparison>,
cancelled: Arc<AtomicBool>,
}
impl Repository {
pub fn open(root: &Path, cancelled: Arc<AtomicBool>) -> Result<Self, String> {
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<Vec<u8>, String> {
let mut cmd = git_command(store);
cmd.args(args).current_dir(&self.root);
checked(run(cmd, vec![], &self.cancelled)?)
}
fn jj(&self, root: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
let mut cmd = jj_command(root);
cmd.args(args);
checked(run(cmd, vec![], &self.cancelled)?)
}
fn check_cancelled(&self) -> Result<(), String> {
if self.cancelled.load(Ordering::Acquire) {
Err("Diff operation cancelled".into())
} else {
Ok(())
}
}
fn revisions(
&self,
root: &Path,
store: &Path,
entries: &mut Vec<Revision>,
notices: &mut Vec<String>,
) -> Result<(), String> {
if self.kind == RepoKind::Jujutsu {
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| 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<String>,
Vec<String>,
) = 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<Vec<(String, String)>, String> {
let mut cmd = Command::new("git");
cmd.args(["ls-remote", "--refs", "--heads", "--tags", "--", url])
.current_dir(&self.root)
.env("GIT_TERMINAL_PROMPT", "0");
let output = text(checked(run(cmd, vec![], &self.cancelled)?)?)?;
output
.lines()
.map(|line| {
let (id, reference) = line
.split_once('\t')
.ok_or("Malformed remote reference metadata")?;
if !matches!(id.len(), 40 | 64)
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
|| !(reference.starts_with("refs/heads/")
|| reference.starts_with("refs/tags/"))
{
return Err("Invalid remote reference metadata".into());
}
Ok((reference.into(), id.into()))
})
.collect()
}
pub fn catalog(&mut self) -> Result<Catalog, String> {
self.check_cancelled()?;
self.discover_remotes()?;
let category = if self.kind == RepoKind::Jujutsu {
Category::Revisions
} else {
Category::Commits
};
let mut entries = vec![Revision {
target: RevisionTarget::Working,
label: "Working checkout".into(),
description:
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
category,
}];
let mut notices = vec![];
self.revisions(&self.root, &self.store, &mut entries, &mut notices)?;
if let Some((root, store)) = &self.private {
self.revisions(root, store, &mut entries, &mut notices)?;
}
for remote in &self.remotes {
for &view in self.kind.categories() {
let history = view == category;
entries.push(Revision {
target: if history {
RevisionTarget::FetchRemote(remote.name.clone())
} else {
RevisionTarget::ListRemote(remote.name.clone())
},
label: format!(
"Load {} from {}",
if history { "history" } else { "refs" },
remote.name
),
description: if history {
"Fetch reachable history into a private comparison repository"
} else {
"List remote references without fetching history"
}
.into(),
category: view,
});
}
for ((name, reference), id) in &self.remote_refs {
if name != &remote.name {
continue;
}
let tag = reference.starts_with("refs/tags/");
let label = reference
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
.unwrap();
if !tag || self.kind == RepoKind::Git {
entries.push(Revision {
target: RevisionTarget::Remote {
remote: name.clone(),
reference: reference.clone(),
},
label: format!("{label}@{name}"),
description: id.clone(),
category: if tag {
Category::Tags
} else if self.kind == RepoKind::Jujutsu {
Category::Bookmarks
} else {
Category::Branches
},
});
}
}
}
self.check_cancelled()?;
let mut seen = BTreeSet::new();
entries.retain(|entry| {
let key = format!("{:?}|{:?}|{}", entry.target, entry.category, entry.label);
seen.insert(key)
});
Ok(Catalog {
kind: self.kind,
entries,
notice: if notices.is_empty() {
None
} else {
Some(notices.join("; "))
},
})
}
fn ensure_private(&mut self) -> Result<(), String> {
if self.private.is_some() {
return Ok(());
}
let root = self.temporary.0.join("repository");
if self.kind == RepoKind::Jujutsu {
let mut cmd = Command::new("jj");
cmd.args([
"--no-pager",
"--color=never",
"git",
"init",
"--no-colocate",
])
.arg(&root);
checked(run(cmd, vec![], &self.cancelled)?)?;
let store = store_for_jj(&root)?;
self.private = Some((root, store));
} else {
let format = text(self.git(&self.store, &["rev-parse", "--show-object-format"])?)?;
let mut cmd = Command::new("git");
cmd.args(["init", "--bare"])
.arg(format!("--object-format={}", format.trim()))
.arg(&root);
checked(run(cmd, vec![], &self.cancelled)?)?;
self.private = Some((root.clone(), root));
}
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", "--fetch-tags=all", "--", name, &url],
)?;
self.registered.insert(name.into());
}
self.jj(
root,
&[
"git",
"fetch",
"--remote",
&format!("exact:{name}"),
"--branch",
&reference.map_or_else(
|| Ok("glob:*".to_owned()),
|reference| {
reference
.strip_prefix("refs/heads/")
.map(|branch| format!("exact:{branch}"))
.ok_or("Jujutsu remote selection requires a bookmark")
},
)?,
],
)?;
} else if let Some(reference) = reference {
self.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{reference}:refs/vamp-selected/{name}/{reference}"),
],
)?;
} else {
self.git(
store,
&[
"fetch",
"--no-write-fetch-head",
"--",
&url,
&format!("+refs/heads/*:refs/remotes/{name}/*"),
&format!("+refs/tags/*:refs/vamp-tags/{name}/*"),
],
)?;
}
if reference.is_none() {
self.loaded.insert(name.into());
let refs = self.advertised(&url)?;
self.remote_refs.retain(|(remote, _), _| remote != name);
for (reference, id) in refs {
self.remote_refs.insert((name.into(), reference), id);
}
}
Ok(())
}
pub fn list_remote(&mut self, name: &str) -> Result<Catalog, String> {
self.discover_remotes()?;
let remote = self
.remotes
.iter()
.find(|remote| remote.name == name)
.ok_or_else(|| format!("Unknown configured remote: {name}"))?;
let refs = self.advertised(&remote.url)?;
self.remote_refs.retain(|(remote, _), _| remote != name);
self.remote_refs.extend(
refs.into_iter()
.map(|(reference, id)| ((name.into(), reference), id)),
);
self.catalog()
}
pub fn fetch_remote(&mut self, remote: &str) -> Result<Catalog, String> {
self.load_remote(remote, None)?;
self.catalog()
}
fn 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 } => 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(_) => {
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);
}
}
Err(format!(
"Commit {id} is unavailable locally or in loaded remote history"
))
}
fn resolve_stored(&self, id: &str) -> Result<Option<(PathBuf, String)>, String> {
for store in
std::iter::once(&self.store).chain(self.private.as_ref().map(|(_, store)| store))
{
let mut cmd = git_command(store);
cmd.args(["rev-parse", "--verify", &format!("{id}^{{commit}}")])
.current_dir(&self.root);
let output = run(cmd, vec![], &self.cancelled)?;
if output.code == Some(0) {
return Ok(Some((store.clone(), text(output.stdout)?.trim().into())));
}
if !matches!(output.code, Some(1 | 128)) {
checked(output)?;
}
}
Ok(None)
}
fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result<Snapshot, String> {
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,
object: None,
notice,
},
);
}
Ok(snapshot)
}
fn commit_snapshot(&self, store: &Path, id: &str) -> Result<Snapshot, String> {
if id.bytes().all(|b| b == b'0') {
return Ok(BTreeMap::new());
}
// Jujutsu stores unresolved merge trees specially; do not show their raw Git encoding.
if self.kind == RepoKind::Jujutsu {
let root = if store == self.store {
&self.root
} else {
&self.private.as_ref().unwrap().0
};
let conflict = text(self.jj(
root,
&[
"log",
"--no-graph",
"-r",
&format!("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 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,
notice,
},
);
}
Ok(snapshot)
}
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
let stores: BTreeSet<_> = snapshot
.values()
.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
{
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
.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;
}
}
Ok(())
}
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
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,
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,
notice,
},
);
}
Ok(snapshot)
}
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
if *target == RevisionTarget::Working {
self.working_snapshot(&[])
} else {
let (store, id) = self.resolve(target)?;
self.commit_snapshot(&store, &id)
}
}
/// 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,
buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> {
self.comparison = None;
let mut left = self.snapshot(&before.target)?;
let working_after = after.target == RevisionTarget::Working;
let mut right = if before.target == after.target && (!working_after || buffers.is_empty()) {
BTreeMap::new()
} else if working_after {
self.working_snapshot(buffers)?
} else {
self.snapshot(&after.target)?
};
let same = before.target == after.target && (!working_after || buffers.is_empty());
// Frozen checkout/conflict bytes have no object identity. Compare them in a
// single batch, while immutable tree/tree comparisons need only object 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(&mut left)?;
self.materialize(&mut 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);
}
}
let files: Vec<_> = files
.into_iter()
.enumerate()
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
.collect();
let editable_paths = if working_after {
files
.iter()
.filter(|file| {
right_view
.get(&file.path)
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
})
.map(|file| file.path.clone())
.collect()
} else {
BTreeSet::new()
};
self.comparison = Some(PreparedComparison {
id: comparison_id,
left,
right,
files: files
.iter()
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
.collect(),
});
Ok(Comparison {
before: before.label.clone(),
after: after.label.clone(),
files,
root: self.root.clone(),
editable_paths,
})
}
/// Prepare one catalog entry using its original comparison's frozen sources.
pub fn load_file(
&mut self,
comparison_id: u64,
file_index: usize,
) -> Result<ChangedFile, String> {
self.check_cancelled()?;
let prepared = self
.comparison
.as_ref()
.filter(|comparison| comparison.id == comparison_id)
.ok_or("Comparison is no longer available")?;
let (path, old_path, status) = prepared
.files
.get(file_index)
.ok_or("Changed file is no longer available")?;
let before_path = old_path.as_ref().unwrap_or(path);
let mut left = BTreeMap::new();
let mut right = BTreeMap::new();
let before = prepared.left.get(before_path);
let after = prepared.right.get(path);
if let Some(data) = before.filter(|data| data.object.is_some()) {
left.insert(before_path.clone(), clone_file_data(data));
}
if let Some(data) = after.filter(|data| data.object.is_some()) {
right.insert(path.clone(), clone_file_data(data));
}
self.materialize(&mut left)?;
self.materialize(&mut right)?;
let mut file = self.changed_file(
path,
left.get(before_path).or(before),
right.get(path).or(after),
)?;
file.status = *status;
file.old_path = old_path.clone();
Ok(file)
}
fn changed_file(
&self,
path: &Path,
before: Option<&FileData>,
after: Option<&FileData>,
) -> Result<ChangedFile, String> {
let status = if before.is_none() {
FileStatus::Added
} else if after.is_none() {
FileStatus::Removed
} else {
FileStatus::Modified
};
let a = before.map_or(&[][..], |f| f.bytes.as_slice());
let b = after.map_or(&[][..], |f| f.bytes.as_slice());
let mut notices: Vec<String> = before
.into_iter()
.chain(after)
.filter_map(|f| f.notice.clone())
.collect();
if before.into_iter().chain(after).any(|f| f.mode == "120000") {
notices.push("Symbolic link: showing link target, never following it".into());
}
if let (Some(a), Some(b)) = (before, after)
&& a.mode != b.mode
{
notices.push(format!("File mode changed: {} → {}", a.mode, b.mode));
}
let binary = a.contains(&0)
|| b.contains(&0)
|| std::str::from_utf8(a).is_err()
|| std::str::from_utf8(b).is_err();
let (before_lines, after_lines, hunks) = if binary {
notices.push("Binary or non-UTF-8 content; text diff is unavailable".into());
(vec![], vec![], vec![])
} else {
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(),
notice: file.notice.clone(),
}
}
fn lines(bytes: &[u8]) -> Vec<String> {
if bytes.is_empty() {
return vec![];
}
let text = std::str::from_utf8(bytes).unwrap();
let text = text.strip_suffix('\n').unwrap_or(text);
text.split('\n').map(str::to_owned).collect()
}
fn eof_label(file: Option<&FileData>) -> &'static str {
match file {
None => "absent",
Some(f) if f.bytes.is_empty() => "empty file",
Some(f) if f.bytes.ends_with(b"\n") => "present",
Some(_) => "missing",
}
}
#[cfg(all(test, unix))]
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);
}
}