1782 lines
65 KiB
Rust
1782 lines
65 KiB
Rust
//! Read-only active-repository discovery and isolated revision comparison.
|
|
use crate::diff::{
|
|
Catalog, Category, ChangedFile, Comparison, ComparisonMode, 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)>,
|
|
before: String,
|
|
after: String,
|
|
working_after: bool,
|
|
working_anchor: Option<String>,
|
|
}
|
|
|
|
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: 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<Vec<(String, String)>, String> {
|
|
let mut cmd = Command::new("git");
|
|
cmd.args(["ls-remote", "--refs", "--heads", "--tags", "--", url])
|
|
.current_dir(&self.root)
|
|
.env("GIT_TERMINAL_PROMPT", "0");
|
|
let output = text(checked(run(cmd, vec![], &self.cancelled)?)?)?;
|
|
output
|
|
.lines()
|
|
.map(|line| {
|
|
let (id, reference) = line
|
|
.split_once('\t')
|
|
.ok_or("Malformed remote reference metadata")?;
|
|
if !matches!(id.len(), 40 | 64)
|
|
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
|
|
|| !(reference.starts_with("refs/heads/")
|
|
|| reference.starts_with("refs/tags/"))
|
|
{
|
|
return Err("Invalid remote reference metadata".into());
|
|
}
|
|
Ok((reference.into(), id.into()))
|
|
})
|
|
.collect()
|
|
}
|
|
pub fn catalog(&mut self) -> Result<Catalog, String> {
|
|
self.check_cancelled()?;
|
|
self.discover_remotes()?;
|
|
let category = if self.kind == RepoKind::Jujutsu {
|
|
Category::Revisions
|
|
} else {
|
|
Category::Commits
|
|
};
|
|
let mut entries = vec![Revision {
|
|
target: RevisionTarget::Working,
|
|
label: "Working checkout".into(),
|
|
description:
|
|
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
|
|
category,
|
|
}];
|
|
let mut notices = vec![];
|
|
self.revisions(&self.root, &self.store, &mut entries, &mut notices)?;
|
|
if let Some((root, store)) = &self.private {
|
|
self.revisions(root, store, &mut entries, &mut notices)?;
|
|
}
|
|
for remote in &self.remotes {
|
|
for &view in self.kind.categories() {
|
|
let history = view == category;
|
|
entries.push(Revision {
|
|
target: if history {
|
|
RevisionTarget::FetchRemote(remote.name.clone())
|
|
} else {
|
|
RevisionTarget::ListRemote(remote.name.clone())
|
|
},
|
|
label: format!(
|
|
"Load {} from {}",
|
|
if history { "history" } else { "refs" },
|
|
remote.name
|
|
),
|
|
description: if history {
|
|
"Fetch reachable history into a private comparison repository"
|
|
} else {
|
|
"List remote references without fetching history"
|
|
}
|
|
.into(),
|
|
category: view,
|
|
});
|
|
}
|
|
for ((name, reference), id) in &self.remote_refs {
|
|
if name != &remote.name {
|
|
continue;
|
|
}
|
|
let tag = reference.starts_with("refs/tags/");
|
|
let label = reference
|
|
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
|
|
.unwrap();
|
|
if !tag || self.kind == RepoKind::Git {
|
|
entries.push(Revision {
|
|
target: RevisionTarget::Remote {
|
|
remote: name.clone(),
|
|
reference: reference.clone(),
|
|
},
|
|
label: format!("{label}@{name}"),
|
|
description: id.clone(),
|
|
category: if tag {
|
|
Category::Tags
|
|
} else if self.kind == RepoKind::Jujutsu {
|
|
Category::Bookmarks
|
|
} else {
|
|
Category::Branches
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
self.check_cancelled()?;
|
|
let mut seen = BTreeSet::new();
|
|
entries.retain(|entry| {
|
|
let key = format!("{:?}|{:?}|{}", entry.target, entry.category, entry.label);
|
|
seen.insert(key)
|
|
});
|
|
Ok(Catalog {
|
|
kind: self.kind,
|
|
entries,
|
|
notice: if notices.is_empty() {
|
|
None
|
|
} else {
|
|
Some(notices.join("; "))
|
|
},
|
|
})
|
|
}
|
|
fn ensure_private(&mut self) -> Result<(), String> {
|
|
if self.private.is_some() {
|
|
return Ok(());
|
|
}
|
|
let root = self.temporary.0.join("repository");
|
|
if self.kind == RepoKind::Jujutsu {
|
|
let mut cmd = Command::new("jj");
|
|
cmd.args([
|
|
"--no-pager",
|
|
"--color=never",
|
|
"git",
|
|
"init",
|
|
"--no-colocate",
|
|
])
|
|
.arg(&root);
|
|
checked(run(cmd, vec![], &self.cancelled)?)?;
|
|
let store = store_for_jj(&root)?;
|
|
self.private = Some((root, store));
|
|
} else {
|
|
let format = text(self.git(&self.store, &["rev-parse", "--show-object-format"])?)?;
|
|
let mut cmd = Command::new("git");
|
|
cmd.args(["init", "--bare"])
|
|
.arg(format!("--object-format={}", format.trim()))
|
|
.arg(&root);
|
|
checked(run(cmd, vec![], &self.cancelled)?)?;
|
|
self.private = Some((root.clone(), root));
|
|
}
|
|
// Only the private store gains alternates; active refs and objects stay untouched.
|
|
let objects = self.git(
|
|
&self.store,
|
|
&[
|
|
"rev-parse",
|
|
"--path-format=absolute",
|
|
"--git-path",
|
|
"objects",
|
|
],
|
|
)?;
|
|
let mut alternate = vec![b'"'];
|
|
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
|
|
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
|
|
alternate.push(byte);
|
|
} else {
|
|
write!(&mut alternate, "\\{byte:03o}").unwrap();
|
|
}
|
|
}
|
|
alternate.extend_from_slice(b"\"\n");
|
|
let store = &self.private.as_ref().unwrap().1;
|
|
fs::write(store.join("objects/info/alternates"), alternate).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
fn load_remote(&mut self, name: &str, reference: Option<&str>) -> Result<(), String> {
|
|
if self.remotes.is_empty() {
|
|
self.discover_remotes()?;
|
|
}
|
|
let url = self
|
|
.remotes
|
|
.iter()
|
|
.find(|r| r.name == name)
|
|
.ok_or_else(|| format!("Unknown configured remote: {name}"))?
|
|
.url
|
|
.clone();
|
|
self.ensure_private()?;
|
|
let (root, store) = self.private.as_ref().unwrap();
|
|
if self.kind == RepoKind::Jujutsu {
|
|
if !self.registered.contains(name) {
|
|
self.jj(root, &["git", "remote", "add", "--", name, &url])?;
|
|
self.registered.insert(name.into());
|
|
}
|
|
self.jj(
|
|
root,
|
|
&[
|
|
"git",
|
|
"fetch",
|
|
"--remote",
|
|
&format!("exact:{name}"),
|
|
"--branch",
|
|
&reference.map_or_else(
|
|
|| Ok("glob:*".to_owned()),
|
|
|reference| {
|
|
reference
|
|
.strip_prefix("refs/heads/")
|
|
.map(|branch| format!("exact:{branch}"))
|
|
.ok_or("Jujutsu remote selection requires a bookmark")
|
|
},
|
|
)?,
|
|
],
|
|
)?;
|
|
} else if let Some(reference) = reference {
|
|
self.git(
|
|
store,
|
|
&[
|
|
"fetch",
|
|
"--no-tags",
|
|
"--no-write-fetch-head",
|
|
"--",
|
|
&url,
|
|
&format!("+{reference}:refs/vamp-selected/{name}/{reference}"),
|
|
],
|
|
)?;
|
|
} else {
|
|
self.git(
|
|
store,
|
|
&[
|
|
"fetch",
|
|
"--no-write-fetch-head",
|
|
"--",
|
|
&url,
|
|
&format!("+refs/heads/*:refs/remotes/{name}/*"),
|
|
&format!("+refs/tags/*:refs/vamp-tags/{name}/*"),
|
|
],
|
|
)?;
|
|
}
|
|
if reference.is_none() {
|
|
self.loaded.insert(name.into());
|
|
let refs = self.advertised(&url)?;
|
|
self.remote_refs.retain(|(remote, _), _| remote != name);
|
|
for (reference, id) in refs {
|
|
self.remote_refs.insert((name.into(), reference), id);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn list_remote(&mut self, name: &str) -> Result<Catalog, String> {
|
|
self.discover_remotes()?;
|
|
let remote = self
|
|
.remotes
|
|
.iter()
|
|
.find(|remote| remote.name == name)
|
|
.ok_or_else(|| format!("Unknown configured remote: {name}"))?;
|
|
let refs = self.advertised(&remote.url)?;
|
|
self.remote_refs.retain(|(remote, _), _| remote != name);
|
|
self.remote_refs.extend(
|
|
refs.into_iter()
|
|
.map(|(reference, id)| ((name.into(), reference), id)),
|
|
);
|
|
self.catalog()
|
|
}
|
|
pub fn fetch_remote(&mut self, remote: &str) -> Result<Catalog, String> {
|
|
self.load_remote(remote, None)?;
|
|
self.catalog()
|
|
}
|
|
fn 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)
|
|
}
|
|
}
|
|
fn merge_base(&mut self, before: &str, after: &str) -> Result<String, String> {
|
|
const JJ_ROOT: &str = "0000000000000000000000000000000000000000";
|
|
if self.kind == RepoKind::Jujutsu && (before == JJ_ROOT || after == JJ_ROOT) {
|
|
return Ok(JJ_ROOT.into());
|
|
}
|
|
let store = self
|
|
.private
|
|
.as_ref()
|
|
.map_or(&self.store, |(_, store)| store);
|
|
let mut command = git_command(store);
|
|
command.args(["merge-base", "--all", before, after]);
|
|
let output = run(command, vec![], &self.cancelled)?;
|
|
if output.code == Some(1) && output.stdout.is_empty() {
|
|
// Every jj history has the synthetic root, absent from Git's graph.
|
|
return if self.kind == RepoKind::Jujutsu {
|
|
Ok(JJ_ROOT.into())
|
|
} else {
|
|
Err(
|
|
"Branch changes: no merge base; use Snapshots to compare unrelated histories"
|
|
.into(),
|
|
)
|
|
};
|
|
}
|
|
let bases = text(checked(output).map_err(|e| format!("Cannot resolve merge base: {e}"))?)?;
|
|
let mut bases = bases.lines();
|
|
let base = bases.next().ok_or("Branch changes: no merge base")?;
|
|
if bases.next().is_some() {
|
|
return Err(
|
|
"Branch changes: multiple merge bases; select Snapshots or different endpoints"
|
|
.into(),
|
|
);
|
|
}
|
|
Ok(base.into())
|
|
}
|
|
fn working_anchor(&self) -> Result<Option<String>, String> {
|
|
if self.kind == RepoKind::Jujutsu {
|
|
return Ok(Some(
|
|
text(self.jj(
|
|
&self.root,
|
|
&["log", "--no-graph", "-r", "@", "-T", "change_id"],
|
|
)?)?
|
|
.trim()
|
|
.into(),
|
|
));
|
|
}
|
|
let mut command = git_command(&self.store);
|
|
command.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
let output = run(command, vec![], &self.cancelled)?;
|
|
if output.code == Some(1) {
|
|
return Ok(None); // An unborn checkout has no commit anchor yet.
|
|
}
|
|
Ok(Some(text(checked(output)?)?.trim().into()))
|
|
}
|
|
fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> {
|
|
if before == after || after.is_none() {
|
|
return Ok(false);
|
|
}
|
|
let Some(before) = before else {
|
|
return Ok(true);
|
|
};
|
|
if self.kind == RepoKind::Jujutsu {
|
|
let revset = format!(
|
|
"ancestors(@) & change_id({})",
|
|
serde_json::to_string(before).unwrap()
|
|
);
|
|
return Ok(!self
|
|
.jj(
|
|
&self.root,
|
|
&["log", "--no-graph", "-r", &revset, "-T", "change_id"],
|
|
)?
|
|
.is_empty());
|
|
}
|
|
let mut command = git_command(&self.store);
|
|
command.args(["merge-base", "--is-ancestor", before, after.unwrap()]);
|
|
let output = run(command, vec![], &self.cancelled)?;
|
|
if output.code == Some(1) {
|
|
Ok(false)
|
|
} else {
|
|
checked(output).map(|_| true)
|
|
}
|
|
}
|
|
/// Freeze comparison sources and return path/status metadata only.
|
|
/// A new comparison replaces sources retained for the previous request ID.
|
|
pub fn compare(
|
|
&mut self,
|
|
comparison_id: u64,
|
|
before: &Revision,
|
|
after: &Revision,
|
|
mode: ComparisonMode,
|
|
buffers: &[(PathBuf, String)],
|
|
) -> Result<Comparison, String> {
|
|
self.check_cancelled()?;
|
|
let 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 working_anchor = if working_after {
|
|
self.working_anchor()?
|
|
} else {
|
|
None
|
|
};
|
|
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 {
|
|
self.snapshot(after_target)?
|
|
};
|
|
let mut prepared = PreparedComparison {
|
|
id: comparison_id,
|
|
left,
|
|
right,
|
|
files: vec![],
|
|
before: before_label.into(),
|
|
after: after.label.clone(),
|
|
working_after,
|
|
working_anchor,
|
|
};
|
|
let comparison = self.build_comparison(&mut prepared, same)?;
|
|
if working_after && self.working_anchor()? != prepared.working_anchor {
|
|
return Err("Working checkout moved during comparison".into());
|
|
}
|
|
self.comparison = Some(prepared);
|
|
Ok(comparison)
|
|
}
|
|
|
|
/// Advance only a selected Working After, keeping its original Before frozen.
|
|
pub fn refresh_working_after(
|
|
&mut self,
|
|
comparison_id: u64,
|
|
new_id: u64,
|
|
buffers: &[(PathBuf, String)],
|
|
) -> Result<Option<Comparison>, String> {
|
|
let Some(prepared) = self
|
|
.comparison
|
|
.as_ref()
|
|
.filter(|prepared| prepared.id == comparison_id && prepared.working_after)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let anchor = self.working_anchor()?;
|
|
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<Comparison, String> {
|
|
let left = &mut prepared.left;
|
|
let right = &mut prepared.right;
|
|
// 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(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);
|
|
}
|
|
}
|
|
let files: Vec<_> = files
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
|
|
.collect();
|
|
let editable_paths = if prepared.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()
|
|
};
|
|
prepared.files = files
|
|
.iter()
|
|
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
|
|
.collect();
|
|
Ok(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<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);
|
|
}
|
|
}
|