feat: track checkout identity for pinned diff editability

This commit is contained in:
2026-10-13 17:04:15 +02:00
parent e3e087f16f
commit 99fa5a2aff
14 changed files with 705 additions and 170 deletions
+199 -24
View File
@@ -375,7 +375,10 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
&[],
)
.unwrap();
assert!(reverse.editable_paths.is_empty());
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
@@ -516,6 +519,46 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
.success()
);
assert_eq!(disk_state(&active), state);
// An advertised remote alias already available at HEAD is eligible without
// importing refs or substituting the local unsaved overlay for pinned bytes.
git(
&source,
&["remote", "add", "origin", bare.to_str().unwrap()],
);
let source_state = disk_state(&source);
let mut checked_out = open(&source);
let advertised = checked_out.list_remote("origin").unwrap();
let alias = advertised
.entries
.iter()
.find(|entry| entry.target == remote.target)
.unwrap();
let pinned = checked_out
.compare(
20,
&revision(&ancestor, RepoKind::Git),
alias,
ComparisonMode::Snapshots,
&[(source.join("file"), "dirty live\n".into())],
)
.unwrap();
assert!(pinned.editable_paths.contains(Path::new("file")));
assert_eq!(checked_out.load_file(20, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&source), source_state);
git(&source, &["checkout", "--detach", &ancestor]);
let readonly = checked_out
.refresh_checkout(20, 21, false, &[])
.unwrap()
.unwrap();
assert!(readonly.editable_paths.is_empty());
assert_eq!(checked_out.load_file(21, 0).unwrap().after, ["remote tip"]);
git(&source, &["checkout", "--detach", &tip]);
let promoted = checked_out
.refresh_checkout(21, 22, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
assert_eq!(checked_out.load_file(22, 0).unwrap().after, ["remote tip"]);
}
#[test]
@@ -707,6 +750,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
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!(
@@ -760,6 +804,31 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
.success()
);
assert_eq!(disk_state(&active), state);
assert!(
repository
.refresh_checkout(61, 62, false, &[])
.unwrap()
.is_none()
);
// Simulate importing and checking out the privately selected endpoint behind
// the viewer. Its initially unavailable active-jj identity must be retried.
jj(&active, &["git", "fetch", "--remote", "origin"]);
jj(&active, &["edit", "--ignore-immutable", &tip]);
let checked_out_state = disk_state(&active);
let promoted = repository
.refresh_checkout(
61,
62,
false,
&[(active.join("file"), "dirty live\n".into())],
)
.unwrap()
.unwrap();
assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after);
assert!(promoted.editable_paths.contains(Path::new("file")));
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&active), checked_out_state);
}
#[test]
@@ -1327,7 +1396,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
fs::write(root.join("file"), "later disk\n").unwrap();
assert!(
repository
.refresh_working_after(1, 2, &[])
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
@@ -1340,12 +1409,19 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
let state = disk_state(&root);
assert!(
repository
.refresh_working_after(99, 2, &[])
.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_working_after(1, 2, &[(root.join("file"), "unsaved\n".into())])
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
.unwrap()
.unwrap();
assert_eq!(refreshed.before, original.before);
@@ -1369,7 +1445,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
assert!(repository.load_file(1, 0).is_err());
assert!(
repository
.refresh_working_after(2, 3, &[])
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
@@ -1377,7 +1453,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
git(&root, &["checkout", "--detach", &base]);
assert!(
repository
.refresh_working_after(2, 3, &[])
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
@@ -1387,7 +1463,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
git(&root, &["commit", "-m", "unrelated to tip"]);
assert!(
repository
.refresh_working_after(2, 3, &[])
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
@@ -1400,7 +1476,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
git(&root, &["checkout", "--detach", &tip]);
assert!(
repository
.refresh_working_after(2, 3, &[])
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
@@ -1431,18 +1507,18 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "next"]);
cancelled.store(true, Ordering::Release);
assert!(repository.refresh_working_after(10, 11, &[]).is_err());
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
cancelled.store(false, Ordering::Release);
assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]);
fs::write(root.join("oversized"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
assert!(repository.refresh_working_after(10, 11, &[]).is_err());
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
let original = repository.load_file(10, 0).unwrap();
assert_eq!(original.before, ["frozen before"]);
assert_eq!(original.after, ["frozen after"]);
assert!(repository.load_file(11, 0).is_err());
fs::remove_file(root.join("oversized")).unwrap();
let refreshed = repository
.refresh_working_after(10, 11, &[])
.refresh_checkout(10, 11, true, &[])
.unwrap()
.unwrap();
assert_eq!(refreshed.files.len(), 1);
@@ -1450,20 +1526,63 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
assert_eq!(file.before, ["frozen before"]);
assert_eq!(file.after, ["next checkout"]);
let tip = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
repository
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_working_after(12, 13, &[])
.refresh_checkout(99, 13, false, &[])
.unwrap()
.is_none()
);
let readonly = repository
.refresh_checkout(12, 13, false, &[(root.join("file"), "dirty live\n".into())])
.unwrap()
.unwrap();
assert_eq!(readonly.before, pinned.before);
assert_eq!(readonly.after, pinned.after);
assert!(readonly.editable_paths.is_empty());
assert_eq!(readonly.files.len(), pinned.files.len());
assert_eq!(readonly.files[0].path, pinned.files[0].path);
assert!(repository.load_file(12, 0).is_err());
assert_eq!(
repository.load_file(12, 0).unwrap().after,
repository.load_file(13, 0).unwrap().after,
["next checkout"]
);
assert!(
repository
.refresh_checkout(13, 14, false, &[])
.unwrap()
.is_none()
);
let RevisionTarget::Commit(tip_id) = &tip.target else {
unreachable!()
};
git(&root, &["checkout", "--detach", tip_id]);
let promoted = repository
.refresh_checkout(13, 14, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
fs::write(root.join("file"), "dirty disk\n").unwrap();
assert_eq!(
repository.load_file(14, 0).unwrap().after,
["next checkout"]
);
cancelled.store(true, Ordering::Release);
assert!(repository.refresh_checkout(14, 15, false, &[]).is_err());
cancelled.store(false, Ordering::Release);
let recovered = repository
.refresh_checkout(14, 15, false, &[])
.unwrap()
.unwrap();
assert_eq!(recovered.editable_paths, pinned.editable_paths);
assert_eq!(
repository.load_file(15, 0).unwrap().after,
["next checkout"]
);
}
@@ -1498,7 +1617,7 @@ fn refresh_materialization_failure_restores_prepared_file_sources() {
fs::write(root.join("a-file"), "new checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "descendant"]);
assert!(repository.refresh_working_after(1, 2, &[]).is_err());
assert!(repository.refresh_checkout(1, 2, true, &[]).is_err());
let file = repository.load_file(1, index).unwrap();
assert_eq!(file.before, ["frozen source"]);
assert!(file.after.is_empty());
@@ -1523,7 +1642,7 @@ fn unborn_git_working_after_safely_advances_to_its_first_commit() {
.unwrap();
assert!(
repository
.refresh_working_after(1, 2, &[])
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
@@ -1531,7 +1650,7 @@ fn unborn_git_working_after_safely_advances_to_its_first_commit() {
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "initial"]);
let comparison = repository
.refresh_working_after(1, 2, &[])
.refresh_checkout(1, 2, true, &[])
.unwrap()
.unwrap();
assert_eq!(comparison.files.len(), 1);
@@ -1563,7 +1682,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
let state = disk_state(&root);
assert!(
repository
.refresh_working_after(1, 2, &[])
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
@@ -1574,7 +1693,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
fs::write(root.join("added"), "new file\n").unwrap();
let state = disk_state(&root);
let mut comparison = repository
.refresh_working_after(1, 2, &[(root.join("file"), "unsaved\n".into())])
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
.unwrap()
.unwrap();
for (index, file) in comparison.files.iter_mut().enumerate() {
@@ -1594,21 +1713,21 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
assert_eq!(disk_state(&root), state);
assert!(
repository
.refresh_working_after(2, 3, &[])
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
jj(&root, &["edit", &base]);
assert!(
repository
.refresh_working_after(2, 3, &[])
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
jj(&root, &["new", &base]);
assert!(
repository
.refresh_working_after(2, 3, &[])
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
@@ -1630,7 +1749,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
fs::write(root.join("file"), "new checkout\n").unwrap();
jj(&root, &["new"]);
let refreshed = repository
.refresh_working_after(10, 11, &[])
.refresh_checkout(10, 11, true, &[])
.unwrap()
.unwrap();
assert_eq!(refreshed.files.len(), 1);
@@ -1644,10 +1763,66 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
jj(&root, &["new"]);
assert!(
repository
.refresh_working_after(12, 13, &[])
.refresh_checkout(12, 13, true, &[])
.unwrap()
.is_none()
);
let tip_id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let pinned_tip = revision(&tip_id, RepoKind::Jujutsu);
let pinned = repository
.compare(20, &historical, &pinned_tip, ComparisonMode::Snapshots, &[])
.unwrap();
assert!(pinned.editable_paths.contains(Path::new("file")));
let index = pinned
.files
.iter()
.position(|file| file.path == Path::new("file"))
.unwrap();
assert_eq!(
repository.load_file(20, index).unwrap().after,
["new checkout"]
);
fs::write(root.join("file"), "rewritten checkout\n").unwrap();
jj(&root, &["describe", "-m", "same pinned change"]);
let rewritten = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
assert_ne!(rewritten, tip_id);
assert!(
repository
.refresh_checkout(20, 21, false, &[])
.unwrap()
.is_none()
);
assert_eq!(
repository.load_file(20, index).unwrap().after,
["new checkout"]
);
let same_change = repository
.compare(21, &historical, &pinned_tip, ComparisonMode::Snapshots, &[])
.unwrap();
assert_eq!(same_change.editable_paths, pinned.editable_paths);
jj(&root, &["new"]);
let readonly = repository
.refresh_checkout(21, 22, false, &[(root.join("file"), "dirty live\n".into())])
.unwrap()
.unwrap();
assert!(readonly.editable_paths.is_empty());
assert_eq!(readonly.before, pinned.before);
assert_eq!(readonly.after, pinned.after);
assert_eq!(readonly.files.len(), pinned.files.len());
assert_eq!(
repository.load_file(22, index).unwrap().after,
["new checkout"]
);
jj(&root, &["edit", &rewritten]);
let promoted = repository
.refresh_checkout(22, 23, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
assert_eq!(
repository.load_file(23, index).unwrap().after,
["new checkout"]
);
}
}