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
+2 -2
View File
@@ -548,7 +548,8 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
let comparison = diff.comparison.as_ref().unwrap();
assert_eq!(comparison.files.len(), 1);
assert_eq!(comparison.files[0].path, Path::new("feature-only"));
assert_eq!(comparison.files[0].after, ["feature branch"]);
assert_eq!(session.editor().context().text, "feature branch\n");
assert!(diff.after_editable());
assert_eq!(
comparison.before,
format!("merge base of {before} and {after} · {base}")
@@ -590,7 +591,6 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
assert_eq!(diff.before.as_ref().unwrap().label, before);
assert_eq!(diff.after.as_ref().unwrap().label, after);
session.execute("diff.close");
assert_eq!(session.editor().path(), Some(root.join("common").as_path()));
session.execute("diff");
wait_until(&mut session, |session| {
+1
View File
@@ -558,6 +558,7 @@ fn diff_repository(fixture: &Fixture, historical: bool) -> (String, Option<Strin
repository(fixture, &["add", "."]);
repository(fixture, &["commit", "-qm", "after"]);
let after = repository(fixture, &["rev-parse", "HEAD"]);
repository(fixture, &["checkout", "--detach", &before]);
fs::write(&fixture.source, "disk is not the historical source\n").unwrap();
fs::write(&fixture.target, "disk is not the historical target\n").unwrap();
after
+2 -1
View File
@@ -275,6 +275,7 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
fs::write(&source, &after_text).unwrap();
fixture.git(&["commit", "-qam", "after"]);
let after = fixture.git(&["rev-parse", "HEAD"]);
fixture.git(&["checkout", "--detach", &before]);
lines[19] = "working x x tail".into();
let working_text = lines.join("\n") + "\n";
fs::write(&source, &working_text).unwrap();
@@ -386,5 +387,5 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
assert_eq!(session.editor().cursor(), original_cursor);
assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&source).unwrap(), working_text);
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), after);
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), before);
}
+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"]
);
}
}