dcfa50319c
Conflicted revisions read every file through jj file show, which returns empty output for symlinks and causes repository-wide subprocess fanout. Keep ordinary Git blob sources and immutable identities; render only selected conflicted paths through jj. Verified all 905 tests and the original local bookmark comparison with 25 correct paths in 0.522s, plus actual terminal conflict viewing. Strict Clippy is blocked by existing diagnostics outside this change.
3258 lines
120 KiB
Rust
3258 lines
120 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;
|
|
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,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn working_file_changes(
|
|
root: &Path,
|
|
buffers: &[(PathBuf, Arc<str>)],
|
|
cancelled: &dyn Fn() -> bool,
|
|
) -> Result<Vec<WorkingFileChange>, String> {
|
|
WorkingFileReader::default().read(root, buffers, cancelled)
|
|
}
|
|
|
|
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,
|
|
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,
|
|
}
|
|
|
|
pub struct Repository {
|
|
root: PathBuf,
|
|
kind: RepoKind,
|
|
store: PathBuf,
|
|
temporary: Temporary,
|
|
private: Option<(PathBuf, PathBuf)>, // workspace and Git object store
|
|
remotes: Vec<Remote>,
|
|
remote_refs: BTreeMap<(String, String), String>,
|
|
loaded: BTreeSet<String>,
|
|
registered: BTreeSet<String>,
|
|
comparison: Option<PreparedComparison>,
|
|
cancelled: Arc<AtomicBool>,
|
|
}
|
|
impl Repository {
|
|
pub fn open(root: &Path, cancelled: Arc<AtomicBool>) -> Result<Self, String> {
|
|
let root = fs::canonicalize(root).map_err(|e| e.to_string())?;
|
|
let discovered = root
|
|
.ancestors()
|
|
.find(|p| p.join(".jj").exists() || p.join(".git").exists())
|
|
.ok_or("No Jujutsu or Git working-tree repository found")?;
|
|
let (root, kind, store) = if discovered.join(".jj").exists() {
|
|
let mut cmd = jj_command(discovered);
|
|
cmd.arg("root");
|
|
let output = checked(run(cmd, vec![], &cancelled)?)?;
|
|
let actual = path_bytes(output.strip_suffix(b"\n").unwrap_or(&output))?;
|
|
let store = store_for_jj(&actual)?;
|
|
(actual, RepoKind::Jujutsu, store)
|
|
} else {
|
|
let actual = discovered.to_path_buf();
|
|
let mut cmd = Command::new("git");
|
|
cmd.arg("-C")
|
|
.arg(&actual)
|
|
.args(["rev-parse", "--absolute-git-dir"])
|
|
.env("GIT_OPTIONAL_LOCKS", "0");
|
|
let output = checked(run(cmd, vec![], &cancelled)?)?;
|
|
let store = path_bytes(output.strip_suffix(b"\n").unwrap_or(&output))?;
|
|
(actual, RepoKind::Git, store)
|
|
};
|
|
Ok(Self {
|
|
root,
|
|
kind,
|
|
store,
|
|
temporary: Temporary::new()?,
|
|
private: None,
|
|
remotes: vec![],
|
|
remote_refs: BTreeMap::new(),
|
|
loaded: BTreeSet::new(),
|
|
registered: BTreeSet::new(),
|
|
comparison: None,
|
|
cancelled,
|
|
})
|
|
}
|
|
fn git(&self, store: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
|
|
let mut cmd = git_command(store);
|
|
cmd.args(args).current_dir(&self.root);
|
|
checked(run(cmd, vec![], &self.cancelled)?)
|
|
}
|
|
fn jj(&self, root: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
|
|
let mut cmd = jj_command(root);
|
|
cmd.args(args);
|
|
checked(run(cmd, vec![], &self.cancelled)?)
|
|
}
|
|
fn check_cancelled(&self) -> Result<(), String> {
|
|
if self.cancelled.load(Ordering::Acquire) {
|
|
Err("Diff operation cancelled".into())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
fn revisions(
|
|
&self,
|
|
root: &Path,
|
|
store: &Path,
|
|
entries: &mut Vec<Revision>,
|
|
notices: &mut Vec<String>,
|
|
) -> Result<(), String> {
|
|
if self.kind == RepoKind::Jujutsu {
|
|
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| stringify(b.name() ++ "@" ++ b.remote()))) ++ "]\n""#;
|
|
let revset = if root == self.root {
|
|
"all()"
|
|
} else {
|
|
"::remote_bookmarks() | ::remote_tags()"
|
|
};
|
|
let output =
|
|
text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?;
|
|
for line in output.lines() {
|
|
let (id, change, description, mut bookmarks, remote_bookmarks): (
|
|
String,
|
|
String,
|
|
String,
|
|
Vec<String>,
|
|
Vec<String>,
|
|
) = serde_json::from_str(line)
|
|
.map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?;
|
|
bookmarks.extend(remote_bookmarks);
|
|
let target = RevisionTarget::Commit(id.clone());
|
|
entries.push(Revision {
|
|
target: target.clone(),
|
|
label: change.clone(),
|
|
description: description.trim_end_matches('\n').into(),
|
|
category: Category::Revisions,
|
|
});
|
|
for name in bookmarks {
|
|
entries.push(Revision {
|
|
target: target.clone(),
|
|
label: name,
|
|
description: format!("{change} {id} {}", description.trim_end()),
|
|
category: Category::Bookmarks,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
let output = self.git(
|
|
store,
|
|
&[
|
|
"log",
|
|
"--no-color",
|
|
"--no-show-signature",
|
|
"--no-decorate",
|
|
"--all",
|
|
"--format=%H%x00%B%x00",
|
|
],
|
|
)?;
|
|
let mut records = output.split(|b| *b == 0);
|
|
while let Some(id) = records.next() {
|
|
let id = std::str::from_utf8(id)
|
|
.map_err(|_| "Invalid Git commit ID")?
|
|
.trim();
|
|
if id.is_empty() {
|
|
continue;
|
|
}
|
|
let description = records.next().ok_or("Truncated Git log")?;
|
|
entries.push(Revision {
|
|
target: RevisionTarget::Commit(id.into()),
|
|
label: id.into(),
|
|
description: String::from_utf8_lossy(description).trim_end().into(),
|
|
category: Category::Commits,
|
|
});
|
|
}
|
|
let references = text(self.git(
|
|
store,
|
|
&[
|
|
"for-each-ref",
|
|
"--format=%(refname)",
|
|
"refs/heads",
|
|
"refs/remotes",
|
|
"refs/tags",
|
|
],
|
|
)?)?;
|
|
let mut request = Vec::new();
|
|
for reference in references.lines() {
|
|
request.extend_from_slice(reference.as_bytes());
|
|
request.extend_from_slice(b"^{commit}\n");
|
|
}
|
|
let mut cmd = git_command(store);
|
|
cmd.args(["cat-file", "--batch-check"]);
|
|
let resolved = text(checked(run(cmd, request, &self.cancelled)?)?)?;
|
|
let mut records = resolved.lines();
|
|
for reference in references.lines() {
|
|
let record = records.next().ok_or("Truncated Git reference metadata")?;
|
|
let mut fields = record.split_whitespace();
|
|
let id = fields.next().ok_or("Missing Git reference object ID")?;
|
|
if fields.next() != Some("commit") {
|
|
notices.push(format!(
|
|
"{reference} does not point to a commit; non-commit tags cannot be compared"
|
|
));
|
|
continue;
|
|
}
|
|
let category = if reference.starts_with("refs/tags/") {
|
|
Category::Tags
|
|
} else {
|
|
Category::Branches
|
|
};
|
|
let label = reference
|
|
.strip_prefix("refs/heads/")
|
|
.or_else(|| reference.strip_prefix("refs/remotes/"))
|
|
.or_else(|| reference.strip_prefix("refs/tags/"))
|
|
.unwrap_or(reference);
|
|
entries.push(Revision {
|
|
target: RevisionTarget::Commit(id.into()),
|
|
label: label.into(),
|
|
description: format!("{reference} {id}"),
|
|
category,
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn discover_remotes(&mut self) -> Result<(), String> {
|
|
let mut remotes = vec![];
|
|
if self.kind == RepoKind::Jujutsu {
|
|
for line in text(self.jj(&self.root, &["git", "remote", "list"])?)?.lines() {
|
|
let (name, url) = line
|
|
.split_once(char::is_whitespace)
|
|
.ok_or("Malformed Jujutsu remote metadata")?;
|
|
remotes.push(Remote {
|
|
name: name.into(),
|
|
url: url.trim_start().into(),
|
|
});
|
|
}
|
|
} else {
|
|
for name in text(self.git(&self.store, &["remote"])?)?.lines() {
|
|
let url = text(self.git(&self.store, &["remote", "get-url", name])?)?
|
|
.trim_end_matches('\n')
|
|
.to_owned();
|
|
remotes.push(Remote {
|
|
name: name.into(),
|
|
url,
|
|
});
|
|
}
|
|
}
|
|
for remote in &mut remotes {
|
|
if !remote.url.contains(':') && !Path::new(&remote.url).is_absolute() {
|
|
remote.url = self.root.join(&remote.url).to_string_lossy().into_owned();
|
|
}
|
|
}
|
|
self.remotes = remotes;
|
|
Ok(())
|
|
}
|
|
fn advertised(&self, url: &str) -> Result<Vec<(String, String)>, String> {
|
|
let mut cmd = Command::new("git");
|
|
cmd.args(["ls-remote", "--refs", "--heads", "--tags", "--", url])
|
|
.current_dir(&self.root)
|
|
.env("GIT_TERMINAL_PROMPT", "0");
|
|
let output = text(checked(run(cmd, vec![], &self.cancelled)?)?)?;
|
|
output
|
|
.lines()
|
|
.map(|line| {
|
|
let (id, reference) = line
|
|
.split_once('\t')
|
|
.ok_or("Malformed remote reference metadata")?;
|
|
if !matches!(id.len(), 40 | 64)
|
|
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
|
|
|| !(reference.starts_with("refs/heads/")
|
|
|| reference.starts_with("refs/tags/"))
|
|
{
|
|
return Err("Invalid remote reference metadata".into());
|
|
}
|
|
Ok((reference.into(), id.into()))
|
|
})
|
|
.collect()
|
|
}
|
|
pub fn catalog(&mut self) -> Result<Catalog, String> {
|
|
self.check_cancelled()?;
|
|
self.discover_remotes()?;
|
|
let category = if self.kind == RepoKind::Jujutsu {
|
|
Category::Revisions
|
|
} else {
|
|
Category::Commits
|
|
};
|
|
let mut entries = vec![Revision {
|
|
target: RevisionTarget::Working,
|
|
label: "Working checkout".into(),
|
|
description:
|
|
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
|
|
category,
|
|
}];
|
|
let mut notices = vec![];
|
|
self.revisions(&self.root, &self.store, &mut entries, &mut notices)?;
|
|
if let Some((root, store)) = &self.private {
|
|
self.revisions(root, store, &mut entries, &mut notices)?;
|
|
}
|
|
for remote in &self.remotes {
|
|
for &view in self.kind.categories() {
|
|
let history = view == category;
|
|
entries.push(Revision {
|
|
target: if history {
|
|
RevisionTarget::FetchRemote(remote.name.clone())
|
|
} else {
|
|
RevisionTarget::ListRemote(remote.name.clone())
|
|
},
|
|
label: format!(
|
|
"Load {} from {}",
|
|
if history { "history" } else { "refs" },
|
|
remote.name
|
|
),
|
|
description: if history {
|
|
"Fetch reachable history into a private comparison repository"
|
|
} else {
|
|
"List remote references without fetching history"
|
|
}
|
|
.into(),
|
|
category: view,
|
|
});
|
|
}
|
|
for ((name, reference), id) in &self.remote_refs {
|
|
if name != &remote.name {
|
|
continue;
|
|
}
|
|
let tag = reference.starts_with("refs/tags/");
|
|
let label = reference
|
|
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
|
|
.unwrap();
|
|
if !tag || self.kind == RepoKind::Git {
|
|
entries.push(Revision {
|
|
target: RevisionTarget::Remote {
|
|
remote: name.clone(),
|
|
reference: reference.clone(),
|
|
},
|
|
label: format!("{label}@{name}"),
|
|
description: id.clone(),
|
|
category: if tag {
|
|
Category::Tags
|
|
} else if self.kind == RepoKind::Jujutsu {
|
|
Category::Bookmarks
|
|
} else {
|
|
Category::Branches
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
self.check_cancelled()?;
|
|
let mut seen = BTreeSet::new();
|
|
entries.retain(|entry| {
|
|
let key = format!("{:?}|{:?}|{}", entry.target, entry.category, entry.label);
|
|
seen.insert(key)
|
|
});
|
|
Ok(Catalog {
|
|
kind: self.kind,
|
|
entries,
|
|
notice: if notices.is_empty() {
|
|
None
|
|
} else {
|
|
Some(notices.join("; "))
|
|
},
|
|
})
|
|
}
|
|
fn ensure_private(&mut self) -> Result<(), String> {
|
|
if self.private.is_some() {
|
|
return Ok(());
|
|
}
|
|
let root = self.temporary.0.join("repository");
|
|
if self.kind == RepoKind::Jujutsu {
|
|
let mut cmd = Command::new("jj");
|
|
cmd.args([
|
|
"--no-pager",
|
|
"--color=never",
|
|
"git",
|
|
"init",
|
|
"--no-colocate",
|
|
])
|
|
.arg(&root);
|
|
checked(run(cmd, vec![], &self.cancelled)?)?;
|
|
let store = store_for_jj(&root)?;
|
|
self.private = Some((root, store));
|
|
} else {
|
|
let format = text(self.git(&self.store, &["rev-parse", "--show-object-format"])?)?;
|
|
let mut cmd = Command::new("git");
|
|
cmd.args(["init", "--bare"])
|
|
.arg(format!("--object-format={}", format.trim()))
|
|
.arg(&root);
|
|
checked(run(cmd, vec![], &self.cancelled)?)?;
|
|
self.private = Some((root.clone(), root));
|
|
}
|
|
// Only the private store gains alternates; active refs and objects stay untouched.
|
|
let objects = self.git(
|
|
&self.store,
|
|
&[
|
|
"rev-parse",
|
|
"--path-format=absolute",
|
|
"--git-path",
|
|
"objects",
|
|
],
|
|
)?;
|
|
let mut alternate = vec![b'"'];
|
|
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
|
|
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
|
|
alternate.push(byte);
|
|
} else {
|
|
write!(&mut alternate, "\\{byte:03o}").unwrap();
|
|
}
|
|
}
|
|
alternate.extend_from_slice(b"\"\n");
|
|
let store = &self.private.as_ref().unwrap().1;
|
|
fs::write(store.join("objects/info/alternates"), alternate).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
fn load_remote(&mut self, name: &str, reference: Option<&str>) -> Result<(), String> {
|
|
if self.remotes.is_empty() {
|
|
self.discover_remotes()?;
|
|
}
|
|
let url = self
|
|
.remotes
|
|
.iter()
|
|
.find(|r| r.name == name)
|
|
.ok_or_else(|| format!("Unknown configured remote: {name}"))?
|
|
.url
|
|
.clone();
|
|
self.ensure_private()?;
|
|
let (root, store) = self.private.as_ref().unwrap();
|
|
if self.kind == RepoKind::Jujutsu {
|
|
if !self.registered.contains(name) {
|
|
self.jj(root, &["git", "remote", "add", "--", name, &url])?;
|
|
self.registered.insert(name.into());
|
|
}
|
|
self.jj(
|
|
root,
|
|
&[
|
|
"git",
|
|
"fetch",
|
|
"--remote",
|
|
&format!("exact:{name}"),
|
|
"--branch",
|
|
&reference.map_or_else(
|
|
|| Ok("glob:*".to_owned()),
|
|
|reference| {
|
|
reference
|
|
.strip_prefix("refs/heads/")
|
|
.map(|branch| format!("exact:{branch}"))
|
|
.ok_or("Jujutsu remote selection requires a bookmark")
|
|
},
|
|
)?,
|
|
],
|
|
)?;
|
|
} else if let Some(reference) = reference {
|
|
self.git(
|
|
store,
|
|
&[
|
|
"fetch",
|
|
"--no-tags",
|
|
"--no-write-fetch-head",
|
|
"--",
|
|
&url,
|
|
&format!("+{reference}:refs/vamp-selected/{name}/{reference}"),
|
|
],
|
|
)?;
|
|
} else {
|
|
self.git(
|
|
store,
|
|
&[
|
|
"fetch",
|
|
"--no-write-fetch-head",
|
|
"--",
|
|
&url,
|
|
&format!("+refs/heads/*:refs/remotes/{name}/*"),
|
|
&format!("+refs/tags/*:refs/vamp-tags/{name}/*"),
|
|
],
|
|
)?;
|
|
}
|
|
if reference.is_none() {
|
|
self.loaded.insert(name.into());
|
|
let refs = self.advertised(&url)?;
|
|
self.remote_refs.retain(|(remote, _), _| remote != name);
|
|
for (reference, id) in refs {
|
|
self.remote_refs.insert((name.into(), reference), id);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn list_remote(&mut self, name: &str) -> Result<Catalog, String> {
|
|
self.discover_remotes()?;
|
|
let remote = self
|
|
.remotes
|
|
.iter()
|
|
.find(|remote| remote.name == name)
|
|
.ok_or_else(|| format!("Unknown configured remote: {name}"))?;
|
|
let refs = self.advertised(&remote.url)?;
|
|
self.remote_refs.retain(|(remote, _), _| remote != name);
|
|
self.remote_refs.extend(
|
|
refs.into_iter()
|
|
.map(|(reference, id)| ((name.into(), reference), id)),
|
|
);
|
|
self.catalog()
|
|
}
|
|
pub fn fetch_remote(&mut self, remote: &str) -> Result<Catalog, String> {
|
|
self.load_remote(remote, None)?;
|
|
self.catalog()
|
|
}
|
|
fn 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 resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
|
|
let id = match target {
|
|
RevisionTarget::Commit(id) | RevisionTarget::RemoteCommit { id, .. } => {
|
|
if !matches!(id.len(), 40 | 64)
|
|
|| (self.kind == RepoKind::Jujutsu && id.len() != 40)
|
|
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
|
|
{
|
|
return Err(
|
|
"Invalid immutable commit ID (expected a full hexadecimal object ID)"
|
|
.into(),
|
|
);
|
|
}
|
|
if id.bytes().all(|b| b == b'0') && self.kind == RepoKind::Jujutsu {
|
|
return Ok((self.store.clone(), id.clone()));
|
|
}
|
|
id.clone()
|
|
}
|
|
RevisionTarget::Remote { remote, reference } => self
|
|
.remote_refs
|
|
.get(&(remote.clone(), reference.clone()))
|
|
.ok_or_else(|| {
|
|
format!("Remote reference is no longer advertised: {reference}@{remote}")
|
|
})?
|
|
.clone(),
|
|
RevisionTarget::Working => return Err("Working checkout is not a commit".into()),
|
|
RevisionTarget::FetchRemote(_)
|
|
| RevisionTarget::ListRemote(_)
|
|
| RevisionTarget::PullRequest { .. } => {
|
|
return Err("Select a revision, not a history loader".into());
|
|
}
|
|
};
|
|
if let Some(resolved) = self.resolve_stored(&id)? {
|
|
return Ok(resolved);
|
|
}
|
|
if let RevisionTarget::Remote { remote, reference } = target {
|
|
self.load_remote(remote, Some(reference))?;
|
|
if let Some(resolved) = self.resolve_stored(&id)? {
|
|
return Ok(resolved);
|
|
}
|
|
}
|
|
if let RevisionTarget::RemoteCommit {
|
|
remote, reference, ..
|
|
} = target
|
|
{
|
|
if !reference.starts_with("refs/") {
|
|
return Err("Invalid pull request reference".into());
|
|
}
|
|
self.git(&self.store, &["check-ref-format", reference])?;
|
|
self.discover_remotes()?;
|
|
let url = self
|
|
.remotes
|
|
.iter()
|
|
.find(|entry| &entry.name == remote)
|
|
.ok_or_else(|| format!("Unknown configured remote: {remote}"))?
|
|
.url
|
|
.clone();
|
|
self.ensure_private()?;
|
|
let store = &self.private.as_ref().unwrap().1;
|
|
// Closed PR refs can disappear; servers may still serve their immutable commits.
|
|
if self
|
|
.git(
|
|
store,
|
|
&[
|
|
"fetch",
|
|
"--no-tags",
|
|
"--no-write-fetch-head",
|
|
"--",
|
|
&url,
|
|
&format!("+{id}:refs/vamp-pull-request/selected"),
|
|
],
|
|
)
|
|
.is_err()
|
|
{
|
|
self.git(
|
|
store,
|
|
&[
|
|
"fetch",
|
|
"--no-tags",
|
|
"--no-write-fetch-head",
|
|
"--",
|
|
&url,
|
|
&format!("+{reference}:refs/vamp-pull-request/selected"),
|
|
],
|
|
)
|
|
.map_err(|error| {
|
|
format!("Pull request commit unavailable; reopen :pull_requests: {error}")
|
|
})?;
|
|
}
|
|
if let Some(resolved) = self.resolve_stored(&id)? {
|
|
return Ok(resolved);
|
|
}
|
|
return Err(
|
|
"Pull request changed or its commit is unavailable; reopen :pull_requests".into(),
|
|
);
|
|
}
|
|
Err(format!(
|
|
"Commit {id} is unavailable locally or in loaded remote history"
|
|
))
|
|
}
|
|
|
|
fn resolve_stored(&self, id: &str) -> Result<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> {
|
|
let stores: BTreeSet<_> = snapshot
|
|
.values()
|
|
.filter(|file| file.projection.is_none())
|
|
.filter_map(|file| file.object.as_ref().map(|(store, _)| store.clone()))
|
|
.collect();
|
|
for store in stores {
|
|
let mut request = Vec::new();
|
|
for file in snapshot.values() {
|
|
if let Some((source, id)) = &file.object
|
|
&& source == &store
|
|
&& file.projection.is_none()
|
|
{
|
|
request.extend_from_slice(id.as_bytes());
|
|
request.push(b'\n');
|
|
}
|
|
}
|
|
let mut cmd = git_command(&store);
|
|
cmd.args(["cat-file", "--batch"]);
|
|
let data = checked(run(cmd, request, &self.cancelled)?)?;
|
|
let mut offset = 0;
|
|
for (path, file) in snapshot.iter_mut() {
|
|
if file.projection.is_some()
|
|
|| file
|
|
.object
|
|
.as_ref()
|
|
.is_none_or(|(source, _)| source != &store)
|
|
{
|
|
continue;
|
|
}
|
|
self.check_cancelled()?;
|
|
let end = offset
|
|
+ data[offset..]
|
|
.iter()
|
|
.position(|b| *b == b'\n')
|
|
.ok_or("Truncated Git object header")?;
|
|
let header = std::str::from_utf8(&data[offset..end])
|
|
.map_err(|_| "Invalid Git object header")?;
|
|
let size: usize = header
|
|
.rsplit(' ')
|
|
.next()
|
|
.ok_or("Missing Git object size")?
|
|
.parse()
|
|
.map_err(|_| "Invalid Git object size")?;
|
|
if size > FILE_LIMIT {
|
|
return Err(format!(
|
|
"{} exceeds the 16 MiB comparison file limit",
|
|
path.display()
|
|
));
|
|
}
|
|
offset = end + 1;
|
|
if size >= data.len().saturating_sub(offset) {
|
|
return Err("Truncated Git object content".into());
|
|
}
|
|
file.bytes = data[offset..offset + size].to_vec();
|
|
file.object = None;
|
|
offset += size + 1;
|
|
}
|
|
}
|
|
let mut total = 0;
|
|
for (path, file) in snapshot.iter_mut() {
|
|
let Some((root, id)) = &file.projection else {
|
|
continue;
|
|
};
|
|
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
|
|
let pattern = jj_file_pattern(path.to_str().ok_or("Non-UTF-8 Jujutsu conflict path")?);
|
|
let bytes = self.jj(
|
|
root,
|
|
&["file", "show", "-r", &revset, "-T", "", "--", &pattern],
|
|
)?;
|
|
if bytes.len() > FILE_LIMIT {
|
|
return Err(format!(
|
|
"{} exceeds the 16 MiB comparison file limit",
|
|
path.display()
|
|
));
|
|
}
|
|
total += bytes.len();
|
|
if total > OUTPUT_LIMIT {
|
|
return Err(
|
|
"Jujutsu conflict projection exceeds the 64 MiB comparison limit".into(),
|
|
);
|
|
}
|
|
file.bytes = bytes;
|
|
file.object = None;
|
|
file.projection = None;
|
|
}
|
|
Ok(())
|
|
}
|
|
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
|
|
let mut tracked = BTreeSet::new();
|
|
let mut submodules = BTreeSet::new();
|
|
if self.kind == RepoKind::Git {
|
|
for record in self
|
|
.git(&self.store, &["ls-files", "--stage", "-z"])?
|
|
.split(|b| *b == 0)
|
|
.filter(|p| !p.is_empty())
|
|
{
|
|
let tab = record
|
|
.iter()
|
|
.position(|b| *b == b'\t')
|
|
.ok_or("Malformed Git index entry")?;
|
|
let path = path_bytes(&record[tab + 1..])?;
|
|
if record.starts_with(b"160000 ") {
|
|
submodules.insert(path.clone());
|
|
}
|
|
tracked.insert(path);
|
|
}
|
|
} else {
|
|
let output = text(self.jj(
|
|
&self.root,
|
|
&[
|
|
"file",
|
|
"list",
|
|
"-T",
|
|
"\"[\" ++ json(path) ++ \",\" ++ json(file_type) ++ \"]\\n\"",
|
|
],
|
|
)?)?;
|
|
for line in output.lines() {
|
|
let (path, kind): (String, String) =
|
|
serde_json::from_str(line).map_err(|e| e.to_string())?;
|
|
let path = PathBuf::from(path);
|
|
if kind == "git-submodule" {
|
|
submodules.insert(path.clone());
|
|
}
|
|
tracked.insert(path);
|
|
}
|
|
}
|
|
let mut paths = tracked;
|
|
if self.kind == RepoKind::Git {
|
|
for path in self
|
|
.git(
|
|
&self.store,
|
|
&["ls-files", "--others", "--exclude-standard", "-z"],
|
|
)?
|
|
.split(|b| *b == 0)
|
|
.filter(|p| !p.is_empty())
|
|
{
|
|
paths.insert(path_bytes(path)?);
|
|
}
|
|
} else {
|
|
let excluded = submodules.clone();
|
|
let checkout = self.root.clone();
|
|
let walker = ignore::WalkBuilder::new(&self.root)
|
|
.hidden(false)
|
|
.ignore(false)
|
|
.parents(false)
|
|
.require_git(false)
|
|
.follow_links(false)
|
|
.filter_entry(move |entry| {
|
|
entry.file_name() != OsStr::new(".git")
|
|
&& entry.file_name() != OsStr::new(".jj")
|
|
&& !entry
|
|
.path()
|
|
.strip_prefix(&checkout)
|
|
.is_ok_and(|path| excluded.contains(path))
|
|
})
|
|
.build();
|
|
for entry in walker {
|
|
self.check_cancelled()?;
|
|
let entry = entry.map_err(|e| e.to_string())?;
|
|
if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
|
|
paths.insert(
|
|
entry
|
|
.path()
|
|
.strip_prefix(&self.root)
|
|
.map_err(|e| e.to_string())?
|
|
.to_path_buf(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let overlays: BTreeMap<_, _> = buffers
|
|
.iter()
|
|
.filter_map(|(path, text)| {
|
|
let relative = path.strip_prefix(&self.root).ok()?;
|
|
if safe_path(relative).is_err()
|
|
|| !path.is_absolute()
|
|
|| text.as_bytes().contains(&0)
|
|
{
|
|
return None;
|
|
}
|
|
// Reject symlinks in every component, not just the final file.
|
|
let mut full = self.root.clone();
|
|
for component in relative.components() {
|
|
full.push(component);
|
|
let metadata = fs::symlink_metadata(&full).ok()?;
|
|
if metadata.file_type().is_symlink() {
|
|
return None;
|
|
}
|
|
}
|
|
fs::symlink_metadata(&full)
|
|
.ok()?
|
|
.is_file()
|
|
.then_some((relative.to_path_buf(), text))
|
|
})
|
|
.collect();
|
|
paths.extend(overlays.keys().cloned());
|
|
let mut total = 0;
|
|
let mut snapshot = BTreeMap::new();
|
|
for path in paths {
|
|
self.check_cancelled()?;
|
|
safe_path(&path)?;
|
|
let full = self.root.join(&path);
|
|
// Never follow an intermediate symlink outside the checkout.
|
|
let mut parent = full.parent();
|
|
let mut blocked = false;
|
|
while let Some(p) = parent {
|
|
if p == self.root {
|
|
break;
|
|
}
|
|
if fs::symlink_metadata(p).is_ok_and(|m| m.file_type().is_symlink()) {
|
|
blocked = true;
|
|
break;
|
|
}
|
|
parent = p.parent();
|
|
}
|
|
if blocked {
|
|
continue;
|
|
} // The parent symlink itself is the on-disk replacement.
|
|
let metadata = match fs::symlink_metadata(&full) {
|
|
Ok(m) => m,
|
|
Err(e)
|
|
if e.kind() == std::io::ErrorKind::NotFound
|
|
|| e.kind() == std::io::ErrorKind::NotADirectory =>
|
|
{
|
|
continue;
|
|
}
|
|
Err(e) => return Err(format!("{}: {e}", path.display())),
|
|
};
|
|
if metadata.is_dir() {
|
|
if submodules.contains(&path) || full.join(".git").exists() {
|
|
let notice = if full.join(".git").exists() {
|
|
let mut cmd = Command::new("git");
|
|
cmd.arg("-C")
|
|
.arg(&full)
|
|
.args(["rev-parse", "--verify", "HEAD"])
|
|
.env("GIT_OPTIONAL_LOCKS", "0");
|
|
let output = run(cmd, vec![], &self.cancelled)?;
|
|
if output.code == Some(0) {
|
|
format!(
|
|
"Submodule commit {} (contents are not traversed)",
|
|
text(output.stdout)?.trim()
|
|
)
|
|
} else {
|
|
"Submodule revision is unavailable; contents are not traversed".into()
|
|
}
|
|
} else {
|
|
"Submodule directory is not initialized; contents are not traversed".into()
|
|
};
|
|
snapshot.insert(
|
|
path,
|
|
FileData {
|
|
mode: "160000".into(),
|
|
bytes: vec![],
|
|
object: None,
|
|
projection: None,
|
|
notice: Some(notice),
|
|
},
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
let (mode, bytes, notice) = if metadata.file_type().is_symlink() {
|
|
let target = fs::read_link(&full).map_err(|e| e.to_string())?;
|
|
#[cfg(unix)]
|
|
let bytes = {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
target.as_os_str().as_bytes().to_vec()
|
|
};
|
|
#[cfg(not(unix))]
|
|
let bytes = target.to_string_lossy().as_bytes().to_vec();
|
|
("120000".into(), bytes, None)
|
|
} else if metadata.is_file() {
|
|
if metadata.len() > FILE_LIMIT as u64 {
|
|
return Err(format!(
|
|
"{} exceeds the 16 MiB comparison file limit",
|
|
path.display()
|
|
));
|
|
}
|
|
let bytes = fs::File::open(&full)
|
|
.and_then(|f| {
|
|
let mut bytes = Vec::new();
|
|
f.take(FILE_LIMIT as u64 + 1).read_to_end(&mut bytes)?;
|
|
Ok(bytes)
|
|
})
|
|
.map_err(|e| format!("{}: {e}", path.display()))?;
|
|
if bytes.len() > FILE_LIMIT {
|
|
return Err(format!(
|
|
"{} grew beyond the 16 MiB comparison file limit",
|
|
path.display()
|
|
));
|
|
}
|
|
let bytes = if !bytes.contains(&0)
|
|
&& std::str::from_utf8(&bytes).is_ok()
|
|
&& let Some(text) = overlays.get(&path)
|
|
{
|
|
if text.len() > FILE_LIMIT {
|
|
return Err(format!(
|
|
"{} exceeds the 16 MiB comparison file limit",
|
|
path.display()
|
|
));
|
|
}
|
|
text.as_bytes().to_vec()
|
|
} else {
|
|
bytes
|
|
};
|
|
#[cfg(unix)]
|
|
let executable = {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
metadata.permissions().mode() & 0o111 != 0
|
|
};
|
|
#[cfg(not(unix))]
|
|
let executable = false;
|
|
(
|
|
if executable { "100755" } else { "100644" }.into(),
|
|
bytes,
|
|
None,
|
|
)
|
|
} else {
|
|
(
|
|
"unsupported".into(),
|
|
vec![],
|
|
Some("Unsupported on-disk file type; contents are not read".into()),
|
|
)
|
|
};
|
|
total += bytes.len();
|
|
if total > OUTPUT_LIMIT {
|
|
return Err("Working snapshot exceeds the 64 MiB comparison limit".into());
|
|
}
|
|
snapshot.insert(
|
|
path,
|
|
FileData {
|
|
mode,
|
|
bytes,
|
|
object: None,
|
|
projection: None,
|
|
notice,
|
|
},
|
|
);
|
|
}
|
|
Ok(snapshot)
|
|
}
|
|
fn snapshot(&mut self, target: &RevisionTarget) -> Result<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 resolved = if mode == ComparisonMode::BranchChanges
|
|
&& before.target != RevisionTarget::Working
|
|
&& after.target != RevisionTarget::Working
|
|
{
|
|
let (_, before_id) = self.resolve(&before.target)?;
|
|
let (_, after_id) = self.resolve(&after.target)?;
|
|
let base = self.merge_base(&before_id, &after_id)?;
|
|
let label = format!(
|
|
"merge base of {} and {} · {base}",
|
|
before.label, after.label
|
|
);
|
|
Some((
|
|
RevisionTarget::Commit(base),
|
|
RevisionTarget::Commit(after_id),
|
|
label,
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
let (before_target, after_target, before_label) = resolved.as_ref().map_or(
|
|
(&before.target, &after.target, before.label.as_str()),
|
|
|(before, after, label)| (before, after, label.as_str()),
|
|
);
|
|
let working_after = *after_target == RevisionTarget::Working;
|
|
let checkout = self.working_anchor()?;
|
|
let pinned = if working_after {
|
|
None
|
|
} else {
|
|
let (store, id) = self.resolve(after_target)?;
|
|
Some((store, id))
|
|
};
|
|
let pinned_checkout = pinned
|
|
.as_ref()
|
|
.map(|(_, id)| self.pinned_checkout(id))
|
|
.transpose()?
|
|
.flatten();
|
|
let editable_after =
|
|
working_after || (pinned_checkout.is_some() && pinned_checkout == checkout);
|
|
let left = self.snapshot(before_target)?;
|
|
let same = before_target == after_target && (!working_after || buffers.is_empty());
|
|
let right = if same {
|
|
BTreeMap::new()
|
|
} else if working_after {
|
|
self.working_snapshot(buffers)?
|
|
} else {
|
|
let (store, id) = pinned.as_ref().unwrap();
|
|
self.commit_snapshot(store, id)?
|
|
};
|
|
let mut prepared = PreparedComparison {
|
|
id: comparison_id,
|
|
left,
|
|
right,
|
|
files: vec![],
|
|
before: before_label.into(),
|
|
after: after.label.clone(),
|
|
working_after,
|
|
working_anchor: working_after.then(|| checkout.clone()).flatten(),
|
|
pinned_commit: pinned.map(|(_, id)| id),
|
|
pinned_checkout,
|
|
editable_after,
|
|
};
|
|
let comparison = self.build_comparison(&mut prepared, same)?;
|
|
if self.working_anchor()? != checkout {
|
|
return Err("Checkout moved during comparison".into());
|
|
}
|
|
self.comparison = Some(prepared);
|
|
Ok(comparison)
|
|
}
|
|
|
|
/// Follow Working forward when enabled, or update a pinned After's eligibility.
|
|
/// Pinned sources and path catalogs never change when checkout eligibility does.
|
|
pub fn refresh_checkout(
|
|
&mut self,
|
|
comparison_id: u64,
|
|
new_id: u64,
|
|
follow_working: bool,
|
|
buffers: &[(PathBuf, String)],
|
|
) -> Result<Option<Comparison>, 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<Comparison>, String> {
|
|
let Some(prepared) = self
|
|
.comparison
|
|
.as_ref()
|
|
.filter(|prepared| prepared.id == comparison_id)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
self.check_cancelled()?;
|
|
if prepared.working_after && !follow_working {
|
|
return Ok(None);
|
|
}
|
|
if prepared.pinned_checkout.is_none()
|
|
&& let Some(id) = prepared.pinned_commit.as_deref()
|
|
{
|
|
// A privately loaded endpoint may later be imported into active jj.
|
|
// Retry only its frozen commit ID, never the mutable remote alias.
|
|
let identity = self.pinned_checkout(id)?;
|
|
self.comparison.as_mut().unwrap().pinned_checkout = identity;
|
|
}
|
|
let prepared = self.comparison.as_ref().unwrap();
|
|
let anchor = self.working_anchor()?;
|
|
if !prepared.working_after {
|
|
let editable = prepared.pinned_checkout.is_some() && prepared.pinned_checkout == anchor;
|
|
if editable == prepared.editable_after {
|
|
return Ok(None);
|
|
}
|
|
// No live contents enter the frozen comparison, including dirty
|
|
// frontend buffer overlays.
|
|
let mut prepared = self.comparison.take().unwrap();
|
|
prepared.editable_after = editable;
|
|
let comparison = self.comparison_catalog(&prepared);
|
|
let result = self.check_cancelled().and_then(|()| {
|
|
if self.working_anchor()? != anchor {
|
|
return Err("Checkout moved during comparison".into());
|
|
}
|
|
Ok(comparison)
|
|
});
|
|
if result.is_ok() {
|
|
prepared.id = new_id;
|
|
}
|
|
self.comparison = Some(prepared);
|
|
return result.map(Some);
|
|
}
|
|
if !self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())? {
|
|
return Ok(None);
|
|
}
|
|
let right = self.working_snapshot(buffers)?;
|
|
let mut prepared = self.comparison.take().unwrap();
|
|
let previous_right = std::mem::replace(&mut prepared.right, right);
|
|
let previous_files = std::mem::take(&mut prepared.files);
|
|
let result = self
|
|
.build_comparison(&mut prepared, false)
|
|
.and_then(|comparison| {
|
|
self.check_cancelled()?;
|
|
if self.working_anchor()? != anchor {
|
|
return Err("Working checkout moved during comparison".into());
|
|
}
|
|
Ok(comparison)
|
|
});
|
|
if result.is_ok() {
|
|
prepared.id = new_id;
|
|
prepared.working_anchor = anchor;
|
|
} else {
|
|
prepared.right = previous_right;
|
|
prepared.files = previous_files;
|
|
}
|
|
self.comparison = Some(prepared);
|
|
result.map(Some)
|
|
}
|
|
|
|
fn build_comparison(
|
|
&self,
|
|
prepared: &mut PreparedComparison,
|
|
same: bool,
|
|
) -> Result<Comparison, String> {
|
|
let left = &mut prepared.left;
|
|
let right = &mut prepared.right;
|
|
// Frozen checkout bytes have no object identity. Compare them in a single
|
|
// batch; immutable blobs and deferred conflict projections retain IDs.
|
|
let frozen = |file: &FileData| {
|
|
file.object.is_none() && matches!(file.mode.as_str(), "100644" | "100755" | "120000")
|
|
};
|
|
if !same && (left.values().any(frozen) || right.values().any(frozen)) {
|
|
self.materialize(left)?;
|
|
self.materialize(right)?;
|
|
}
|
|
let right_view = if same { &*left } else { &*right };
|
|
let mut files = vec![];
|
|
let paths: BTreeSet<_> = left.keys().chain(right_view.keys()).collect();
|
|
for path in paths {
|
|
self.check_cancelled()?;
|
|
let a = left.get(path);
|
|
let b = right_view.get(path);
|
|
if a.map(file_identity) == b.map(file_identity) {
|
|
continue;
|
|
}
|
|
files.push(ChangedFile {
|
|
path: path.clone(),
|
|
old_path: None,
|
|
status: if a.is_none() {
|
|
FileStatus::Added
|
|
} else if b.is_none() {
|
|
FileStatus::Removed
|
|
} else {
|
|
FileStatus::Modified
|
|
},
|
|
loaded: false,
|
|
before: vec![],
|
|
after: vec![],
|
|
before_eof: false,
|
|
after_eof: false,
|
|
hunks: vec![],
|
|
notice: None,
|
|
});
|
|
}
|
|
// Match only identical data; modified-content moves remain add/remove pairs.
|
|
let mut candidates = BTreeMap::new();
|
|
for (i, file) in files.iter().enumerate() {
|
|
if file.status == FileStatus::Removed {
|
|
candidates
|
|
.entry(file_identity(&left[&file.path]))
|
|
.or_insert_with(Vec::new)
|
|
.push(i);
|
|
}
|
|
}
|
|
let mut removed = BTreeSet::new();
|
|
for i in 0..files.len() {
|
|
if files[i].status != FileStatus::Added {
|
|
continue;
|
|
}
|
|
if let Some(j) = candidates
|
|
.get_mut(&file_identity(&right_view[&files[i].path]))
|
|
.and_then(Vec::pop)
|
|
{
|
|
files[i].status = FileStatus::Renamed;
|
|
files[i].old_path = Some(files[j].path.clone());
|
|
removed.insert(j);
|
|
}
|
|
}
|
|
prepared.files = files
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter_map(|(i, file)| {
|
|
(!removed.contains(&i)).then_some((file.path, file.old_path, file.status))
|
|
})
|
|
.collect();
|
|
Ok(self.comparison_catalog(prepared))
|
|
}
|
|
|
|
fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison {
|
|
let files = prepared
|
|
.files
|
|
.iter()
|
|
.map(|(path, old_path, status)| ChangedFile {
|
|
path: path.clone(),
|
|
old_path: old_path.clone(),
|
|
status: *status,
|
|
loaded: false,
|
|
before: vec![],
|
|
after: vec![],
|
|
before_eof: false,
|
|
after_eof: false,
|
|
hunks: vec![],
|
|
notice: None,
|
|
})
|
|
.collect();
|
|
let editable_paths = if prepared.editable_after {
|
|
prepared
|
|
.files
|
|
.iter()
|
|
.filter(|(path, _, _)| {
|
|
prepared
|
|
.right
|
|
.get(path)
|
|
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
|
|
})
|
|
.map(|(path, _, _)| path.clone())
|
|
.collect()
|
|
} else {
|
|
BTreeSet::new()
|
|
};
|
|
Comparison {
|
|
before: prepared.before.clone(),
|
|
after: prepared.after.clone(),
|
|
files,
|
|
root: self.root.clone(),
|
|
editable_paths,
|
|
}
|
|
}
|
|
|
|
/// Prepare one catalog entry using its original comparison's frozen sources.
|
|
pub fn load_file(
|
|
&mut self,
|
|
comparison_id: u64,
|
|
file_index: usize,
|
|
) -> Result<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 {
|
|
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))]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String {
|
|
let mut command = Command::new(program);
|
|
if program == "jj" {
|
|
command.args([
|
|
"--config=user.name=\"Picker Author\"",
|
|
"--config=user.email=\"picker@example.test\"",
|
|
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
|
]);
|
|
}
|
|
let output = command
|
|
.args(args)
|
|
.current_dir(root)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
|
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
|
.env("JJ_CONFIG", "/dev/null")
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
String::from_utf8(output.stdout).unwrap().trim().into()
|
|
}
|
|
|
|
fn picker_git_fixture() -> Temporary {
|
|
let temp = Temporary::new().unwrap();
|
|
fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
|
fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]);
|
|
fixture_command(
|
|
&temp.0,
|
|
"git",
|
|
&["config", "user.email", "picker@example.test"],
|
|
);
|
|
temp
|
|
}
|
|
|
|
fn changes_by_path(
|
|
root: &Path,
|
|
buffers: &[(PathBuf, Arc<str>)],
|
|
) -> BTreeMap<PathBuf, WorkingFileChange> {
|
|
working_file_changes(root, buffers, &|| false)
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|change| (change.path.clone(), change))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() {
|
|
use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt};
|
|
let temp = picker_git_fixture();
|
|
let root = &temp.0;
|
|
for (name, contents) in [
|
|
(".gitignore", "ignored*\n"),
|
|
("modified", "original\r\n"),
|
|
("unchanged", "same\n"),
|
|
("removed", "deleted\n"),
|
|
("old", "rename content\n"),
|
|
("duplicate-one", "duplicate\n"),
|
|
("duplicate-two", "duplicate\n"),
|
|
("mode", "mode\n"),
|
|
("binary", "before\0binary"),
|
|
] {
|
|
fs::write(root.join(name), contents).unwrap();
|
|
}
|
|
let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec()));
|
|
let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") {
|
|
Ok(()) => Some(non_utf8),
|
|
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None,
|
|
Err(error) => panic!("cannot create byte filename: {error}"),
|
|
};
|
|
fixture_command(root, "git", &["add", "--all"]);
|
|
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
|
fs::write(root.join("modified"), "staged\n").unwrap();
|
|
fixture_command(root, "git", &["add", "--", "modified"]);
|
|
fs::write(root.join("modified"), "unstaged\n").unwrap();
|
|
fs::write(root.join("binary"), "after\0binary").unwrap();
|
|
if let Some(name) = &non_utf8 {
|
|
fs::write(root.join(name), "after\n").unwrap();
|
|
}
|
|
fs::remove_file(root.join("removed")).unwrap();
|
|
let renamed = "-new [file]\n\t\".txt";
|
|
fs::rename(root.join("old"), root.join(renamed)).unwrap();
|
|
fs::remove_file(root.join("duplicate-one")).unwrap();
|
|
fs::remove_file(root.join("duplicate-two")).unwrap();
|
|
fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap();
|
|
fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap();
|
|
fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap();
|
|
fs::write(root.join("added"), "added\n").unwrap();
|
|
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
|
fs::write(root.join("ignored-tracked"), "forced\n").unwrap();
|
|
fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]);
|
|
std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap();
|
|
let index = fs::read(root.join(".git/index")).unwrap();
|
|
let head = fs::read(root.join(".git/HEAD")).unwrap();
|
|
let disk = changes_by_path(root, &[]);
|
|
for name in ["modified", "binary", "mode"] {
|
|
assert_eq!(disk[Path::new(name)].status, FileStatus::Modified);
|
|
}
|
|
if let Some(name) = &non_utf8 {
|
|
assert_eq!(disk[name].status, FileStatus::Modified);
|
|
}
|
|
assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed);
|
|
assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed);
|
|
assert_eq!(
|
|
disk[Path::new(renamed)].old_path.as_deref(),
|
|
Some(Path::new("old"))
|
|
);
|
|
for name in [
|
|
"added",
|
|
"ignored-tracked",
|
|
"new-duplicate-one",
|
|
"new-duplicate-two",
|
|
] {
|
|
assert_eq!(disk[Path::new(name)].status, FileStatus::Added);
|
|
}
|
|
for name in ["duplicate-one", "duplicate-two"] {
|
|
assert_eq!(disk[Path::new(name)].status, FileStatus::Removed);
|
|
}
|
|
for name in ["unchanged", "old", "ignored", "link"] {
|
|
assert!(!disk.contains_key(Path::new(name)), "{name}");
|
|
}
|
|
let overlays = changes_by_path(
|
|
root,
|
|
&[
|
|
(root.join("modified"), "original\n".into()),
|
|
(root.join("unchanged"), "dirty\n".into()),
|
|
(root.join("removed"), "deleted\n".into()),
|
|
(root.join(renamed), "dirty renamed\n".into()),
|
|
(root.join("mode"), "mode\n".into()),
|
|
(root.join("ignored-buffer"), "ignored\n".into()),
|
|
],
|
|
);
|
|
assert!(!overlays.contains_key(Path::new("modified")));
|
|
assert!(!overlays.contains_key(Path::new("ignored-buffer")));
|
|
assert_eq!(
|
|
overlays[Path::new("unchanged")].status,
|
|
FileStatus::Modified
|
|
);
|
|
assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed);
|
|
assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed);
|
|
assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified);
|
|
let aliases = Temporary::new().unwrap();
|
|
let alias = aliases.0.join("checkout");
|
|
std::os::unix::fs::symlink(root, &alias).unwrap();
|
|
let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]);
|
|
assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified);
|
|
fixture_command(root, "git", &["config", "core.filemode", "0"]);
|
|
assert!(
|
|
!changes_by_path(root, &[(root.join("mode"), "mode\n".into())])
|
|
.contains_key(Path::new("mode"))
|
|
);
|
|
fixture_command(root, "git", &["config", "core.autocrlf", "1"]);
|
|
fs::write(root.join("unchanged"), "same\r\n").unwrap();
|
|
assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged")));
|
|
fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]);
|
|
fixture_command(root, "git", &["config", "core.filemode", "true"]);
|
|
fs::write(root.join("unchanged"), "same\n").unwrap();
|
|
assert_eq!(fs::read(root.join(".git/index")).unwrap(), index);
|
|
assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head);
|
|
assert_eq!(
|
|
fs::read_to_string(root.join("modified")).unwrap(),
|
|
"unstaged\n"
|
|
);
|
|
assert!(!root.join("removed").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() {
|
|
let temp = picker_git_fixture();
|
|
let root = &temp.0;
|
|
fs::create_dir(root.join("sub")).unwrap();
|
|
fs::write(root.join("sub/outgoing"), "out\n").unwrap();
|
|
fs::write(root.join("incoming"), "in\n").unwrap();
|
|
fixture_command(root, "git", &["add", "--all"]);
|
|
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
|
fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap();
|
|
fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap();
|
|
let changes = changes_by_path(&root.join("sub"), &[]);
|
|
assert_eq!(changes.len(), 2);
|
|
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed);
|
|
assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added);
|
|
assert_eq!(changes[Path::new("incoming")].old_path, None);
|
|
std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap();
|
|
assert!(changes_by_path(&root.join("linked"), &[]).is_empty());
|
|
assert!(changes_by_path(&root.join(".git"), &[]).is_empty());
|
|
let unrelated = Temporary::new().unwrap();
|
|
assert!(changes_by_path(&unrelated.0, &[]).is_empty());
|
|
let worktree = temp.0.join("worktree");
|
|
fixture_command(
|
|
root,
|
|
"git",
|
|
&["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"],
|
|
);
|
|
fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap();
|
|
let changes = changes_by_path(&worktree.join("sub"), &[]);
|
|
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified);
|
|
}
|
|
|
|
#[test]
|
|
fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() {
|
|
let temp = picker_git_fixture();
|
|
let root = &temp.0;
|
|
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
|
|
fs::write(root.join("tracked"), "tracked\n").unwrap();
|
|
fs::write(root.join("untracked"), "untracked\n").unwrap();
|
|
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
|
fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]);
|
|
let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]);
|
|
for name in [".gitignore", "tracked", "untracked", "named"] {
|
|
assert_eq!(changes[Path::new(name)].status, FileStatus::Added);
|
|
}
|
|
assert!(!changes.contains_key(Path::new("ignored")));
|
|
assert!(!root.join("named").exists());
|
|
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
|
fs::write(
|
|
root.join(".gitattributes"),
|
|
"tracked filter=evil diff=evil\n",
|
|
)
|
|
.unwrap();
|
|
let evil = "sh -c 'touch executed; cat'";
|
|
for key in [
|
|
"filter.evil.clean",
|
|
"filter.evil.process",
|
|
"diff.evil.command",
|
|
"diff.evil.textconv",
|
|
] {
|
|
fixture_command(root, "git", &["config", key, evil]);
|
|
}
|
|
fixture_command(root, "git", &["config", "filter.evil.required", "true"]);
|
|
fs::write(root.join("tracked"), "changed\n").unwrap();
|
|
assert_eq!(
|
|
changes_by_path(root, &[])[Path::new("tracked")].status,
|
|
FileStatus::Modified
|
|
);
|
|
assert_eq!(
|
|
changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())])
|
|
.get(Path::new("tracked")),
|
|
None
|
|
);
|
|
assert!(!root.join("executed").exists());
|
|
assert!(
|
|
working_file_changes(root, &[], &|| true)
|
|
.unwrap_err()
|
|
.contains("cancelled")
|
|
);
|
|
assert!(
|
|
working_file_changes(
|
|
root,
|
|
&[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())],
|
|
&|| false
|
|
)
|
|
.unwrap_err()
|
|
.contains("16 MiB")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() {
|
|
for colocate in ["--colocate", "--no-colocate"] {
|
|
let temp = Temporary::new().unwrap();
|
|
let root = &temp.0;
|
|
fixture_command(root, "jj", &["git", "init", colocate, "."]);
|
|
fs::write(root.join("file"), "baseline\n").unwrap();
|
|
assert_eq!(
|
|
changes_by_path(root, &[])[Path::new("file")].status,
|
|
FileStatus::Added
|
|
);
|
|
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
|
|
fixture_command(root, "jj", &["describe", "-m", "Baseline"]);
|
|
fixture_command(root, "jj", &["new"]);
|
|
let operation = fixture_command(
|
|
root,
|
|
"jj",
|
|
&[
|
|
"--ignore-working-copy",
|
|
"op",
|
|
"log",
|
|
"--no-graph",
|
|
"-n",
|
|
"1",
|
|
"-T",
|
|
"id",
|
|
],
|
|
);
|
|
let parent = fixture_command(
|
|
root,
|
|
"jj",
|
|
&[
|
|
"--ignore-working-copy",
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
"@-",
|
|
"-T",
|
|
"commit_id",
|
|
],
|
|
);
|
|
let store = store_for_jj(root).unwrap();
|
|
let head = fs::read(store.join("HEAD")).unwrap();
|
|
let index = fs::read(store.join("index")).ok();
|
|
fs::write(root.join("file"), "disk\n").unwrap();
|
|
fs::write(root.join("new"), "new\n").unwrap();
|
|
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
|
let changes = changes_by_path(root, &[]);
|
|
assert_eq!(changes[Path::new("file")].status, FileStatus::Modified);
|
|
assert_eq!(changes[Path::new("new")].status, FileStatus::Added);
|
|
assert!(!changes.contains_key(Path::new("ignored")));
|
|
assert!(
|
|
!changes_by_path(root, &[(root.join("file"), "baseline\n".into())])
|
|
.contains_key(Path::new("file"))
|
|
);
|
|
assert_eq!(fs::read(store.join("HEAD")).unwrap(), head);
|
|
assert_eq!(fs::read(store.join("index")).ok(), index);
|
|
assert_eq!(
|
|
fixture_command(
|
|
root,
|
|
"jj",
|
|
&[
|
|
"--ignore-working-copy",
|
|
"op",
|
|
"log",
|
|
"--no-graph",
|
|
"-n",
|
|
"1",
|
|
"-T",
|
|
"id"
|
|
]
|
|
),
|
|
operation
|
|
);
|
|
fixture_command(root, "jj", &["new", "root()"]);
|
|
fs::write(root.join("sibling"), "sibling\n").unwrap();
|
|
fixture_command(root, "jj", &["describe", "-m", "Sibling"]);
|
|
fixture_command(root, "jj", &["new", &parent, "@"]);
|
|
assert!(changes_by_path(root, &[]).is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
|
|
for colocate in ["--colocate", "--no-colocate"] {
|
|
let temp = Temporary::new().unwrap();
|
|
let root = &temp.0;
|
|
fixture_command(root, "jj", &["git", "init", colocate, "."]);
|
|
fs::write(root.join("conflicted"), "base\n").unwrap();
|
|
fixture_command(root, "jj", &["describe", "-m", "Base"]);
|
|
let base = fixture_command(
|
|
root,
|
|
"jj",
|
|
&[
|
|
"--ignore-working-copy",
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
"@",
|
|
"-T",
|
|
"commit_id",
|
|
],
|
|
);
|
|
fixture_command(root, "jj", &["new"]);
|
|
fs::write(root.join("conflicted"), "left\n").unwrap();
|
|
fixture_command(root, "jj", &["describe", "-m", "Left"]);
|
|
let left = fixture_command(
|
|
root,
|
|
"jj",
|
|
&[
|
|
"--ignore-working-copy",
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
"@",
|
|
"-T",
|
|
"commit_id",
|
|
],
|
|
);
|
|
fixture_command(root, "jj", &["new", &base]);
|
|
fs::write(root.join("conflicted"), "right\n").unwrap();
|
|
fixture_command(root, "jj", &["describe", "-m", "Right"]);
|
|
let right = fixture_command(
|
|
root,
|
|
"jj",
|
|
&[
|
|
"--ignore-working-copy",
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
"@",
|
|
"-T",
|
|
"commit_id",
|
|
],
|
|
);
|
|
fixture_command(root, "jj", &["new", &left, &right]);
|
|
fixture_command(root, "jj", &["new"]);
|
|
let projected = fs::read_to_string(root.join("conflicted")).unwrap();
|
|
assert!(projected.contains("<<<<<<<"));
|
|
let operation = fixture_command(
|
|
root,
|
|
"jj",
|
|
&[
|
|
"--ignore-working-copy",
|
|
"op",
|
|
"log",
|
|
"--no-graph",
|
|
"-n",
|
|
"1",
|
|
"-T",
|
|
"id",
|
|
],
|
|
);
|
|
let mut reader = WorkingFileReader::default();
|
|
assert!(reader.read(root, &[], &|| false).unwrap().is_empty());
|
|
fs::write(root.join("conflicted"), "resolved in working file\n").unwrap();
|
|
let changes = reader.read(root, &[], &|| false).unwrap();
|
|
assert_eq!(changes.len(), 1);
|
|
assert_eq!(changes[0].path, Path::new("conflicted"));
|
|
assert_eq!(changes[0].status, FileStatus::Modified);
|
|
assert!(
|
|
reader
|
|
.read(
|
|
root,
|
|
&[(root.join("conflicted"), projected.into())],
|
|
&|| false
|
|
)
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(root.join("conflicted")).unwrap(),
|
|
"resolved in working file\n"
|
|
);
|
|
assert_eq!(
|
|
fixture_command(
|
|
root,
|
|
"jj",
|
|
&[
|
|
"--ignore-working-copy",
|
|
"op",
|
|
"log",
|
|
"--no-graph",
|
|
"-n",
|
|
"1",
|
|
"-T",
|
|
"id"
|
|
]
|
|
),
|
|
operation
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
|
|
let temp = Temporary::new().unwrap();
|
|
let ready = temp.0.join("ready");
|
|
let cancelled = Arc::new(AtomicBool::new(false));
|
|
let worker_cancelled = Arc::clone(&cancelled);
|
|
let gate = ready.clone();
|
|
let (sent, received) = mpsc::channel();
|
|
let worker = thread::spawn(move || {
|
|
let mut command = Command::new("sh");
|
|
command
|
|
.args([
|
|
"-c",
|
|
"sleep 600 & printf ready > \"$1\"; wait",
|
|
"vamp-diff-test",
|
|
])
|
|
.arg(gate);
|
|
let result = run(command, vec![], &worker_cancelled).map(|_| ());
|
|
sent.send(result).unwrap();
|
|
});
|
|
// File handshake, not a wall-clock scheduling assumption.
|
|
while !ready.exists() {
|
|
if let Ok(result) = received.try_recv() {
|
|
panic!("Process exited before the child was ready: {result:?}");
|
|
}
|
|
thread::yield_now();
|
|
}
|
|
cancelled.store(true, Ordering::Release);
|
|
assert!(received.recv().unwrap().unwrap_err().contains("cancelled"));
|
|
worker.join().unwrap();
|
|
|
|
let mut command = Command::new("sh");
|
|
command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]);
|
|
let result = run(command, vec![], &AtomicBool::new(false)).unwrap();
|
|
assert_eq!(result.code, Some(0));
|
|
assert_eq!(result.stdout.len(), 50_000);
|
|
assert_eq!(result.stderr.len(), 60_000);
|
|
}
|
|
}
|