feat: default diffs to branch changes

This commit is contained in:
Valdemar Lorenzen
2026-10-13 10:41:37 +02:00
parent fafec2a6b0
commit 6c254f9b0d
10 changed files with 562 additions and 33 deletions
+126 -1
View File
@@ -7,7 +7,11 @@ use std::{
time::{Duration, Instant},
};
use vamp::{diff::Phase, editor::Input, session::EditorSession};
use vamp::{
diff::{ComparisonMode, Phase},
editor::Input,
session::EditorSession,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
@@ -111,6 +115,10 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
session.handle(Input::Enter);
assert_eq!(session.editor().diff().unwrap().phase, Phase::After);
session.handle(Input::Enter);
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
wait_until(&mut session, |session| {
session
.editor()
@@ -485,3 +493,120 @@ fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
fs::read_to_string(root.join("first.rs")).unwrap()
);
}
#[test]
fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips() {
let fixture = Fixture::new();
let root = fixture.0.join("repo");
fixture.git(&["init", "-q"]);
fs::write(root.join("common"), "common\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "common"]);
let base = fixture.git(&["rev-parse", "HEAD"]);
fs::write(root.join("base-only"), "base branch\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "base only"]);
let before = fixture.git(&["rev-parse", "HEAD"]);
fixture.git(&["checkout", "-qb", "feature", &base]);
fs::write(root.join("feature-only"), "feature branch\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "feature only"]);
let after = fixture.git(&["rev-parse", "HEAD"]);
let config = fixture.0.join("config.rb");
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+b', config.core.diff.mode.toggle\nend\n").unwrap();
let mut session =
EditorSession::open(Some(root.join("common")), vec![], Some(&config)).unwrap();
session.execute("diff");
wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading
});
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::BranchChanges
);
session.handle(Input::Ctrl('b'));
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
session.handle(Input::Ctrl('p'));
type_query(&mut session, "diff.mode.toggle");
session.handle(Input::Enter);
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::BranchChanges
);
for id in [&before, &after] {
type_query(&mut session, id);
session.handle(Input::Down);
session.handle(Input::Enter);
}
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let diff = session.editor().diff().unwrap();
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!(
comparison.before,
format!("merge base of {before} and {after} · {base}")
);
assert_eq!(diff.before.as_ref().unwrap().label, before);
session.execute("diff.mode.toggle");
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let comparison = session
.editor()
.diff()
.unwrap()
.comparison
.as_ref()
.unwrap();
assert_eq!(comparison.before, before);
assert_eq!(comparison.after, after);
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("base-only"), Path::new("feature-only")]
);
// Superseding an in-flight recomparison must not restore the old mode's result.
session.execute("diff.mode.toggle");
session.execute("diff.mode.toggle");
session.execute("diff.mode.toggle");
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let diff = session.editor().diff().unwrap();
assert_eq!(diff.comparison_mode, ComparisonMode::BranchChanges);
assert_eq!(diff.comparison.as_ref().unwrap().files.len(), 1);
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| {
!session.editor().diff().unwrap().loading
});
session.handle(Input::Enter);
let diff = session.editor().diff().unwrap();
assert_eq!(diff.phase, Phase::After);
assert_eq!(diff.comparison_mode, ComparisonMode::Snapshots);
session.execute("diff.mode.toggle");
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
assert_eq!(
session.editor().message(),
"Working checkout requires Snapshots"
);
}
+202 -5
View File
@@ -16,7 +16,7 @@ use std::{
},
};
use vamp::{
diff::{Category, Comparison, FileStatus, RepoKind, Revision, RevisionTarget},
diff::{Category, Comparison, ComparisonMode, FileStatus, RepoKind, Revision, RevisionTarget},
repository::Repository,
};
@@ -134,7 +134,8 @@ fn compare_loaded(
after: &Revision,
buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> {
let mut comparison = repository.compare(1, before, after, buffers)?;
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
@@ -381,7 +382,13 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
);
assert!(
repository
.compare(1, &working(RepoKind::Git), &working(RepoKind::Git), &[])
.compare(
1,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap()
.files
.is_empty()
@@ -574,6 +581,40 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
.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);
}
}
@@ -657,6 +698,15 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
.iter()
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
);
let local = catalog.entries.iter().find(|revision| {
matches!(&revision.target, RevisionTarget::Commit(id) if id.bytes().any(|byte| byte != b'0'))
}).unwrap();
let branch = repository
.compare(60, local, &remote, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(branch.before.ends_with(&"0".repeat(40)));
assert_eq!(branch.files.len(), 1);
assert_eq!(repository.load_file(60, 0).unwrap().after, ["remote tip"]);
let comparison =
compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap();
assert_eq!(
@@ -682,6 +732,14 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
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()))
@@ -747,6 +805,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
1,
&revision("--bad", RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap_err()
@@ -758,6 +817,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
1,
&revision(&"f".repeat(40), RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap_err()
@@ -769,7 +829,13 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
};
assert!(
repository
.compare(1, &loader, &revision(&id, RepoKind::Git), &[])
.compare(
1,
&loader,
&revision(&id, RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap_err()
.contains("history loader")
);
@@ -813,6 +879,7 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
1,
&revision(&baseline, RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap()
@@ -1033,6 +1100,29 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
assert_eq!(ordinary.before, ["old"]);
assert_eq!(ordinary.after, ["left ordinary"]);
assert!(ordinary.notice.is_none());
let branch = repository
.compare(
30,
&revision(&base, RepoKind::Jujutsu),
&revision(&merged, RepoKind::Jujutsu),
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
let index = branch
.files
.iter()
.position(|file| file.path == Path::new("conflict"))
.unwrap();
let conflict = repository.load_file(30, index).unwrap();
assert_eq!(conflict.after, expected);
assert!(
conflict
.notice
.as_deref()
.unwrap()
.contains("conflict projection")
);
let working_comparison = compare_loaded(
&mut repository,
&revision(&merged, RepoKind::Jujutsu),
@@ -1112,7 +1202,7 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
);
assert!(
repository
.compare(1, &base, &base, &buffers)
.compare(1, &base, &base, ComparisonMode::Snapshots, &buffers)
.unwrap()
.files
.is_empty()
@@ -1152,6 +1242,7 @@ fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details()
100,
&revision(&before, RepoKind::Git),
&revision(&after, RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
@@ -1176,6 +1267,7 @@ fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details()
101,
&revision(&after, RepoKind::Git),
&revision(&after, RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
@@ -1198,6 +1290,7 @@ fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
50,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[(root.join("file"), "unsaved after\n".into())],
)
.unwrap();
@@ -1211,3 +1304,107 @@ fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
assert_eq!(again.before, details.before);
assert_eq!(again.after, details.after);
}
#[test]
fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_mutation() {
let fixture = Fixture::new();
let active = fixture.dir("active\nquoted\"\\");
init_git(&active);
fs::write(active.join("common"), "base\n").unwrap();
git(&active, &["add", "."]);
git(&active, &["commit", "-m", "base"]);
let base = git(&active, &["rev-parse", "HEAD"]);
let source = fixture.0.join("source");
git(
&fixture.0,
&["clone", active.to_str().unwrap(), source.to_str().unwrap()],
);
git(&source, &["config", "user.name", "Diff Test"]);
git(&source, &["config", "user.email", "diff@example.invalid"]);
fs::write(source.join("remote-only"), "remote\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "remote"]);
fs::write(active.join("local-only"), "local\n").unwrap();
git(&active, &["add", "."]);
git(&active, &["commit", "-m", "local"]);
let local = revision(&git(&active, &["rev-parse", "HEAD"]), RepoKind::Git);
git(
&active,
&["remote", "add", "origin", source.to_str().unwrap()],
);
let state = disk_state(&active);
let mut repository = open(&active);
let catalog = repository.list_remote("origin").unwrap();
let remote = catalog
.entries
.iter()
.find(|r| matches!(r.target, RevisionTarget::Remote { .. }))
.unwrap();
let branch = repository
.compare(40, &local, remote, ComparisonMode::BranchChanges, &[])
.unwrap();
assert_eq!(branch.files.len(), 1);
assert_eq!(branch.files[0].path, Path::new("remote-only"));
assert!(branch.before.ends_with(&base));
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
let reverse = repository
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
.unwrap();
assert_eq!(reverse.files.len(), 1);
assert_eq!(reverse.files[0].path, Path::new("local-only"));
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
assert!(
repository
.compare(42, &local, &local, ComparisonMode::BranchChanges, &[])
.unwrap()
.files
.is_empty()
);
assert_eq!(disk_state(&active), state);
}
#[test]
fn branch_changes_rejects_missing_and_multiple_merge_bases_without_snapshot_fallback() {
let fixture = Fixture::new();
let root = fixture.dir("graphs");
init_git(&root);
fs::write(root.join("file"), "base\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = git(&root, &["rev-parse", "HEAD"]);
let tree = git(&root, &["rev-parse", "HEAD^{tree}"]);
let left = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "left"]);
let right = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "right"]);
let one = git(
&root,
&["commit-tree", &tree, "-p", &left, "-p", &right, "-m", "one"],
);
let two = git(
&root,
&["commit-tree", &tree, "-p", &right, "-p", &left, "-m", "two"],
);
let unrelated = git(&root, &["commit-tree", &tree, "-m", "unrelated"]);
let state = disk_state(&root);
let mut repository = open(&root);
for (before, after, message) in [
(&base, &unrelated, "no merge base"),
(&one, &two, "multiple merge bases"),
] {
let before = revision(before, RepoKind::Git);
let after = revision(after, RepoKind::Git);
assert!(
repository
.compare(50, &before, &after, ComparisonMode::BranchChanges, &[])
.unwrap_err()
.contains(message)
);
assert!(
repository
.compare(51, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap()
.files
.is_empty()
);
}
assert_eq!(disk_state(&root), state);
}