Files
vamp/tests/repository.rs
T
Valdemar Lorenzen dcfa50319c fix: preserve lazy snapshots for conflicted jj revisions
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.
2026-10-14 15:49:26 +02:00

2105 lines
70 KiB
Rust

#![cfg(unix)]
use std::{
collections::BTreeMap,
ffi::OsString,
fs,
os::unix::{
ffi::OsStringExt,
fs::{PermissionsExt, symlink},
},
path::{Path, PathBuf},
process::{Command, Output},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
};
use vamp::{
diff::{Category, Comparison, ComparisonMode, FileStatus, RepoKind, Revision, RevisionTarget},
repository::Repository,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-repository-test-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn dir(&self, name: &str) -> PathBuf {
let path = self.0.join(name);
fs::create_dir(&path).unwrap();
path
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(program: &str, root: &Path, args: &[&str]) -> Output {
Command::new(program)
.current_dir(root)
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.env("JJ_USER", "Diff Test")
.env("JJ_EMAIL", "diff@example.invalid")
.output()
.unwrap()
}
fn success(program: &str, root: &Path, args: &[&str]) -> String {
let result = command(program, root, args);
assert!(
result.status.success(),
"{program} {args:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
String::from_utf8(result.stdout)
.unwrap()
.trim_end_matches('\n')
.into()
}
fn git(root: &Path, args: &[&str]) -> String {
success("git", root, args)
}
fn jj(root: &Path, args: &[&str]) -> String {
success("jj", root, args)
}
fn init_git(root: &Path) {
git(root, &["init", "--initial-branch=local"]);
git(root, &["config", "user.name", "Diff Test"]);
git(root, &["config", "user.email", "diff@example.invalid"]);
}
fn init_jj(root: &Path, colocate: bool) {
jj(
root,
&[
"git",
"init",
if colocate {
"--colocate"
} else {
"--no-colocate"
},
],
);
jj(root, &["config", "set", "--repo", "user.name", "Diff Test"]);
jj(
root,
&[
"config",
"set",
"--repo",
"user.email",
"diff@example.invalid",
],
);
}
fn open(root: &Path) -> Repository {
Repository::open(root, Arc::new(AtomicBool::new(false))).unwrap()
}
fn revision(id: &str, kind: RepoKind) -> Revision {
Revision {
target: RevisionTarget::Commit(id.into()),
label: id.into(),
description: String::new(),
category: if kind == RepoKind::Jujutsu {
Category::Revisions
} else {
Category::Commits
},
}
}
fn working(kind: RepoKind) -> Revision {
let mut r = revision("working", kind);
r.target = RevisionTarget::Working;
r
}
fn compare_loaded(
repository: &mut Repository,
before: &Revision,
after: &Revision,
buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> {
let mut comparison =
repository.compare(1, before, after, ComparisonMode::Snapshots, buffers)?;
for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(1, index)?;
if file
.notice
.as_deref()
.is_some_and(|notice| notice.contains("Binary or non-UTF-8"))
{
comparison.editable_paths.remove(&file.path);
}
}
Ok(comparison)
}
fn changed<'a>(comparison: &'a Comparison, path: &Path) -> &'a vamp::diff::ChangedFile {
comparison
.files
.iter()
.find(|f| f.path == path)
.unwrap_or_else(|| panic!("Missing changed file {path:?}: {:?}", comparison.files))
}
// Capture active metadata, checkout bytes, executable bits and link targets. Access
// times are intentionally excluded: reading a repository must not change its data.
fn disk_state(root: &Path) -> BTreeMap<PathBuf, (u32, Vec<u8>)> {
fn visit(root: &Path, path: &Path, result: &mut BTreeMap<PathBuf, (u32, Vec<u8>)>) {
for entry in fs::read_dir(path).unwrap() {
let entry = entry.unwrap();
let full = entry.path();
let metadata = fs::symlink_metadata(&full).unwrap();
if metadata.is_dir() {
visit(root, &full, result);
} else {
let data = if metadata.file_type().is_symlink() {
use std::os::unix::ffi::OsStrExt;
fs::read_link(&full)
.unwrap()
.as_os_str()
.as_bytes()
.to_vec()
} else {
fs::read(&full).unwrap()
};
result.insert(
full.strip_prefix(root).unwrap().to_path_buf(),
(metadata.permissions().mode(), data),
);
}
}
}
let mut result = BTreeMap::new();
visit(root, root, &mut result);
result
}
#[test]
fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() {
let fixture = Fixture::new();
let root = fixture.dir("git");
init_git(&root);
for (path, data) in [
("text", &b"one\nthree\n"[..]),
("removed", b"removed\n"),
("empty-before", b""),
("newline", b"same\n"),
("crlf", b"a\r\n"),
("rename-old", b"move\n"),
("binary", b"\0old"),
("ignored-tracked", b"old\n"),
("executable", b"script\n"),
("type-switch", b"target"),
(".gitignore", b"ignored-*\n"),
] {
fs::write(root.join(path), data).unwrap();
}
git(&root, &["add", "."]);
git(&root, &["add", "--force", "ignored-tracked"]);
git(&root, &["commit", "-m", "baseline"]);
git(&root, &["tag", "-a", "annotated", "-m", "tag"]);
git(
&root,
&["tag", "-a", "nested", "-m", "nested tag", "annotated"],
);
let id = git(&root, &["rev-parse", "HEAD"]);
fs::write(root.join("text"), b"one\nstaged\nthree\n").unwrap();
git(&root, &["add", "text"]);
fs::write(root.join("text"), b"one\ntwo\nthree\n").unwrap();
fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("empty-before"), b"first\n").unwrap();
fs::write(root.join("empty-added"), b"").unwrap();
fs::write(root.join("added"), b"added\n").unwrap();
fs::write(root.join("newline"), b"same").unwrap();
fs::write(root.join("crlf"), b"b\r\n").unwrap();
fs::write(root.join("binary"), b"\0new").unwrap();
fs::write(root.join("ignored-tracked"), b"changed\n").unwrap();
fs::write(root.join("ignored-untracked"), b"must not appear\n").unwrap();
fs::rename(root.join("rename-old"), root.join("rename-new")).unwrap();
fs::remove_file(root.join("type-switch")).unwrap();
symlink("target", root.join("type-switch")).unwrap();
fs::set_permissions(root.join("executable"), fs::Permissions::from_mode(0o755)).unwrap();
let odd = PathBuf::from("-tab\tand\nnewline");
fs::write(root.join(&odd), b"odd\n").unwrap();
let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec()));
let non_utf8_created = match fs::write(root.join(&non_utf8), b"bytes\n") {
Ok(()) => true,
// APFS rejects non-UTF-8 names before repository code can read them.
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => false,
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
};
symlink("/not/a/path/to/read", root.join("link")).unwrap();
let state = disk_state(&root);
let mut repository = open(&root);
let catalog = repository.catalog().unwrap();
assert_eq!(catalog.kind, RepoKind::Git);
assert_eq!(catalog.entries[0].target, RevisionTarget::Working);
assert!(
catalog
.entries
.iter()
.any(|r| r.label == id && r.description == "baseline")
);
assert!(catalog.entries.iter().any(|r| r.label == "nested"
&& r.category == Category::Tags
&& r.target == RevisionTarget::Commit(id.clone())));
let comparison = compare_loaded(
&mut repository,
&revision(&id, RepoKind::Git),
&working(RepoKind::Git),
&[],
)
.unwrap();
let text = changed(&comparison, Path::new("text"));
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
assert!(comparison.editable_paths.contains(Path::new("text")));
assert!(comparison.editable_paths.contains(Path::new("empty-added")));
for path in ["binary", "link", "removed", "type-switch"] {
assert!(!comparison.editable_paths.contains(Path::new(path)));
}
assert_eq!(text.after, ["one", "two", "three"]);
assert_eq!(
(
text.hunks[0].before_start,
text.hunks[0].before_len,
text.hunks[0].after_start,
text.hunks[0].after_len
),
(1, 0, 1, 1)
);
let empty = changed(&comparison, Path::new("empty-before"));
assert_eq!(
(
empty.hunks[0].before_start,
empty.hunks[0].before_len,
empty.hunks[0].after_start,
empty.hunks[0].after_len
),
(0, 0, 0, 1)
);
let deleted = changed(&comparison, Path::new("removed"));
assert_eq!(deleted.status, FileStatus::Removed);
assert_eq!(
(
deleted.hunks[0].before_start,
deleted.hunks[0].before_len,
deleted.hunks[0].after_start,
deleted.hunks[0].after_len
),
(0, 1, 0, 0)
);
assert_eq!(
changed(&comparison, Path::new("empty-added")).status,
FileStatus::Added
);
assert!(
changed(&comparison, Path::new("empty-added"))
.after
.is_empty()
);
assert_eq!(changed(&comparison, &odd).after, ["odd"]);
if non_utf8_created {
assert_eq!(changed(&comparison, &non_utf8).after, ["bytes"]);
}
assert_eq!(changed(&comparison, Path::new("crlf")).after, ["b\r"]);
assert!(
changed(&comparison, Path::new("newline"))
.notice
.as_ref()
.unwrap()
.contains("EOF newline")
);
let type_switch = changed(&comparison, Path::new("type-switch"));
assert_eq!(type_switch.before, type_switch.after);
assert!(
type_switch
.notice
.as_ref()
.unwrap()
.contains("100644 → 120000")
);
let binary = changed(&comparison, Path::new("binary"));
assert!(binary.before.is_empty() && binary.after.is_empty() && binary.hunks.is_empty());
assert!(binary.notice.as_ref().unwrap().contains("Binary"));
assert_eq!(
changed(&comparison, Path::new("link")).after,
["/not/a/path/to/read"]
);
assert!(
changed(&comparison, Path::new("link"))
.notice
.as_ref()
.unwrap()
.contains("Symbolic link")
);
assert!(
changed(&comparison, Path::new("executable"))
.notice
.as_ref()
.unwrap()
.contains("mode changed")
);
assert_eq!(
changed(&comparison, Path::new("ignored-tracked")).after,
["changed"]
);
assert!(
!comparison
.files
.iter()
.any(|f| f.path == Path::new("ignored-untracked"))
);
let moved = changed(&comparison, Path::new("rename-new"));
assert_eq!(moved.status, FileStatus::Renamed);
assert_eq!(moved.old_path.as_deref(), Some(Path::new("rename-old")));
assert_eq!(moved.before, moved.after);
assert!(moved.hunks.is_empty());
let reverse = compare_loaded(
&mut repository,
&working(RepoKind::Git),
&revision(&id, RepoKind::Git),
&[],
)
.unwrap();
assert!(reverse.editable_paths.contains(Path::new("text")));
assert!(!reverse.editable_paths.contains(Path::new("added")));
assert!(!reverse.editable_paths.contains(Path::new("link")));
assert_eq!(changed(&reverse, Path::new("text")).after, ["one", "three"]);
assert_eq!(
changed(&reverse, Path::new("added")).status,
FileStatus::Removed
);
assert!(
repository
.compare(
1,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap()
.files
.is_empty()
);
assert_eq!(disk_state(&root), state);
}
#[test]
fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_stays_private() {
let fixture = Fixture::new();
let source = fixture.dir("source");
init_git(&source);
fs::write(source.join("file"), "ancestor\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "remote ancestor needle"]);
let ancestor = git(&source, &["rev-parse", "HEAD"]);
git(&source, &["tag", "-a", "ancestor-tag", "-m", "annotated"]);
fs::write(source.join("file"), "remote tip\n").unwrap();
git(&source, &["commit", "-am", "remote tip"]);
let tip = git(&source, &["rev-parse", "HEAD"]);
git(&source, &["branch", "never-fetched"]);
let bare = fixture.0.join("remote.git");
git(
&fixture.0,
&[
"clone",
"--bare",
source.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
let active = fixture.dir("active");
init_git(&active);
fs::write(active.join("file"), "local\n").unwrap();
git(&active, &["add", "."]);
git(&active, &["commit", "-m", "local"]);
let local = git(&active, &["rev-parse", "HEAD"]);
git(
&active,
&["remote", "add", "origin", bare.to_str().unwrap()],
);
let state = disk_state(&active);
let mut repository = open(&active);
let catalog = repository.list_remote("origin").unwrap();
let remote = catalog
.entries
.iter()
.find(|r| {
r.target
== (RevisionTarget::Remote {
remote: "origin".into(),
reference: "refs/heads/never-fetched".into(),
})
})
.unwrap()
.clone();
let tag = catalog
.entries
.iter()
.find(|r| {
r.target
== (RevisionTarget::Remote {
remote: "origin".into(),
reference: "refs/tags/ancestor-tag".into(),
})
})
.unwrap()
.clone();
assert!(
catalog
.entries
.iter()
.any(|r| r.target == RevisionTarget::FetchRemote("origin".into()))
);
assert!(
!catalog
.entries
.iter()
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
);
assert!(
!command("git", &active, &["cat-file", "-e", &tip])
.status
.success()
);
let comparison = compare_loaded(
&mut repository,
&revision(&local, RepoKind::Git),
&remote,
&[(active.join("file"), "unsaved local text\n".into())],
)
.unwrap();
assert!(comparison.editable_paths.is_empty());
assert_eq!(changed(&comparison, Path::new("file")).before, ["local"]);
assert_eq!(
changed(&comparison, Path::new("file")).after,
["remote tip"]
);
let comparison = compare_loaded(&mut repository, &tag, &remote, &[]).unwrap();
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
let comparison =
compare_loaded(&mut repository, &remote, &working(RepoKind::Git), &[]).unwrap();
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
let loaded = repository.fetch_remote("origin").unwrap();
let historic = loaded
.entries
.iter()
.find(|r| {
r.target == RevisionTarget::Commit(ancestor.clone()) && r.category == Category::Commits
})
.unwrap();
assert_eq!(historic.label, ancestor);
assert_eq!(historic.description, "remote ancestor needle");
let comparison = compare_loaded(
&mut repository,
historic,
&revision(&local, RepoKind::Git),
&[],
)
.unwrap();
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
assert!(
!command("git", &active, &["cat-file", "-e", &tip])
.status
.success()
);
assert_eq!(disk_state(&active), state);
// An advertised remote alias already available at HEAD is eligible without
// importing refs or substituting the local unsaved overlay for pinned bytes.
git(
&source,
&["remote", "add", "origin", bare.to_str().unwrap()],
);
let source_state = disk_state(&source);
let mut checked_out = open(&source);
let advertised = checked_out.list_remote("origin").unwrap();
let alias = advertised
.entries
.iter()
.find(|entry| entry.target == remote.target)
.unwrap();
let pinned = checked_out
.compare(
20,
&revision(&ancestor, RepoKind::Git),
alias,
ComparisonMode::Snapshots,
&[(source.join("file"), "dirty live\n".into())],
)
.unwrap();
assert!(pinned.editable_paths.contains(Path::new("file")));
assert_eq!(checked_out.load_file(20, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&source), source_state);
git(&source, &["checkout", "--detach", &ancestor]);
let readonly = checked_out
.refresh_checkout(20, 21, false, &[])
.unwrap()
.unwrap();
assert!(readonly.editable_paths.is_empty());
assert_eq!(checked_out.load_file(21, 0).unwrap().after, ["remote tip"]);
git(&source, &["checkout", "--detach", &tip]);
let promoted = checked_out
.refresh_checkout(21, 22, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
assert_eq!(checked_out.load_file(22, 0).unwrap().after, ["remote tip"]);
}
#[test]
fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_operations() {
for colocate in [false, true] {
let fixture = Fixture::new();
let active = fixture.dir("active");
init_jj(&active, colocate);
fs::write(active.join("file"), "baseline\n").unwrap();
fs::write(active.join("deleted"), "delete\n").unwrap();
fs::write(active.join(".gitignore"), "ignored-*\n").unwrap();
jj(&active, &["describe", "-m", "jj baseline searchable"]);
let id = jj(
&active,
&[
"--ignore-working-copy",
"--at-operation=@",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
jj(&active, &["bookmark", "create", "baseline"]);
jj(&active, &["new"]);
fs::write(active.join("file"), "disk\n").unwrap();
fs::remove_file(active.join("deleted")).unwrap();
fs::write(active.join("addition"), "new\n").unwrap();
fs::write(active.join("ignored-untracked"), "ignored\n").unwrap();
let state = disk_state(&active);
let mut repository = open(&active);
let catalog = repository.catalog().unwrap();
assert_eq!(catalog.kind, RepoKind::Jujutsu);
let baseline = catalog
.entries
.iter()
.find(|r| {
r.target == RevisionTarget::Commit(id.clone()) && r.category == Category::Revisions
})
.unwrap();
assert!(
catalog
.entries
.iter()
.any(|r| r.label == "baseline" && r.category == Category::Bookmarks)
);
let comparison =
compare_loaded(&mut repository, baseline, &working(RepoKind::Jujutsu), &[]).unwrap();
assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]);
assert_eq!(
changed(&comparison, Path::new("deleted")).status,
FileStatus::Removed
);
assert_eq!(
changed(&comparison, Path::new("addition")).status,
FileStatus::Added
);
assert!(
!comparison
.files
.iter()
.any(|f| f.path == Path::new("ignored-untracked"))
);
let synthetic_root = revision(&"0".repeat(40), RepoKind::Jujutsu);
let branch = repository
.compare(
20,
&synthetic_root,
baseline,
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
assert!(branch.before.ends_with(&"0".repeat(40)));
assert!(
branch
.files
.iter()
.any(|file| file.path == Path::new("file"))
);
let reversed = repository
.compare(
21,
baseline,
&synthetic_root,
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
assert!(reversed.files.is_empty());
assert!(
repository
.compare(22, baseline, baseline, ComparisonMode::BranchChanges, &[])
.unwrap()
.files
.is_empty()
);
assert_eq!(disk_state(&active), state);
}
}
#[test]
fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_change_history() {
let fixture = Fixture::new();
let source = fixture.dir("source");
init_jj(&source, false);
fs::write(source.join("file"), "remote ancestor\n").unwrap();
jj(&source, &["describe", "-m", "remote historic needle"]);
let ancestor = jj(
&source,
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
jj(&source, &["new"]);
fs::write(source.join("file"), "remote tip\n").unwrap();
jj(&source, &["describe", "-m", "remote tip"]);
let tip = jj(
&source,
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
jj(&source, &["bookmark", "create", "never-fetched"]);
let bare = fixture.dir("remote.git");
jj(&source, &["git", "export"]);
// Build remote fixture data by copying exported refs, never by pushing.
git(
&fixture.0,
&[
"clone",
"--bare",
"--no-hardlinks",
source.join(".jj/repo/store/git").to_str().unwrap(),
bare.to_str().unwrap(),
],
);
let active = fixture.dir("active");
init_jj(&active, false);
fs::write(active.join("file"), "local\n").unwrap();
jj(&active, &["describe", "-m", "local"]);
jj(&active, &["new"]);
jj(
&active,
&["git", "remote", "add", "origin", bare.to_str().unwrap()],
);
fs::write(active.join("unsnapshotted"), "keep disk\n").unwrap();
let state = disk_state(&active);
let mut repository = open(&active);
let catalog = repository.list_remote("origin").unwrap();
let remote = catalog
.entries
.iter()
.find(|r| {
r.target
== (RevisionTarget::Remote {
remote: "origin".into(),
reference: "refs/heads/never-fetched".into(),
})
})
.unwrap()
.clone();
assert!(
!catalog
.entries
.iter()
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
);
let local = catalog.entries.iter().find(|revision| {
matches!(&revision.target, RevisionTarget::Commit(id) if id.bytes().any(|byte| byte != b'0'))
}).unwrap();
let branch = repository
.compare(60, local, &remote, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(branch.before.ends_with(&"0".repeat(40)));
assert_eq!(branch.files.len(), 1);
assert_eq!(repository.load_file(60, 0).unwrap().after, ["remote tip"]);
assert!(branch.editable_paths.is_empty());
let comparison =
compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap();
assert_eq!(
changed(&comparison, Path::new("file")).before,
["remote tip"]
);
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
let loaded = repository.fetch_remote("origin").unwrap();
let historic = loaded
.entries
.iter()
.find(|r| {
r.target == RevisionTarget::Commit(ancestor.clone())
&& r.category == Category::Revisions
})
.unwrap();
let comparison = compare_loaded(&mut repository, historic, &remote, &[]).unwrap();
assert_eq!(
changed(&comparison, Path::new("file")).before,
["remote ancestor"]
);
assert_eq!(
changed(&comparison, Path::new("file")).after,
["remote tip"]
);
let branch = repository
.compare(61, historic, &remote, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(branch.before.ends_with(&ancestor));
assert_eq!(
repository.load_file(61, 0).unwrap().before,
["remote ancestor"]
);
let store = active.join(".jj/repo/store");
let active_store =
fs::canonicalize(store.join(fs::read_to_string(store.join("git_target")).unwrap().trim()))
.unwrap();
assert!(
!command(
"git",
&active,
&[
"--git-dir",
active_store.to_str().unwrap(),
"cat-file",
"-e",
&tip
]
)
.status
.success()
);
assert_eq!(disk_state(&active), state);
assert!(
repository
.refresh_checkout(61, 62, false, &[])
.unwrap()
.is_none()
);
// Simulate importing and checking out the privately selected endpoint behind
// the viewer. Its initially unavailable active-jj identity must be retried.
jj(&active, &["git", "fetch", "--remote", "origin"]);
jj(&active, &["edit", "--ignore-immutable", &tip]);
let checked_out_state = disk_state(&active);
let promoted = repository
.refresh_checkout(
61,
62,
false,
&[(active.join("file"), "dirty live\n".into())],
)
.unwrap()
.unwrap();
assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after);
assert!(promoted.editable_paths.contains(Path::new("file")));
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&active), checked_out_state);
}
#[test]
fn errors_are_explicit_and_do_not_modify_active_repository() {
let fixture = Fixture::new();
assert!(Repository::open(&fixture.0, Arc::new(AtomicBool::new(false))).is_err());
let root = fixture.dir("active");
init_git(&root);
fs::write(root.join("file"), "baseline\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "baseline"]);
let id = git(&root, &["rev-parse", "HEAD"]);
git(
&root,
&[
"remote",
"add",
"broken",
fixture.0.join("missing.git").to_str().unwrap(),
],
);
let state = disk_state(&root);
let mut repository = open(&root);
let catalog = repository.catalog().unwrap();
assert!(catalog.notice.is_none());
assert!(
catalog
.entries
.iter()
.any(|revision| revision.target == RevisionTarget::ListRemote("broken".into()))
);
assert!(repository.list_remote("broken").is_err());
assert!(repository.fetch_remote("broken").is_err());
assert!(
repository
.fetch_remote("unknown")
.unwrap_err()
.contains("Unknown configured remote")
);
assert!(
repository
.compare(
1,
&revision("--bad", RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap_err()
.contains("Invalid immutable commit")
);
assert!(
repository
.compare(
1,
&revision(&"f".repeat(40), RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap_err()
.contains("unavailable")
);
let loader = Revision {
target: RevisionTarget::FetchRemote("broken".into()),
..working(RepoKind::Git)
};
assert!(
repository
.compare(
1,
&loader,
&revision(&id, RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap_err()
.contains("history loader")
);
assert!(
Repository::open(&root, Arc::new(AtomicBool::new(true)))
.err()
.unwrap()
.contains("cancelled")
);
assert_eq!(disk_state(&root), state);
}
#[test]
fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit() {
let fixture = Fixture::new();
let child = fixture.dir("child");
init_git(&child);
fs::write(child.join("file"), "first\n").unwrap();
git(&child, &["add", "."]);
git(&child, &["commit", "-m", "first"]);
let first = git(&child, &["rev-parse", "HEAD"]);
let root = fixture.dir("active");
init_git(&root);
git(
&root,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
child.to_str().unwrap(),
"nested",
],
);
git(&root, &["commit", "-m", "submodule"]);
let baseline = git(&root, &["rev-parse", "HEAD"]);
let mut repository = open(&root);
assert!(
repository
.compare(
1,
&revision(&baseline, RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap()
.files
.is_empty()
);
fs::write(child.join("file"), "second\n").unwrap();
git(&child, &["commit", "-am", "second"]);
let second = git(&child, &["rev-parse", "HEAD"]);
git(&root.join("nested"), &["fetch", "origin"]);
git(&root.join("nested"), &["checkout", "--detach", &second]);
let state = disk_state(&root);
let comparison = compare_loaded(
&mut repository,
&revision(&baseline, RepoKind::Git),
&working(RepoKind::Git),
&[],
)
.unwrap();
let submodule = changed(&comparison, Path::new("nested"));
assert_eq!(submodule.status, FileStatus::Modified);
let notice = submodule.notice.as_ref().unwrap();
assert!(
notice.contains(&first) && notice.contains(&second) && notice.contains("not traversed")
);
assert!(submodule.before.is_empty() && submodule.after.is_empty());
assert!(
!comparison
.files
.iter()
.any(|f| f.path.starts_with("nested/file"))
);
assert_eq!(disk_state(&root), state);
}
#[test]
fn multiple_remotes_retain_tag_only_history_even_when_tag_names_collide() {
let fixture = Fixture::new();
let mut ids = vec![];
let mut urls = vec![];
for name in ["one", "two"] {
let source = fixture.dir(name);
init_git(&source);
fs::write(source.join("file"), format!("{name}\n")).unwrap();
git(&source, &["add", "."]);
git(
&source,
&["commit", "-m", &format!("{name} tag-only needle")],
);
ids.push(git(&source, &["rev-parse", "HEAD"]));
git(&source, &["tag", "collision"]);
git(&source, &["update-ref", "-d", "refs/heads/local"]);
let bare = fixture.0.join(format!("{name}.git"));
git(
&fixture.0,
&[
"clone",
"--bare",
source.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
urls.push(bare);
}
let active = fixture.dir("active");
init_git(&active);
fs::write(active.join("local"), "local\n").unwrap();
git(&active, &["add", "."]);
git(&active, &["commit", "-m", "local"]);
for (name, url) in ["one", "two"].into_iter().zip(&urls) {
git(&active, &["remote", "add", name, url.to_str().unwrap()]);
}
let state = disk_state(&active);
let mut repository = open(&active);
repository.fetch_remote("one").unwrap();
let loaded = repository.fetch_remote("two").unwrap();
for (name, id) in ["one", "two"].into_iter().zip(&ids) {
assert!(
loaded
.entries
.iter()
.any(|r| r.target == RevisionTarget::Commit(id.clone())
&& r.description == format!("{name} tag-only needle"))
);
}
let comparison = compare_loaded(
&mut repository,
&revision(&ids[0], RepoKind::Git),
&revision(&ids[1], RepoKind::Git),
&[],
)
.unwrap();
assert_eq!(changed(&comparison, Path::new("file")).before, ["one"]);
assert_eq!(changed(&comparison, Path::new("file")).after, ["two"]);
assert_eq!(disk_state(&active), state);
}
#[test]
fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable() {
let fixture = Fixture::new();
let root = fixture.dir("active");
init_jj(&root, false);
fs::write(root.join("conflict"), "base\n").unwrap();
fs::write(root.join("ordinary"), "old\n").unwrap();
symlink("ordinary", root.join("unchanged-link")).unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(
&root,
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
jj(&root, &["new", &base]);
fs::write(root.join("conflict"), "left\n").unwrap();
fs::write(root.join("ordinary"), "left ordinary\n").unwrap();
jj(&root, &["describe", "-m", "left"]);
let left = jj(
&root,
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
jj(&root, &["new", &base]);
fs::write(root.join("conflict"), "right\n").unwrap();
jj(&root, &["describe", "-m", "right"]);
let right = jj(
&root,
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
jj(&root, &["new", &left, &right, "-m", "conflicted merge"]);
let merged = jj(
&root,
&[
"--ignore-working-copy",
"--at-operation=@",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
let projection = command(
"jj",
&root,
&[
"--ignore-working-copy",
"--at-operation=@",
"file",
"show",
"-r",
&merged,
"-T",
"",
"--",
"root-file:\"conflict\"",
],
);
assert!(
projection.status.success(),
"{}",
String::from_utf8_lossy(&projection.stderr)
);
let projection = String::from_utf8(projection.stdout).unwrap();
let expected: Vec<_> = projection
.strip_suffix('\n')
.unwrap_or(&projection)
.split('\n')
.map(str::to_owned)
.collect();
let state = disk_state(&root);
let mut repository = open(&root);
let catalog = repository.catalog().unwrap();
assert!(
catalog
.entries
.iter()
.any(|r| r.target == RevisionTarget::Commit(merged.clone()))
);
let comparison = compare_loaded(
&mut repository,
&revision(&base, RepoKind::Jujutsu),
&revision(&merged, RepoKind::Jujutsu),
&[],
)
.unwrap();
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("conflict"), Path::new("ordinary")]
);
let conflict = changed(&comparison, Path::new("conflict"));
assert_eq!(conflict.after, expected);
assert!(
conflict
.notice
.as_ref()
.unwrap()
.contains("conflict projection")
);
assert!(!conflict.hunks.is_empty());
let ordinary = changed(&comparison, Path::new("ordinary"));
assert_eq!(ordinary.before, ["old"]);
assert_eq!(ordinary.after, ["left ordinary"]);
assert!(ordinary.notice.is_none());
let branch = repository
.compare(
30,
&revision(&base, RepoKind::Jujutsu),
&revision(&merged, RepoKind::Jujutsu),
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
let index = branch
.files
.iter()
.position(|file| file.path == Path::new("conflict"))
.unwrap();
let conflict = repository.load_file(30, index).unwrap();
assert_eq!(conflict.after, expected);
assert!(
conflict
.notice
.as_deref()
.unwrap()
.contains("conflict projection")
);
let working_comparison = compare_loaded(
&mut repository,
&revision(&merged, RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
&[],
)
.unwrap();
assert!(
changed(&working_comparison, Path::new("conflict"))
.notice
.as_ref()
.unwrap()
.contains("conflict projection")
);
assert_eq!(disk_state(&root), state);
}
#[test]
fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() {
let fixture = Fixture::new();
let root = fixture.dir("lazy-conflict");
init_jj(&root, false);
fs::write(root.join("conflict"), "base\n").unwrap();
fs::write(root.join("unselected"), "old\n").unwrap();
symlink("unselected", root.join("unchanged-link")).unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base]);
fs::write(root.join("conflict"), "left\n").unwrap();
fs::write(root.join("unselected"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
jj(&root, &["describe", "-m", "left"]);
let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base]);
fs::write(root.join("conflict"), "right\n").unwrap();
jj(&root, &["describe", "-m", "right"]);
let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &left, &right, "-m", "conflicted merge"]);
let merged = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let state = disk_state(&root);
let mut repository = open(&root);
let comparison = repository
.compare(
1,
&revision(&base, RepoKind::Jujutsu),
&revision(&merged, RepoKind::Jujutsu),
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("conflict"), Path::new("unselected")]
);
let conflict = repository.load_file(1, 0).unwrap();
assert_eq!(conflict.before, ["base"]);
let projection = jj(
&root,
&[
"--ignore-working-copy",
"file",
"show",
"-r",
&merged,
"-T",
"",
"--",
"conflict",
],
);
assert_eq!(conflict.after, projection.lines().collect::<Vec<_>>());
assert!(!conflict.hunks.is_empty());
assert!(repository.load_file(1, 1).is_err());
assert_eq!(disk_state(&root), state);
jj(&root, &["new", &merged]);
fs::write(root.join("extra"), "extra\n").unwrap();
jj(
&root,
&["describe", "-m", "unrelated change retaining conflict"],
);
let next = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let comparison = repository
.compare(
2,
&revision(&merged, RepoKind::Jujutsu),
&revision(&next, RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("extra")]
);
}
#[test]
fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths() {
let fixture = Fixture::new();
let root = fs::canonicalize(fixture.dir("overlay")).unwrap();
init_git(&root);
fs::create_dir(root.join("sub")).unwrap();
fs::write(root.join("file"), "base\n").unwrap();
fs::write(root.join("sub/file"), "nested\n").unwrap();
fs::write(root.join("binary"), b"\0binary").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let id = git(&root, &["rev-parse", "HEAD"]);
let outside = fixture.dir("outside");
fs::write(outside.join("file"), "outside\n").unwrap();
symlink(&outside, root.join("linked")).unwrap();
symlink("file", root.join("link")).unwrap();
let state = disk_state(&root);
let buffers = vec![
(root.join("file"), "unsaved\n".into()),
(root.join("binary"), "not binary\n".into()),
(root.join("missing"), "not on disk\n".into()),
(root.join("link"), "not a regular file\n".into()),
(root.join("linked/file"), "outside via link\n".into()),
(outside.join("file"), "outside\n".into()),
(root.join("sub/../../outside/file"), "escaped\n".into()),
(root.join(".git/config"), "metadata\n".into()),
];
let mut repository = open(&root.join("sub"));
let base = revision(&id, RepoKind::Git);
let checkout = working(RepoKind::Git);
let comparison = compare_loaded(&mut repository, &base, &checkout, &buffers).unwrap();
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
assert!(comparison.editable_paths.contains(Path::new("file")));
for path in [
"binary",
"missing",
"link",
"linked",
"linked/file",
".git/config",
] {
assert!(!comparison.editable_paths.contains(Path::new(path)));
}
assert!(
!comparison
.files
.iter()
.any(|file| file.path == Path::new("binary")
|| file.path == Path::new("missing")
|| file.path == Path::new("linked/file"))
);
let immutable = compare_loaded(&mut repository, &checkout, &base, &buffers).unwrap();
assert!(immutable.editable_paths.is_empty());
assert!(
!immutable
.files
.iter()
.any(|file| file.path == Path::new("file"))
);
assert!(
repository
.compare(1, &base, &base, ComparisonMode::Snapshots, &buffers)
.unwrap()
.files
.is_empty()
);
let same_checkout = compare_loaded(&mut repository, &checkout, &checkout, &buffers).unwrap();
assert_eq!(changed(&same_checkout, Path::new("file")).before, ["base"]);
assert_eq!(
changed(&same_checkout, Path::new("file")).after,
["unsaved"]
);
assert_eq!(disk_state(&root), state);
assert_eq!(
fs::read_to_string(outside.join("file")).unwrap(),
"outside\n"
);
}
#[test]
fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details() {
let fixture = Fixture::new();
let root = fixture.dir("lazy");
init_git(&root);
fs::write(root.join("unchanged-large"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
fs::write(root.join("selected"), "before\n").unwrap();
fs::write(root.join("unselected"), "old\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "before"]);
let before = git(&root, &["rev-parse", "HEAD"]);
fs::write(root.join("selected"), "after\n").unwrap();
fs::write(root.join("unselected"), vec![b'y'; 17 * 1024 * 1024]).unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "after"]);
let after = git(&root, &["rev-parse", "HEAD"]);
let mut repository = open(&root);
let comparison = repository
.compare(
100,
&revision(&before, RepoKind::Git),
&revision(&after, RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(comparison.files.len(), 2);
assert!(comparison.files.iter().all(|file| !file.loaded
&& file.before.is_empty()
&& file.after.is_empty()
&& file.hunks.is_empty()));
let index = comparison
.files
.iter()
.position(|file| file.path == Path::new("selected"))
.unwrap();
let selected = repository.load_file(100, index).unwrap();
assert!(selected.loaded);
assert_eq!(selected.before, ["before"]);
assert_eq!(selected.after, ["after"]);
assert_eq!(selected.hunks.len(), 1);
assert!(repository.load_file(99, index).is_err());
let empty = repository
.compare(
101,
&revision(&after, RepoKind::Git),
&revision(&after, RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert!(empty.files.is_empty());
assert!(repository.load_file(100, index).is_err());
}
#[test]
fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
let fixture = Fixture::new();
let root = fixture.dir("frozen").canonicalize().unwrap();
init_git(&root);
fs::write(root.join("file"), "committed\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "before"]);
fs::write(root.join("file"), "disk before\n").unwrap();
let mut repository = open(&root);
let comparison = repository
.compare(
50,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[(root.join("file"), "unsaved after\n".into())],
)
.unwrap();
assert_eq!(comparison.files.len(), 1);
assert!(!comparison.files[0].loaded);
fs::write(root.join("file"), "later disk\n").unwrap();
let details = repository.load_file(50, 0).unwrap();
assert_eq!(details.before, ["disk before"]);
assert_eq!(details.after, ["unsaved after"]);
let again = repository.load_file(50, 0).unwrap();
assert_eq!(again.before, details.before);
assert_eq!(again.after, details.after);
}
#[test]
fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_text() {
let fixture = Fixture::new();
let root = fixture.dir("follow").canonicalize().unwrap();
init_git(&root);
fs::write(root.join("file"), "base\n").unwrap();
fs::write(root.join("removed"), "remove me\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = git(&root, &["rev-parse", "HEAD"]);
fs::write(root.join("file"), "original disk\n").unwrap();
let mut repository = open(&root);
let original = compare_loaded(
&mut repository,
&revision(&base, RepoKind::Git),
&working(RepoKind::Git),
&[],
)
.unwrap();
fs::write(root.join("file"), "later disk\n").unwrap();
assert!(
repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("added"), "new file\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "descendant"]);
let tip = git(&root, &["rev-parse", "HEAD"]);
let state = disk_state(&root);
assert!(
repository
.refresh_checkout(99, 2, true, &[])
.unwrap()
.is_none()
);
assert!(
repository
.refresh_checkout(1, 2, false, &[])
.unwrap()
.is_none()
);
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
let mut refreshed = repository
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
.unwrap()
.unwrap();
assert_eq!(refreshed.before, original.before);
assert_eq!(refreshed.after, original.after);
for (index, file) in refreshed.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap();
}
assert_eq!(changed(&refreshed, Path::new("file")).before, ["base"]);
assert_eq!(changed(&refreshed, Path::new("file")).after, ["unsaved"]);
assert_eq!(
changed(&refreshed, Path::new("added")).status,
FileStatus::Added
);
assert_eq!(
changed(&refreshed, Path::new("removed")).status,
FileStatus::Removed
);
assert!(refreshed.editable_paths.contains(Path::new("file")));
assert!(refreshed.editable_paths.contains(Path::new("added")));
assert!(!refreshed.editable_paths.contains(Path::new("removed")));
assert!(repository.load_file(1, 0).is_err());
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
assert_eq!(disk_state(&root), state);
git(&root, &["checkout", "--detach", &base]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
git(&root, &["checkout", "-b", "other"]);
fs::write(root.join("file"), "other branch\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "unrelated to tip"]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
let index = refreshed
.files
.iter()
.position(|file| file.path == Path::new("file"))
.unwrap();
assert_eq!(repository.load_file(2, index).unwrap().after, ["unsaved"]);
git(&root, &["checkout", "--detach", &tip]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
}
#[test]
fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure() {
let fixture = Fixture::new();
let root = fixture.dir("frozen-follow").canonicalize().unwrap();
init_git(&root);
fs::write(root.join("file"), "base\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
fs::write(root.join("file"), "frozen before\n").unwrap();
let cancelled = Arc::new(AtomicBool::new(false));
let mut repository = Repository::open(&root, cancelled.clone()).unwrap();
repository
.compare(
10,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[(root.join("file"), "frozen after\n".into())],
)
.unwrap();
fs::write(root.join("file"), "next checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "next"]);
cancelled.store(true, Ordering::Release);
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
cancelled.store(false, Ordering::Release);
assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]);
fs::write(root.join("oversized"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
let original = repository.load_file(10, 0).unwrap();
assert_eq!(original.before, ["frozen before"]);
assert_eq!(original.after, ["frozen after"]);
assert!(repository.load_file(11, 0).is_err());
fs::remove_file(root.join("oversized")).unwrap();
let refreshed = repository
.refresh_checkout(10, 11, true, &[])
.unwrap()
.unwrap();
assert_eq!(refreshed.files.len(), 1);
let file = repository.load_file(11, 0).unwrap();
assert_eq!(file.before, ["frozen before"]);
assert_eq!(file.after, ["next checkout"]);
let tip = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
let pinned = repository
.compare(12, &base, &tip, ComparisonMode::Snapshots, &[])
.unwrap();
assert!(pinned.editable_paths.contains(Path::new("file")));
fs::write(root.join("file"), "newer checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "newer"]);
assert!(
repository
.refresh_checkout(99, 13, false, &[])
.unwrap()
.is_none()
);
let readonly = repository
.refresh_checkout(12, 13, false, &[(root.join("file"), "dirty live\n".into())])
.unwrap()
.unwrap();
assert_eq!(readonly.before, pinned.before);
assert_eq!(readonly.after, pinned.after);
assert!(readonly.editable_paths.is_empty());
assert_eq!(readonly.files.len(), pinned.files.len());
assert_eq!(readonly.files[0].path, pinned.files[0].path);
assert!(repository.load_file(12, 0).is_err());
assert_eq!(
repository.load_file(13, 0).unwrap().after,
["next checkout"]
);
assert!(
repository
.refresh_checkout(13, 14, false, &[])
.unwrap()
.is_none()
);
let RevisionTarget::Commit(tip_id) = &tip.target else {
unreachable!()
};
git(&root, &["checkout", "--detach", tip_id]);
let promoted = repository
.refresh_checkout(13, 14, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
fs::write(root.join("file"), "dirty disk\n").unwrap();
assert_eq!(
repository.load_file(14, 0).unwrap().after,
["next checkout"]
);
cancelled.store(true, Ordering::Release);
assert!(repository.refresh_checkout(14, 15, false, &[]).is_err());
cancelled.store(false, Ordering::Release);
let recovered = repository
.refresh_checkout(14, 15, false, &[])
.unwrap()
.unwrap();
assert_eq!(recovered.editable_paths, pinned.editable_paths);
assert_eq!(
repository.load_file(15, 0).unwrap().after,
["next checkout"]
);
}
#[test]
fn refresh_materialization_failure_restores_prepared_file_sources() {
let fixture = Fixture::new();
let root = fixture.dir("failed-materialization");
init_git(&root);
fs::write(root.join("a-file"), "frozen source\n").unwrap();
fs::write(root.join("z-large"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
fs::remove_file(root.join("a-file")).unwrap();
fs::remove_file(root.join("z-large")).unwrap();
let mut repository = open(&root);
let original = repository
.compare(
1,
&base,
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
let index = original
.files
.iter()
.position(|file| file.path == Path::new("a-file"))
.unwrap();
fs::write(root.join("a-file"), "new checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "descendant"]);
assert!(repository.refresh_checkout(1, 2, true, &[]).is_err());
let file = repository.load_file(1, index).unwrap();
assert_eq!(file.before, ["frozen source"]);
assert!(file.after.is_empty());
assert_eq!(file.status, FileStatus::Removed);
assert!(repository.load_file(2, index).is_err());
}
#[test]
fn unborn_git_working_after_safely_advances_to_its_first_commit() {
let fixture = Fixture::new();
let root = fixture.dir("unborn");
init_git(&root);
let mut repository = open(&root);
repository
.compare(
1,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert!(
repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
fs::write(root.join("first"), "initial\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "initial"]);
let comparison = repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.unwrap();
assert_eq!(comparison.files.len(), 1);
assert_eq!(repository.load_file(2, 0).unwrap().after, ["initial"]);
}
#[test]
fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
for colocate in [false, true] {
let fixture = Fixture::new();
let root = fixture.dir("jj-follow").canonicalize().unwrap();
init_jj(&root, colocate);
fs::write(root.join("file"), "base\n").unwrap();
fs::write(root.join("removed"), "remove me\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new"]);
fs::write(root.join("file"), "original disk\n").unwrap();
let mut repository = open(&root);
compare_loaded(
&mut repository,
&revision(&base, RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
&[],
)
.unwrap();
fs::write(root.join("file"), "same-change rewrite\n").unwrap();
jj(&root, &["describe", "-m", "rewritten"]);
let state = disk_state(&root);
assert!(
repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new"]);
fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("added"), "new file\n").unwrap();
let state = disk_state(&root);
let mut comparison = repository
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
.unwrap()
.unwrap();
for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap();
}
assert_eq!(changed(&comparison, Path::new("file")).before, ["base"]);
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
assert_eq!(
changed(&comparison, Path::new("added")).status,
FileStatus::Added
);
assert_eq!(
changed(&comparison, Path::new("removed")).status,
FileStatus::Removed
);
assert!(repository.load_file(1, 0).is_err());
assert_eq!(disk_state(&root), state);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
jj(&root, &["edit", &base]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
jj(&root, &["new", &base]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
let index = comparison
.files
.iter()
.position(|file| file.path == Path::new("file"))
.unwrap();
assert_eq!(repository.load_file(2, index).unwrap().after, ["unsaved"]);
repository
.compare(
10,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[(root.join("file"), "frozen after\n".into())],
)
.unwrap();
fs::write(root.join("file"), "new checkout\n").unwrap();
jj(&root, &["new"]);
let refreshed = repository
.refresh_checkout(10, 11, true, &[])
.unwrap()
.unwrap();
assert_eq!(refreshed.files.len(), 1);
let file = repository.load_file(11, 0).unwrap();
assert_eq!(file.before, ["base"]);
assert_eq!(file.after, ["new checkout"]);
let historical = revision(&base, RepoKind::Jujutsu);
repository
.compare(12, &historical, &historical, ComparisonMode::Snapshots, &[])
.unwrap();
jj(&root, &["new"]);
assert!(
repository
.refresh_checkout(12, 13, true, &[])
.unwrap()
.is_none()
);
let tip_id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let pinned_tip = revision(&tip_id, RepoKind::Jujutsu);
let pinned = repository
.compare(20, &historical, &pinned_tip, ComparisonMode::Snapshots, &[])
.unwrap();
assert!(pinned.editable_paths.contains(Path::new("file")));
let index = pinned
.files
.iter()
.position(|file| file.path == Path::new("file"))
.unwrap();
assert_eq!(
repository.load_file(20, index).unwrap().after,
["new checkout"]
);
fs::write(root.join("file"), "rewritten checkout\n").unwrap();
jj(&root, &["describe", "-m", "same pinned change"]);
let rewritten = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
assert_ne!(rewritten, tip_id);
assert!(
repository
.refresh_checkout(20, 21, false, &[])
.unwrap()
.is_none()
);
assert_eq!(
repository.load_file(20, index).unwrap().after,
["new checkout"]
);
let same_change = repository
.compare(21, &historical, &pinned_tip, ComparisonMode::Snapshots, &[])
.unwrap();
assert_eq!(same_change.editable_paths, pinned.editable_paths);
jj(&root, &["new"]);
let readonly = repository
.refresh_checkout(21, 22, false, &[(root.join("file"), "dirty live\n".into())])
.unwrap()
.unwrap();
assert!(readonly.editable_paths.is_empty());
assert_eq!(readonly.before, pinned.before);
assert_eq!(readonly.after, pinned.after);
assert_eq!(readonly.files.len(), pinned.files.len());
assert_eq!(
repository.load_file(22, index).unwrap().after,
["new checkout"]
);
jj(&root, &["edit", &rewritten]);
let promoted = repository
.refresh_checkout(22, 23, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
assert_eq!(
repository.load_file(23, index).unwrap().after,
["new checkout"]
);
}
}
#[test]
fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_mutation() {
let fixture = Fixture::new();
let active = fixture.dir("active\nquoted\"\\");
init_git(&active);
fs::write(active.join("common"), "base\n").unwrap();
git(&active, &["add", "."]);
git(&active, &["commit", "-m", "base"]);
let base = git(&active, &["rev-parse", "HEAD"]);
let source = fixture.0.join("source");
git(
&fixture.0,
&["clone", active.to_str().unwrap(), source.to_str().unwrap()],
);
git(&source, &["config", "user.name", "Diff Test"]);
git(&source, &["config", "user.email", "diff@example.invalid"]);
fs::write(source.join("remote-only"), "remote\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "remote"]);
fs::write(active.join("local-only"), "local\n").unwrap();
git(&active, &["add", "."]);
git(&active, &["commit", "-m", "local"]);
let local = revision(&git(&active, &["rev-parse", "HEAD"]), RepoKind::Git);
git(
&active,
&["remote", "add", "origin", source.to_str().unwrap()],
);
let state = disk_state(&active);
let mut repository = open(&active);
let catalog = repository.list_remote("origin").unwrap();
let remote = catalog
.entries
.iter()
.find(|r| matches!(r.target, RevisionTarget::Remote { .. }))
.unwrap();
let branch = repository
.compare(40, &local, remote, ComparisonMode::BranchChanges, &[])
.unwrap();
assert_eq!(branch.files.len(), 1);
assert_eq!(branch.files[0].path, Path::new("remote-only"));
assert!(branch.before.ends_with(&base));
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
let reverse = repository
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
.unwrap();
assert_eq!(reverse.files.len(), 1);
assert_eq!(reverse.files[0].path, Path::new("local-only"));
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
assert!(
repository
.compare(42, &local, &local, ComparisonMode::BranchChanges, &[])
.unwrap()
.files
.is_empty()
);
assert_eq!(disk_state(&active), state);
}
#[test]
fn branch_changes_rejects_missing_and_multiple_merge_bases_without_snapshot_fallback() {
let fixture = Fixture::new();
let root = fixture.dir("graphs");
init_git(&root);
fs::write(root.join("file"), "base\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = git(&root, &["rev-parse", "HEAD"]);
let tree = git(&root, &["rev-parse", "HEAD^{tree}"]);
let left = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "left"]);
let right = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "right"]);
let one = git(
&root,
&["commit-tree", &tree, "-p", &left, "-p", &right, "-m", "one"],
);
let two = git(
&root,
&["commit-tree", &tree, "-p", &right, "-p", &left, "-m", "two"],
);
let unrelated = git(&root, &["commit-tree", &tree, "-m", "unrelated"]);
let state = disk_state(&root);
let mut repository = open(&root);
for (before, after, message) in [
(&base, &unrelated, "no merge base"),
(&one, &two, "multiple merge bases"),
] {
let before = revision(before, RepoKind::Git);
let after = revision(after, RepoKind::Git);
assert!(
repository
.compare(50, &before, &after, ComparisonMode::BranchChanges, &[])
.unwrap_err()
.contains(message)
);
assert!(
repository
.compare(51, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap()
.files
.is_empty()
);
}
assert_eq!(disk_state(&root), state);
}
#[test]
fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
let fixture = Fixture::new();
let source = fixture.dir("source");
init_git(&source);
fs::write(source.join("common"), "base\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "base"]);
let base = git(&source, &["rev-parse", "HEAD"]);
fs::write(source.join("target-only"), "not part of the PR\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "target advanced"]);
let target = git(&source, &["rev-parse", "HEAD"]);
git(&source, &["checkout", "--detach", &base]);
fs::write(source.join("common"), "fork change\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "fork tip"]);
let head = git(&source, &["rev-parse", "HEAD"]);
git(&source, &["update-ref", "refs/pull/7/head", &head]);
for (index, kind) in [RepoKind::Git, RepoKind::Jujutsu, RepoKind::Jujutsu]
.into_iter()
.enumerate()
{
let root = fixture.dir(&format!("active-{index}"));
if kind == RepoKind::Git {
init_git(&root);
fs::write(root.join("unrelated"), "local\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "unrelated local"]);
git(
&root,
&["remote", "add", "origin", source.to_str().unwrap()],
);
} else {
init_jj(&root, index == 2);
jj(
&root,
&["git", "remote", "add", "origin", source.to_str().unwrap()],
);
}
fs::write(root.join("unsaved"), "keep\n").unwrap();
let state = disk_state(&root);
let pinned = |reference: &str, id: &str| Revision {
target: RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: reference.into(),
id: id.into(),
},
..revision(id, kind)
};
let before = pinned("refs/heads/local", &target);
let after = pinned("refs/pull/7/head", &head);
let mut repository = open(&root);
let comparison = repository
.compare(70, &before, &after, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(comparison.before.ends_with(&base));
assert_eq!(comparison.files.len(), 1);
assert_eq!(comparison.files[0].path, Path::new("common"));
assert!(comparison.editable_paths.is_empty());
let file = repository.load_file(70, 0).unwrap();
assert_eq!(file.before, ["base"]);
assert_eq!(file.after, ["fork change"]);
assert_eq!(disk_state(&root), state);
// A force-push cannot silently replace the immutable tip selected in the picker.
let absent = pinned("refs/pull/7/head", &"f".repeat(40));
assert!(
repository
.compare(71, &before, &absent, ComparisonMode::BranchChanges, &[])
.unwrap_err()
.contains("reopen :pull_requests")
);
assert_eq!(disk_state(&root), state);
}
}