Files
vamp/tests/repository.rs
T
2026-10-13 10:48:24 +02:00

1411 lines
45 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.is_empty());
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);
}
#[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"]);
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);
}
#[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();
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();
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 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 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);
}