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