Files
vamp/tests/repository.rs
T
2026-10-20 16:28:53 +02:00

3030 lines
104 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::{CheckoutRefresh, 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 checkout_comparison(refresh: CheckoutRefresh) -> Comparison {
let CheckoutRefresh::Checkout(comparison) = refresh else {
panic!("Expected checkout-only refresh");
};
comparison
}
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 comparison_revisions_freeze_commits_and_address_working_contents() {
for kind in [RepoKind::Git, RepoKind::Jujutsu] {
let fixture = Fixture::new();
let root = fixture.dir("identity").canonicalize().unwrap();
match kind {
RepoKind::Git => init_git(&root),
RepoKind::Jujutsu => init_jj(&root, false),
}
fs::write(root.join("file"), "committed\n").unwrap();
let id = match kind {
RepoKind::Git => {
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "baseline"]);
git(&root, &["rev-parse", "HEAD"])
}
RepoKind::Jujutsu => {
jj(&root, &["describe", "-m", "baseline"]);
jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"])
}
};
fs::write(root.join("file"), "working\n").unwrap();
let state = disk_state(&root);
let before = revision(&id, kind);
let after = working(kind);
let mut repository = open(&root);
let first = repository
.compare(1, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
assert_eq!(first.revisions[0], id);
assert!(first.revisions[1].starts_with("working:"));
assert_eq!(first.blame[0].as_ref().unwrap().commit, id);
assert_eq!(
first.blame[1]
.as_ref()
.map(|baseline| baseline.commit.as_str()),
(kind == RepoKind::Git).then_some(id.as_str())
);
let reopened = open(&root)
.compare(2, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
assert_eq!(first.revisions, reopened.revisions);
let overlay = repository
.compare(
3,
&before,
&after,
ComparisonMode::Snapshots,
&[(root.join("file"), "unsaved\n".into())],
)
.unwrap();
assert_ne!(overlay.revisions[1], first.revisions[1]);
let repeated = repository
.compare(
4,
&before,
&after,
ComparisonMode::Snapshots,
&[(root.join("file"), "unsaved\n".into())],
)
.unwrap();
assert_eq!(repeated.revisions, overlay.revisions);
let working_pair = repository
.compare(5, &after, &after, ComparisonMode::Snapshots, &[])
.unwrap();
assert_eq!(working_pair.revisions[0], first.revisions[1]);
assert_eq!(working_pair.revisions[0], working_pair.revisions[1]);
assert_eq!(
working_pair.blame,
[first.blame[1].clone(), first.blame[1].clone()]
);
assert_eq!(disk_state(&root), state);
fs::write(root.join("file"), "working").unwrap();
let no_newline = repository
.compare(6, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
assert_ne!(no_newline.revisions[1], first.revisions[1]);
}
}
#[test]
fn unopened_catalog_counts_frozen_text_but_not_binary_modes_or_exact_renames() {
let fixture = Fixture::new();
let root = fixture.dir("stats");
init_git(&root);
for (path, bytes) in [
("first", &b"old\nkeep\n"[..]),
("second", b"remove\nkeep\n"),
("removed", b"gone\n"),
("rename-old", b"move\n"),
("binary", b"\0old"),
("mode", b"script\n"),
("crlf", b"a\r\n"),
("eof", b"same\n"),
] {
fs::write(root.join(path), bytes).unwrap();
}
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "before"]);
let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
for (path, bytes) in [
("first", &b"new\nextra\nkeep\n"[..]),
("second", b"keep\n"),
("added", b"one\nlast"),
("binary", b"\0new"),
("crlf", b"b\r\n"),
("eof", b"same"),
] {
fs::write(root.join(path), bytes).unwrap();
}
fs::remove_file(root.join("removed")).unwrap();
fs::rename(root.join("rename-old"), root.join("-renamed\t\n")).unwrap();
fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap();
let mut repository = open(&root);
let catalog = repository
.compare(
1,
&before,
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert!(catalog.files.iter().all(|file| !file.loaded
&& file.before.is_empty()
&& file.after.is_empty()
&& file.hunks.is_empty()));
for (path, counts) in [
("first", (2, 1)),
("second", (0, 1)),
("added", (2, 0)),
("removed", (0, 1)),
("-renamed\t\n", (0, 0)),
("binary", (0, 0)),
("mode", (0, 0)),
("crlf", (1, 1)),
("eof", (1, 1)),
] {
assert_eq!(
changed(&catalog, Path::new(path)).stats,
Some(counts),
"{path}"
);
}
assert_eq!(
changed(&catalog, Path::new("-renamed\t\n")).status,
FileStatus::Renamed
);
fs::write(root.join("second"), "changed after freezing\n").unwrap();
for (index, file) in catalog.files.iter().enumerate() {
let loaded = repository.load_file(1, index).unwrap();
let counts = loaded.hunks.iter().fold((0, 0), |(added, removed), hunk| {
(added + hunk.after_len, removed + hunk.before_len)
});
assert_eq!(file.stats, Some(counts), "{}", file.path.display());
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-m", "advance checkout"]);
let refreshed = repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.map(checkout_comparison)
.unwrap();
assert_eq!(changed(&refreshed, Path::new("first")).stats, Some((2, 1)));
assert_eq!(changed(&refreshed, Path::new("second")).stats, Some((1, 2)));
}
#[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()
.map(checkout_comparison)
.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()
.map(checkout_comparison)
.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_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching_source() {
for colocate in [false, true] {
let fixture = Fixture::new();
let root = fixture.dir("bookmarks");
init_jj(&root, colocate);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["bookmark", "create", "topic"]);
jj(&root, &["new"]);
fs::write(root.join("file"), "next\n").unwrap();
jj(&root, &["describe", "-m", "next"]);
let next = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let mut repository = open(&root);
let catalog = repository.catalog().unwrap();
let topic = catalog
.entries
.iter()
.find(|entry| entry.label == "topic")
.unwrap();
assert_eq!(
topic.target,
RevisionTarget::Bookmark {
name: "topic".into(),
remote: None
}
);
repository
.compare(
1,
&revision(&"0".repeat(40), RepoKind::Jujutsu),
topic,
ComparisonMode::Snapshots,
&[],
)
.unwrap();
jj(&root, &["bookmark", "set", "topic", "-r", &next]);
let state = disk_state(&root);
let Some(CheckoutRefresh::Bookmark(comparison)) =
repository.refresh_checkout(1, 2, false, &[]).unwrap()
else {
panic!("Bookmark movement must replace contents even when follow is disabled");
};
assert_eq!(comparison.after, topic.label);
assert!(comparison.blame[0].is_none());
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, next);
assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]);
assert!(repository.load_file(1, 0).is_err());
assert!(
repository
.refresh_checkout(2, 3, false, &[])
.unwrap()
.is_none()
);
assert_eq!(disk_state(&root), state);
let change = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "change_id"]);
fs::write(root.join("file"), "rewritten\n").unwrap();
jj(&root, &["describe", "-m", "rewritten"]);
assert_eq!(
jj(&root, &["log", "--no-graph", "-r", "@", "-T", "change_id"]),
change
);
let state = disk_state(&root);
assert!(matches!(
repository.refresh_checkout(2, 3, false, &[]).unwrap(),
Some(CheckoutRefresh::Bookmark(_))
));
assert_eq!(repository.load_file(3, 0).unwrap().after, ["rewritten"]);
assert_eq!(disk_state(&root), state);
jj(&root, &["describe", "-m", "description-only rewrite"]);
assert!(matches!(
repository.refresh_checkout(3, 4, false, &[]).unwrap(),
Some(CheckoutRefresh::Bookmark(_))
));
assert_eq!(repository.load_file(4, 0).unwrap().after, ["rewritten"]);
repository
.compare(
5,
&revision(&"0".repeat(40), RepoKind::Jujutsu),
&revision(&base, RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert!(
repository
.refresh_checkout(5, 6, false, &[])
.unwrap()
.is_none()
);
assert_eq!(repository.load_file(5, 0).unwrap().after, ["base"]);
}
}
#[test]
fn jj_bookmark_refresh_retains_frozen_working_before_and_working_after_overlays() {
let fixture = Fixture::new();
let root = fixture.dir("bookmark-working").canonicalize().unwrap();
init_jj(&root, false);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
jj(&root, &["bookmark", "create", "topic"]);
jj(&root, &["new"]);
fs::write(root.join("file"), "frozen disk\n").unwrap();
let mut repository = open(&root);
let catalog = repository.catalog().unwrap();
let topic = catalog
.entries
.iter()
.find(|entry| entry.label == "topic")
.unwrap();
repository
.compare(
1,
&working(RepoKind::Jujutsu),
topic,
ComparisonMode::Snapshots,
&[],
)
.unwrap();
fs::write(root.join("file"), "new topic\n").unwrap();
jj(&root, &["describe", "-m", "next"]);
jj(&root, &["bookmark", "set", "topic", "-r", "@"]);
assert!(matches!(
repository.refresh_checkout(1, 2, false, &[]).unwrap(),
Some(CheckoutRefresh::Bookmark(_))
));
let file = repository.load_file(2, 0).unwrap();
assert_eq!(file.before, ["frozen disk"]);
assert_eq!(file.after, ["new topic"]);
repository
.compare(
3,
topic,
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[(root.join("file"), "old overlay\n".into())],
)
.unwrap();
fs::write(root.join("file"), "rewritten topic\n").unwrap();
jj(&root, &["describe", "-m", "rewrite"]);
let state = disk_state(&root);
assert!(matches!(
repository
.refresh_checkout(3, 4, false, &[(root.join("file"), "dirty buffer\n".into())])
.unwrap(),
Some(CheckoutRefresh::Bookmark(_))
));
let file = repository.load_file(4, 0).unwrap();
assert_eq!(file.before, ["rewritten topic"]);
// Live buffer rebinding is a frontend concern; a bookmark refresh must not
// advance the unrelated frozen Working endpoint while follow is disabled.
assert_eq!(file.after, ["old overlay"]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new"]);
fs::write(root.join("file"), "advanced checkout\n").unwrap();
jj(&root, &["describe", "-m", "advance"]);
jj(&root, &["bookmark", "set", "topic", "-r", "@"]);
assert!(matches!(
repository.refresh_checkout(4, 5, false, &[]).unwrap(),
Some(CheckoutRefresh::Bookmark(_))
));
assert_eq!(repository.load_file(5, 0).unwrap().after, ["old overlay"]);
assert!(matches!(
repository
.refresh_checkout(5, 6, true, &[(root.join("file"), "new overlay\n".into())])
.unwrap(),
Some(CheckoutRefresh::Checkout(_))
));
let file = repository.load_file(6, 0).unwrap();
assert_eq!(file.before, ["advanced checkout"]);
assert_eq!(file.after, ["new overlay"]);
}
#[test]
fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion() {
let fixture = Fixture::new();
let root = fixture.dir("bookmark-branch");
init_jj(&root, false);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["bookmark", "create", "before"]);
jj(&root, &["new"]);
fs::write(root.join("file"), "tip\n").unwrap();
jj(&root, &["describe", "-m", "tip"]);
let tip = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["bookmark", "create", "after"]);
let mut repository = open(&root);
let catalog = repository.catalog().unwrap();
let before = catalog
.entries
.iter()
.find(|entry| entry.label == "before")
.unwrap();
let after = catalog
.entries
.iter()
.find(|entry| entry.label == "after")
.unwrap();
let original = repository
.compare(1, before, after, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(original.before.ends_with(&base));
assert_eq!(original.revisions, [base.clone(), tip.clone()]);
assert_eq!(original.blame[0].as_ref().unwrap().commit, base);
assert_eq!(original.blame[1].as_ref().unwrap().commit, tip);
jj(&root, &["bookmark", "set", "before", "-r", &tip]);
let Some(CheckoutRefresh::Bookmark(comparison)) =
repository.refresh_checkout(1, 2, false, &[]).unwrap()
else {
panic!("Moved Before must recompute merge base");
};
assert!(comparison.before.ends_with(&tip));
assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]);
assert_eq!(comparison.blame[0].as_ref().unwrap().commit, tip);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, tip);
assert!(comparison.files.is_empty());
jj(&root, &["bookmark", "delete", "after"]);
let state = disk_state(&root);
assert!(
repository
.refresh_checkout(2, 3, false, &[])
.unwrap_err()
.contains("missing or deleted")
);
assert!(repository.load_file(3, 0).is_err());
assert_eq!(disk_state(&root), state);
jj(&root, &["bookmark", "create", "after", "-r", &tip]);
// A restored bookmark at the same ID is a successful no-op (or eligibility
// promotion), not another comparison-content replacement.
assert!(!matches!(
repository.refresh_checkout(2, 3, false, &[]).unwrap(),
Some(CheckoutRefresh::Bookmark(_))
));
jj(
&root,
&["bookmark", "set", "after", "-r", &base, "--allow-backwards"],
);
let Some(CheckoutRefresh::Bookmark(comparison)) =
repository.refresh_checkout(3, 4, false, &[]).unwrap()
else {
panic!("Backward bookmark movement must refresh");
};
assert!(comparison.before.ends_with(&base));
assert!(comparison.files.is_empty());
}
#[test]
fn jj_bookmark_refresh_uses_exact_names_and_keeps_last_view_through_conflicts() {
let fixture = Fixture::new();
let root = fixture.dir("bookmark-conflict");
init_jj(&root, false);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let name = "topic'quoted";
let argument = "\"topic'quoted\"";
jj(&root, &["bookmark", "create", argument]);
jj(&root, &["bookmark", "create", "topic"]);
jj(&root, &["new"]);
fs::write(root.join("file"), "left\n").unwrap();
jj(&root, &["describe", "-m", "left"]);
let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["bookmark", "create", "keep-left"]);
jj(&root, &["new", &base]);
fs::write(root.join("file"), "right\n").unwrap();
jj(&root, &["describe", "-m", "right"]);
let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let mut repository = open(&root);
let catalog = repository.catalog().unwrap();
let selected = catalog
.entries
.iter()
.find(|entry| entry.label == name)
.unwrap();
repository
.compare(
1,
&revision(&"0".repeat(40), RepoKind::Jujutsu),
selected,
ComparisonMode::Snapshots,
&[],
)
.unwrap();
jj(&root, &["bookmark", "set", "topic", "-r", &left]);
assert!(
repository
.refresh_checkout(1, 2, false, &[])
.unwrap()
.is_none()
);
let operation = jj(
&root,
&["op", "log", "--no-graph", "--limit", "1", "-T", "id"],
);
jj(&root, &["bookmark", "set", argument, "-r", &left]);
jj(
&root,
&[
&format!("--at-operation={operation}"),
"bookmark",
"set",
argument,
"-r",
&right,
],
);
// Merge the fixture's concurrent operations, not the viewer's read queries.
jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let state = disk_state(&root);
assert!(
repository
.refresh_checkout(1, 2, false, &[])
.unwrap_err()
.contains("conflicted")
);
assert_eq!(repository.load_file(1, 0).unwrap().after, ["base"]);
assert!(repository.load_file(2, 0).is_err());
assert_eq!(disk_state(&root), state);
jj(
&root,
&[
"bookmark",
"set",
argument,
"-r",
&left,
"--allow-backwards",
],
);
assert!(matches!(
repository.refresh_checkout(1, 2, false, &[]).unwrap(),
Some(CheckoutRefresh::Bookmark(_))
));
assert_eq!(repository.load_file(2, 0).unwrap().after, ["left"]);
}
#[test]
fn jj_known_remote_bookmark_refreshes_locally_but_private_catalog_entries_stay_frozen() {
let fixture = Fixture::new();
let source = fixture.dir("source");
init_jj(&source, false);
fs::write(source.join("file"), "first\n").unwrap();
jj(&source, &["describe", "-m", "first"]);
jj(&source, &["bookmark", "create", "topic"]);
jj(&source, &["bookmark", "create", "deleted"]);
jj(&source, &["git", "export"]);
let root = fixture.dir("active");
init_jj(&root, false);
jj(
&root,
&[
"git",
"remote",
"add",
"origin",
source.join(".jj/repo/store/git").to_str().unwrap(),
],
);
let mut repository = open(&root);
let private = repository.fetch_remote("origin").unwrap();
let frozen = private
.entries
.iter()
.find(|entry| {
entry.label == "topic@origin" && matches!(entry.target, RevisionTarget::Commit(_))
})
.unwrap();
jj(&root, &["git", "fetch", "--remote", "origin"]);
jj(&root, &["bookmark", "track", "deleted@origin"]);
jj(&root, &["bookmark", "delete", "deleted"]);
let catalog = repository.catalog().unwrap();
let remote = catalog
.entries
.iter()
.find(|entry| {
entry.target
== (RevisionTarget::Bookmark {
name: "topic".into(),
remote: Some("origin".into()),
})
})
.unwrap();
repository
.compare(
1,
&revision(&"0".repeat(40), RepoKind::Jujutsu),
remote,
ComparisonMode::Snapshots,
&[],
)
.unwrap();
jj(&source, &["new"]);
fs::write(source.join("file"), "second\n").unwrap();
jj(&source, &["describe", "-m", "second"]);
jj(&source, &["bookmark", "set", "topic", "-r", "@"]);
jj(&source, &["git", "export"]);
// Remote movement alone does not cause a network poll by the viewer.
assert!(
repository
.refresh_checkout(1, 2, false, &[])
.unwrap()
.is_none()
);
jj(&root, &["git", "fetch", "--remote", "origin"]);
let state = disk_state(&root);
assert!(matches!(
repository.refresh_checkout(1, 2, false, &[]).unwrap(),
Some(CheckoutRefresh::Bookmark(_))
));
assert_eq!(repository.load_file(2, 0).unwrap().after, ["second"]);
assert_eq!(disk_state(&root), state);
repository
.compare(
3,
&revision(&"0".repeat(40), RepoKind::Jujutsu),
frozen,
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert!(
repository
.refresh_checkout(3, 4, false, &[])
.unwrap()
.is_none()
);
assert_eq!(repository.load_file(3, 0).unwrap().after, ["first"]);
}
#[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()
.map(checkout_comparison)
.unwrap();
assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after);
assert_eq!(promoted.blame, branch.blame);
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();
assert_eq!(changed(&branch, Path::new("conflict")).stats, None);
assert_eq!(changed(&branch, Path::new("ordinary")).stats, Some((1, 1)));
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);
jj(
&root,
&[
"config",
"set",
"--repo",
"snapshot.max-new-file-size",
"100MiB",
],
);
let tail_line = "x".repeat(1023);
let tail = format!("{tail_line}\n").repeat(17 * 1024 + 1);
fs::write(root.join("conflict"), format!("base\n{tail}")).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"), format!("left\n{tail}")).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"), format!("right\n{tail}")).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[0], "base");
assert_eq!(conflict.before.len(), 17 * 1024 + 2);
assert!(conflict.before[1..].iter().all(|line| line == &tail_line));
let projection = jj(
&root,
&[
"--ignore-working-copy",
"file",
"show",
"-r",
&merged,
"-T",
"",
"--",
"conflict",
],
);
assert!(projection.len() > 16 * 1024 * 1024);
assert_eq!(conflict.after, projection.lines().collect::<Vec<_>>());
assert!(!conflict.hunks.is_empty());
let large = repository.load_file(1, 1).unwrap();
assert_eq!(large.after.len(), 1);
assert_eq!(large.after[0].len(), 17 * 1024 * 1024);
assert!(large.after[0].bytes().all(|byte| byte == b'x'));
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 comparison_size_and_line_ceilings_are_lazy_and_explicit() {
let fixture = Fixture::new();
let root = fixture.dir("ceilings");
init_git(&root);
git(&root, &["commit", "--allow-empty", "-m", "before"]);
let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
fs::File::create(root.join("too-many-bytes"))
.unwrap()
.set_len(64 * 1024 * 1024 + 1)
.unwrap();
fs::write(root.join("too-many-lines"), vec![b'\n'; 4_000_001]).unwrap();
fs::write(root.join("selected"), "still available\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "after"]);
let after = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
let mut repository = open(&root);
let catalog = repository
.compare(1, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
assert_eq!(catalog.files.len(), 3);
assert!(catalog.files.iter().all(|file| !file.loaded));
assert_eq!(changed(&catalog, Path::new("too-many-bytes")).stats, None);
assert_eq!(changed(&catalog, Path::new("selected")).stats, Some((1, 0)));
for (index, entry) in catalog.files.iter().enumerate() {
match entry.path.to_str().unwrap() {
"selected" => assert_eq!(
repository.load_file(1, index).unwrap().after,
["still available"]
),
"too-many-bytes" => assert!(
repository
.load_file(1, index)
.unwrap_err()
.contains("64 MiB per-side comparison file limit")
),
"too-many-lines" => assert!(
repository
.load_file(1, index)
.unwrap_err()
.contains("4000000-line per-side comparison limit")
),
path => panic!("Unexpected file {path}"),
}
}
}
#[test]
fn large_git_and_jj_comparisons_load_exact_snapshots_and_frozen_working_overlays() {
let fixture = Fixture::new();
// Both modified sides exceed 52.3 MB; the Working pair exceeds the old
// aggregate 64 MiB budget as well as its old 16 MiB per-file limit.
let body_line = "x".repeat(1023);
let mut contents = format!(
"before\n{}",
format!("{body_line}\n").repeat(52_300_000 / 1024 + 1)
);
let line_count = contents.lines().count();
for kind in [RepoKind::Git, RepoKind::Jujutsu] {
let root = fixture
.dir(if kind == RepoKind::Git { "git" } else { "jj" })
.canonicalize()
.unwrap();
match kind {
RepoKind::Git => init_git(&root),
RepoKind::Jujutsu => {
init_jj(&root, false);
jj(
&root,
&[
"config",
"set",
"--repo",
"snapshot.max-new-file-size",
"100MiB",
],
);
}
}
let set_header = |contents: &mut String, header: &str| {
let end = contents.find('\n').unwrap();
contents.replace_range(..end, header);
};
set_header(&mut contents, "before");
fs::write(root.join("modified"), &contents).unwrap();
set_header(&mut contents, "removed");
fs::write(root.join("removed"), &contents).unwrap();
let before = match kind {
RepoKind::Git => {
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "large before"]);
git(&root, &["rev-parse", "HEAD"])
}
RepoKind::Jujutsu => {
jj(&root, &["describe", "-m", "large before"]);
let id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &id]);
id
}
};
set_header(&mut contents, "after");
fs::write(root.join("modified"), &contents).unwrap();
fs::remove_file(root.join("removed")).unwrap();
set_header(&mut contents, "added");
fs::write(root.join("added"), &contents).unwrap();
let after = match kind {
RepoKind::Git => {
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "large after"]);
git(&root, &["rev-parse", "HEAD"])
}
RepoKind::Jujutsu => {
jj(&root, &["describe", "-m", "large after"]);
jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"])
}
};
let assert_contents = |lines: &[String], header: &str| {
assert_eq!(lines.len(), line_count);
assert_eq!(lines[0], header);
assert!(lines[1..].iter().all(|line| line == &body_line));
};
let cancelled = Arc::new(AtomicBool::new(false));
let mut repository = Repository::open(&root, cancelled.clone()).unwrap();
let catalog = repository
.compare(
1,
&revision(&before, kind),
&revision(&after, kind),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(catalog.files.len(), 3);
assert!(catalog.files.iter().all(|file| !file.loaded
&& file.before.is_empty()
&& file.after.is_empty()
&& file.hunks.is_empty()));
for (index, entry) in catalog.files.iter().enumerate() {
let file = repository.load_file(1, index).unwrap();
assert!(file.loaded);
assert_eq!(file.hunks.len(), 1);
let hunk = &file.hunks[0];
let expected = match entry.path.to_str().unwrap() {
"added" => {
assert_eq!(file.status, FileStatus::Added);
assert!(file.before.is_empty());
assert_contents(&file.after, "added");
(0, 0, 0, line_count)
}
"removed" => {
assert_eq!(file.status, FileStatus::Removed);
assert_contents(&file.before, "removed");
assert!(file.after.is_empty());
(0, line_count, 0, 0)
}
"modified" => {
assert_eq!(file.status, FileStatus::Modified);
assert_contents(&file.before, "before");
assert_contents(&file.after, "after");
(0, 1, 0, 1)
}
path => panic!("Unexpected file {path}"),
};
assert_eq!(
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len
),
expected
);
assert!(file.before.is_empty() || file.before_eof);
assert!(file.after.is_empty() || file.after_eof);
}
set_header(&mut contents, "overlay");
let buffers = [(root.join("modified"), contents)];
let working = repository
.compare(
2,
&revision(&after, kind),
&working(kind),
ComparisonMode::Snapshots,
&buffers,
)
.unwrap();
assert_eq!(working.files.len(), 1);
assert!(!working.files[0].loaded);
assert!(working.editable_paths.contains(Path::new("modified")));
fs::write(root.join("modified"), "later disk contents\n").unwrap();
cancelled.store(true, Ordering::Release);
assert!(
repository
.load_file(2, 0)
.unwrap_err()
.contains("cancelled")
);
cancelled.store(false, Ordering::Release);
let file = repository.load_file(2, 0).unwrap();
assert_contents(&file.before, "after");
assert_contents(&file.after, "overlay");
assert_eq!(file.hunks.len(), 1);
let hunk = &file.hunks[0];
assert_eq!(
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len
),
(0, 1, 0, 1)
);
contents = buffers.into_iter().next().unwrap().1;
}
}
#[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()
.map(checkout_comparison)
.unwrap();
assert_eq!(refreshed.before, original.before);
assert_eq!(refreshed.after, original.after);
assert_eq!(refreshed.blame[0], original.blame[0]);
assert_eq!(refreshed.blame[1].as_ref().unwrap().commit, tip);
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::File::create(root.join("oversized"))
.unwrap()
.set_len(64 * 1024 * 1024 + 1)
.unwrap();
assert!(
repository
.refresh_checkout(10, 11, true, &[])
.unwrap_err()
.contains("64 MiB per-side comparison file limit")
);
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()
.map(checkout_comparison)
.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()
.map(checkout_comparison)
.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()
.map(checkout_comparison)
.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()
.map(checkout_comparison)
.unwrap();
assert_eq!(recovered.editable_paths, pinned.editable_paths);
assert_eq!(
repository.load_file(15, 0).unwrap().after,
["next checkout"]
);
}
#[test]
fn refresh_keeps_historical_blob_sources_lazy_even_with_working_files() {
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);
repository
.compare(
1,
&base,
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(
repository.load_file(1, 0).unwrap().before,
["frozen source"]
);
fs::write(root.join("a-file"), "new checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "descendant"]);
let refreshed = repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.map(checkout_comparison)
.unwrap();
assert!(refreshed.files.iter().all(|file| !file.loaded));
let index = refreshed
.files
.iter()
.position(|file| file.path == Path::new("a-file"))
.unwrap();
let file = repository.load_file(2, index).unwrap();
assert_eq!(file.before, ["frozen source"]);
assert_eq!(file.after, ["new checkout"]);
assert_eq!(file.status, FileStatus::Modified);
assert!(repository.load_file(1, 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()
.map(checkout_comparison)
.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);
let initial = compare_loaded(
&mut repository,
&revision(&base, RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
&[],
)
.unwrap();
assert_eq!(initial.blame[1].as_ref().unwrap().commit, base);
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"]);
let parent = jj(&root, &["log", "--no-graph", "-r", "@-", "-T", "commit_id"]);
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()
.map(checkout_comparison)
.unwrap();
assert_eq!(comparison.blame[0], initial.blame[0]);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, parent);
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()
.map(checkout_comparison)
.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()
.map(checkout_comparison)
.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()
.map(checkout_comparison)
.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!(branch.blame[0].as_ref().unwrap().commit, base);
assert!(!branch.blame[1].as_ref().unwrap().store.starts_with(&active));
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!(reverse.blame[0].as_ref().unwrap().commit, base);
assert_eq!(reverse.blame[1].as_ref().unwrap().commit, local.label);
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);
}
}
#[test]
fn jj_working_blame_captures_one_parent_and_never_selects_an_arbitrary_merge_parent() {
let fixture = Fixture::new();
let root = fixture.dir("blame-parents");
init_jj(&root, false);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new"]);
let state = disk_state(&root);
let mut repository = open(&root);
let one_parent = repository
.compare(
1,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(one_parent.blame[0].as_ref().unwrap().commit, base);
assert_eq!(one_parent.blame[0], one_parent.blame[1]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new", "root()"]);
fs::write(root.join("other"), "sibling\n").unwrap();
jj(&root, &["describe", "-m", "sibling"]);
let sibling = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base, &sibling]);
let state = disk_state(&root);
let merged = repository
.compare(
2,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(merged.blame, [None, None]);
assert_eq!(disk_state(&root), state);
}