feat: refresh diffs when selected jj bookmarks move

This commit is contained in:
Valdemar Lorenzen
2026-10-15 15:18:13 +02:00
parent 1a02502488
commit c32d4a7590
8 changed files with 991 additions and 62 deletions
+442 -1
View File
@@ -17,7 +17,7 @@ use std::{
};
use vamp::{
diff::{Category, Comparison, ComparisonMode, FileStatus, RepoKind, Revision, RevisionTarget},
repository::Repository,
repository::{CheckoutRefresh, Repository},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
@@ -128,6 +128,13 @@ fn working(kind: RepoKind) -> Revision {
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,
@@ -549,6 +556,7 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
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"]);
@@ -556,6 +564,7 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
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"]);
@@ -662,6 +671,427 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
}
}
#[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_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));
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!(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, &["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"]);
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();
@@ -823,6 +1253,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
&[(active.join("file"), "dirty live\n".into())],
)
.unwrap()
.map(checkout_comparison)
.unwrap();
assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after);
@@ -1519,6 +1950,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
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);
@@ -1616,6 +2048,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
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();
@@ -1638,6 +2071,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
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);
@@ -1662,6 +2096,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
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();
@@ -1675,6 +2110,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
let recovered = repository
.refresh_checkout(14, 15, false, &[])
.unwrap()
.map(checkout_comparison)
.unwrap();
assert_eq!(recovered.editable_paths, pinned.editable_paths);
assert_eq!(
@@ -1748,6 +2184,7 @@ fn unborn_git_working_after_safely_advances_to_its_first_commit() {
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"]);
@@ -1791,6 +2228,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
let mut comparison = repository
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
.unwrap()
.map(checkout_comparison)
.unwrap();
for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap();
@@ -1847,6 +2285,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
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();
@@ -1900,6 +2339,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
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);
@@ -1913,6 +2353,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
let promoted = repository
.refresh_checkout(22, 23, false, &[])
.unwrap()
.map(checkout_comparison)
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
assert_eq!(