fix: show revision-aware blame in diff buffers
Diff and snapshot sources were excluded before scheduling, and the separate diff renderer never painted attribution. Capture per-side Git provenance, preserve private object stores for historical tabs, and reuse the current-line worker and cache.
This commit is contained in:
+71
-1
@@ -226,6 +226,13 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
|
||||
.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();
|
||||
@@ -255,6 +262,10 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
|
||||
.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
|
||||
@@ -785,6 +796,8 @@ fn jj_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching
|
||||
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!(
|
||||
@@ -944,6 +957,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
|
||||
.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()
|
||||
@@ -952,6 +967,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
|
||||
};
|
||||
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);
|
||||
@@ -1331,6 +1348,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
.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);
|
||||
@@ -2260,6 +2278,8 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
.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();
|
||||
}
|
||||
@@ -2526,13 +2546,14 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
jj(&root, &["new"]);
|
||||
fs::write(root.join("file"), "original disk\n").unwrap();
|
||||
let mut repository = open(&root);
|
||||
compare_loaded(
|
||||
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);
|
||||
@@ -2545,6 +2566,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
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);
|
||||
@@ -2553,6 +2575,8 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
.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();
|
||||
}
|
||||
@@ -2727,12 +2751,16 @@ fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_muta
|
||||
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
|
||||
@@ -2866,3 +2894,45 @@ fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
|
||||
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);
|
||||
}
|
||||
|
||||
+255
-2
@@ -29,6 +29,26 @@ fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
fn disk_state(root: &Path) -> std::collections::BTreeMap<PathBuf, Vec<u8>> {
|
||||
fn visit(root: &Path, path: &Path, state: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>) {
|
||||
for entry in fs::read_dir(path).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let metadata = fs::symlink_metadata(&path).unwrap();
|
||||
if metadata.is_dir() {
|
||||
visit(root, &path, state);
|
||||
} else if metadata.is_file() {
|
||||
state.insert(
|
||||
path.strip_prefix(root).unwrap().to_path_buf(),
|
||||
fs::read(path).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut state = std::collections::BTreeMap::new();
|
||||
visit(root, root, &mut state);
|
||||
state
|
||||
}
|
||||
|
||||
fn fixture() -> (Temporary, PathBuf, String) {
|
||||
let temp = Temporary::new().unwrap();
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
@@ -150,6 +170,239 @@ fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
||||
None
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn historical_git_and_jj_blame_survives_renamed_and_deleted_paths() {
|
||||
for is_jj in [false, true] {
|
||||
let (temp, old_path, original) = fixture();
|
||||
if is_jj {
|
||||
command(&temp.0, "jj", &["git", "init", "--colocate", "."]);
|
||||
command(&temp.0, "jj", &["new", &original]);
|
||||
}
|
||||
fs::write(&old_path, "historical\nsecond\n").unwrap();
|
||||
let selected = if is_jj {
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&["describe", "-m", "Selected historical lines"],
|
||||
);
|
||||
let id = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
||||
);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
id
|
||||
} else {
|
||||
command(&temp.0, "git", &["add", "."]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "Selected historical lines"],
|
||||
);
|
||||
command(&temp.0, "git", &["rev-parse", "HEAD"])
|
||||
};
|
||||
let renamed = temp.0.join("renamed.txt");
|
||||
fs::rename(&old_path, &renamed).unwrap();
|
||||
let renamed_commit = if is_jj {
|
||||
command(&temp.0, "jj", &["describe", "-m", "Rename"]);
|
||||
let id = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
||||
);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
id
|
||||
} else {
|
||||
command(&temp.0, "git", &["add", "-A"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Rename"]);
|
||||
command(&temp.0, "git", &["rev-parse", "HEAD"])
|
||||
};
|
||||
fs::remove_file(&renamed).unwrap();
|
||||
if is_jj {
|
||||
command(&temp.0, "jj", &["describe", "-m", "Delete"]);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
} else {
|
||||
command(&temp.0, "git", &["add", "-A"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Delete"]);
|
||||
}
|
||||
let store = if is_jj {
|
||||
crate::repository::store_for_jj(&temp.0).unwrap()
|
||||
} else {
|
||||
temp.0.join(".git")
|
||||
};
|
||||
let state = disk_state(&temp.0);
|
||||
for (commit, path) in [
|
||||
(
|
||||
selected.clone(),
|
||||
old_path.strip_prefix(&temp.0).unwrap().to_path_buf(),
|
||||
),
|
||||
(renamed_commit, PathBuf::from("renamed.txt")),
|
||||
] {
|
||||
let source = RevisionSource {
|
||||
store: store.clone(),
|
||||
commit,
|
||||
path,
|
||||
keepalive: None,
|
||||
};
|
||||
let mut worker = BlameWorker::start().unwrap();
|
||||
worker
|
||||
.request(
|
||||
1,
|
||||
old_path.clone(),
|
||||
"historical\nsecond\n".into(),
|
||||
Some(source),
|
||||
)
|
||||
.unwrap();
|
||||
let started = Instant::now();
|
||||
let rows = loop {
|
||||
if let Some((id, result)) = worker.poll() {
|
||||
assert_eq!(id, 1);
|
||||
break result.unwrap().unwrap();
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(20),
|
||||
"Historical blame stalled"
|
||||
);
|
||||
thread::yield_now();
|
||||
};
|
||||
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
|
||||
assert_eq!(
|
||||
rows[0].as_ref().unwrap().summary,
|
||||
"Selected historical lines"
|
||||
);
|
||||
assert!(!rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
||||
}
|
||||
assert!(!old_path.exists() && !renamed.exists());
|
||||
assert_eq!(disk_state(&temp.0), state);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privately_fetched_git_and_noncolocated_jj_sources_keep_selected_attribution() {
|
||||
use crate::diff::{Category, ComparisonMode, Revision, RevisionTarget};
|
||||
let (remote, remote_path, original) = fixture();
|
||||
fs::write(&remote_path, "private historical\nsecond\n").unwrap();
|
||||
command(&remote.0, "git", &["add", "."]);
|
||||
command(
|
||||
&remote.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "Private selected"],
|
||||
);
|
||||
let selected = command(&remote.0, "git", &["rev-parse", "HEAD"]);
|
||||
let reference = command(&remote.0, "git", &["symbolic-ref", "HEAD"]);
|
||||
for is_jj in [false, true] {
|
||||
let (active, active_path, _) = fixture();
|
||||
fs::remove_file(&active_path).unwrap();
|
||||
if is_jj {
|
||||
fs::remove_dir_all(active.0.join(".git")).unwrap();
|
||||
command(&active.0, "jj", &["git", "init", "--no-colocate", "."]);
|
||||
command(
|
||||
&active.0,
|
||||
"jj",
|
||||
&["git", "remote", "add", "origin", remote.0.to_str().unwrap()],
|
||||
);
|
||||
} else {
|
||||
command(
|
||||
&active.0,
|
||||
"git",
|
||||
&["remote", "add", "origin", remote.0.to_str().unwrap()],
|
||||
);
|
||||
}
|
||||
let revision = |id: &str| Revision {
|
||||
target: RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: reference.clone(),
|
||||
id: id.into(),
|
||||
},
|
||||
label: id.into(),
|
||||
description: String::new(),
|
||||
category: Category::Commits,
|
||||
};
|
||||
let state = disk_state(&active.0);
|
||||
let mut repository =
|
||||
crate::repository::Repository::open(&active.0, Arc::new(AtomicBool::new(false)))
|
||||
.unwrap();
|
||||
let comparison = repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&original),
|
||||
&revision(&selected),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let baseline = comparison.blame[1].as_ref().unwrap();
|
||||
assert_eq!(baseline.commit, selected);
|
||||
assert!(!baseline.store.starts_with(&active.0));
|
||||
let source = RevisionSource {
|
||||
store: baseline.store.clone(),
|
||||
commit: baseline.commit.clone(),
|
||||
path: remote_path.strip_prefix(&remote.0).unwrap().to_path_buf(),
|
||||
keepalive: baseline.keepalive.clone(),
|
||||
};
|
||||
let store = source.store.clone();
|
||||
drop(comparison);
|
||||
drop(repository);
|
||||
assert!(
|
||||
store.exists(),
|
||||
"Snapshot provenance must retain private fetched objects"
|
||||
);
|
||||
let rows = read_revision(&source, "private historical\nsecond\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
||||
assert!(!rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(disk_state(&active.0), state);
|
||||
drop(source);
|
||||
assert!(
|
||||
!store.exists(),
|
||||
"Last snapshot release must remove private objects"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_sources_reject_unsafe_paths_and_skip_missing_or_nonregular_baselines() {
|
||||
let (temp, path, commit) = fixture();
|
||||
let mut source = RevisionSource {
|
||||
store: temp.0.join(".git"),
|
||||
commit,
|
||||
path: "not-in-baseline".into(),
|
||||
keepalive: None,
|
||||
};
|
||||
assert_eq!(read_revision(&source, "new\n", &|| false).unwrap(), None);
|
||||
for path in ["", "../file", "/outside", ".git/config", ".jj/repo"] {
|
||||
source.path = path.into();
|
||||
assert!(read_revision(&source, "new\n", &|| false).is_err());
|
||||
}
|
||||
std::os::unix::fs::symlink(&path, temp.0.join("link")).unwrap();
|
||||
fs::create_dir(temp.0.join("nested")).unwrap();
|
||||
fs::write(temp.0.join("nested/file"), "nested\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "link", "nested/file"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Link"]);
|
||||
source.commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
source.path = "link".into();
|
||||
assert_eq!(read_revision(&source, "target\n", &|| false).unwrap(), None);
|
||||
source.path = "nested".into();
|
||||
assert_eq!(read_revision(&source, "nested\n", &|| false).unwrap(), None);
|
||||
source.path = "nested/file".into();
|
||||
let nested = read_revision(&source, "nested\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(nested[0].as_ref().unwrap().commit, source.commit);
|
||||
source.path = path.strip_prefix(&temp.0).unwrap().into();
|
||||
assert_eq!(
|
||||
read_revision(&source, "first\n", &|| true).unwrap_err(),
|
||||
"Blame cancelled"
|
||||
);
|
||||
assert_eq!(
|
||||
read_revision(&source, &"x".repeat(LIMIT + 1), &|| false).unwrap_err(),
|
||||
"Blame input exceeds 16 MiB"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
@@ -332,7 +585,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||
let mut worker = BlameWorker::start().unwrap();
|
||||
for id in 0..20 {
|
||||
worker
|
||||
.request(id, path.clone(), "first\nsecond\n".into())
|
||||
.request(id, path.clone(), "first\nsecond\n".into(), None)
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
@@ -351,7 +604,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(20, path, "x".repeat(LIMIT + 1))
|
||||
.request(20, path, "x".repeat(LIMIT + 1), None)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
|
||||
@@ -780,6 +780,7 @@ fn choose_pair(editor: &mut Editor) -> u64 {
|
||||
|
||||
fn comparison() -> Comparison {
|
||||
Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["abcdef123".into(), "working:snapshot".into()],
|
||||
before: "abcdef123".into(),
|
||||
after: "Working checkout".into(),
|
||||
@@ -1774,6 +1775,7 @@ fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() {
|
||||
assert!(editor.apply_diff_result(
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["working:snapshot".into(), "working:snapshot".into()],
|
||||
before: "Working".into(),
|
||||
after: "Working".into(),
|
||||
@@ -1806,6 +1808,7 @@ fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
|
||||
Category::Commits,
|
||||
));
|
||||
state.comparison = Some(Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["base".into(), "working:snapshot".into()],
|
||||
root: PathBuf::new(),
|
||||
before: "base".into(),
|
||||
|
||||
@@ -1879,6 +1879,7 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_
|
||||
assert!(editor.apply_diff_result(
|
||||
comparison_request,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
root: editor.project_root().to_owned(),
|
||||
revisions: ["base".into(), "working-snapshot".into()],
|
||||
before: "base".into(),
|
||||
@@ -3068,6 +3069,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
|
||||
assert!(editor.apply_diff_result(
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
root: root.canonicalize().unwrap(),
|
||||
revisions: ["base".into(), "working-snapshot".into()],
|
||||
before: "base".into(),
|
||||
@@ -3105,6 +3107,7 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
root: root.to_owned(),
|
||||
revisions: ["base".into(), "head".into()],
|
||||
before: "base".into(),
|
||||
@@ -4108,6 +4111,7 @@ fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives(
|
||||
assert!(editor.apply_diff_result(
|
||||
refresh,
|
||||
Ok(Outcome::BookmarkRefresh(Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
revisions: ["base".into(), "topic-snapshot".into()],
|
||||
before: "base".into(),
|
||||
@@ -4229,6 +4233,7 @@ fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_n
|
||||
let loaded_after = target.after.clone();
|
||||
target.loaded = false;
|
||||
let replacement = crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
revisions: ["base".into(), "ahead-snapshot".into()],
|
||||
before: "base".into(),
|
||||
|
||||
@@ -588,6 +588,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["base".into(), "after".into()],
|
||||
root: fixture.0.clone(),
|
||||
before: "base".into(),
|
||||
@@ -3721,6 +3722,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["base".into(), "working:snapshot".into()],
|
||||
root: fixture.0.canonicalize().unwrap(),
|
||||
before: "base".into(),
|
||||
|
||||
+227
-5
@@ -2593,6 +2593,7 @@ fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
|
||||
diff.apply_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: std::path::PathBuf::new(),
|
||||
editable_paths: Default::default(),
|
||||
before: "old revision".into(),
|
||||
@@ -4805,6 +4806,7 @@ fn immutable_diff_draw_path_preserves_document_until_explicit_close() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: std::path::PathBuf::new(),
|
||||
editable_paths: Default::default(),
|
||||
before: "old revision".into(),
|
||||
@@ -5652,6 +5654,7 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
@@ -5745,6 +5748,7 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: ["live.rs".into()].into(),
|
||||
before: "baseline".into(),
|
||||
@@ -6377,6 +6381,7 @@ fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
@@ -6455,6 +6460,218 @@ fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() {
|
||||
assert!(titles.contains("2 [No Name]"), "{titles}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_blame_is_visible_in_focused_diff_buffers() {
|
||||
for unified in [false, true] {
|
||||
for before in [false, true] {
|
||||
let mut editor = readonly_diff_editor(deletion_file());
|
||||
if unified {
|
||||
editor.execute("diff.view.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
if editor.diff_buffer_focus() != Some(if before { Focus::Before } else { Focus::After })
|
||||
{
|
||||
editor.handle(Input::Char('\t'));
|
||||
}
|
||||
let source = editor.blame_source().unwrap();
|
||||
let blame = Arc::new(crate::blame::LineBlame {
|
||||
commit: "0123456789abcdef".into(),
|
||||
author: "Author".into(),
|
||||
summary: "Original".into(),
|
||||
uncommitted: false,
|
||||
});
|
||||
editor.set_line_blame(source.clone(), blame.clone());
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let screen = screen_text(&terminal);
|
||||
assert!(screen.contains("Author · Original (01234567)"), "{screen}");
|
||||
let caret = terminal.get_cursor_position().unwrap();
|
||||
assert!(pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Author"));
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert!(!editor.set_line_blame(source, blame));
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("Author · Original"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_blame_uses_final_wrap_and_survives_a_historical_tab() {
|
||||
for unified in [false, true] {
|
||||
let text = "x".repeat(200);
|
||||
let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", &text, "after"));
|
||||
if unified {
|
||||
editor.execute("diff.view.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.execute("word_wrap.toggle");
|
||||
let source = editor.blame_source().unwrap();
|
||||
let blame = Arc::new(crate::blame::LineBlame {
|
||||
commit: String::new(),
|
||||
author: String::new(),
|
||||
summary: String::new(),
|
||||
uncommitted: true,
|
||||
});
|
||||
editor.set_line_blame(source.clone(), blame.clone());
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 14)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let caret = terminal.get_cursor_position().unwrap();
|
||||
let screen = screen_text(&terminal);
|
||||
assert_eq!(screen.matches("Uncommitted").count(), 1, "{screen}");
|
||||
assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Uncommitted"));
|
||||
let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 14)).unwrap();
|
||||
narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap();
|
||||
assert!(!screen_text(&narrow).contains("Uncommitted"));
|
||||
editor.execute("tab.new");
|
||||
assert!(editor.diff().is_none());
|
||||
if !editor.word_wrap() {
|
||||
editor.execute("word_wrap.toggle");
|
||||
}
|
||||
editor.set_cached_line_blame(&source, blame);
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Uncommitted"));
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::Char('z'));
|
||||
assert_eq!(editor.lines().join("\n"), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_blame_loads_real_historical_and_working_diff_sources() {
|
||||
let directory = Directory::new();
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git")
|
||||
.current_dir(&directory.0)
|
||||
.args(args)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_AUTHOR_NAME", "Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "author@example.test")
|
||||
.env("GIT_COMMITTER_NAME", "Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "author@example.test")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
};
|
||||
git(&["init", "-q", "--template="]);
|
||||
let path = directory.file("file.txt", "original\n");
|
||||
git(&["add", "file.txt"]);
|
||||
git(&["commit", "-qm", "Original"]);
|
||||
let original = git(&["rev-parse", "HEAD"]);
|
||||
directory.file("file.txt", "historical\n");
|
||||
git(&["commit", "-qam", "Historical"]);
|
||||
let historical = git(&["rev-parse", "HEAD"]);
|
||||
git(&["rm", "-q", "file.txt"]);
|
||||
git(&["commit", "-qm", "Deleted"]);
|
||||
let config = directory.file(
|
||||
".git/vamp-config.rb",
|
||||
"Vamp.configure { |c| c.git_gutter = 'none' }",
|
||||
);
|
||||
let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap();
|
||||
let ready = |session: &mut EditorSession, predicate: &dyn Fn(&Editor) -> bool| {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !predicate(session.editor()) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"message={:?}, state={:?}, blame={:?}, text={:?}",
|
||||
session.editor().message(),
|
||||
session.editor().diff().map(|diff| (
|
||||
diff.phase,
|
||||
diff.focus,
|
||||
diff.file,
|
||||
&diff.error
|
||||
)),
|
||||
session.editor().line_blame(),
|
||||
session.editor().lines()
|
||||
);
|
||||
session.poll();
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
};
|
||||
session.execute("diff");
|
||||
ready(&mut session, &|editor| {
|
||||
editor.diff().is_some_and(|diff| diff.catalog.is_some())
|
||||
});
|
||||
for revision in [&original, &historical] {
|
||||
for ch in revision.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
ready(&mut session, &|editor| {
|
||||
editor
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.selected_file_loaded())
|
||||
});
|
||||
session.handle(Input::Char('\t'));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
for expected in ["Original", "Historical"] {
|
||||
ready(&mut session, &|editor| {
|
||||
editor
|
||||
.line_blame()
|
||||
.is_some_and(|label| label.contains(expected))
|
||||
});
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
let screen = screen_text(&terminal);
|
||||
assert!(screen.contains(&format!("Author · {expected}")), "{screen}");
|
||||
assert!(!screen.contains("Uncommitted"), "{screen}");
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
assert!(!path.exists());
|
||||
session.execute("diff.close");
|
||||
directory.file("file.txt", "restored\n");
|
||||
git(&["add", "file.txt"]);
|
||||
git(&["commit", "-qm", "Restored"]);
|
||||
directory.file("file.txt", "unsaved baseline\n");
|
||||
session.editor_mut().refresh_files();
|
||||
session.execute("diff");
|
||||
ready(&mut session, &|editor| {
|
||||
editor.diff().is_some_and(|diff| diff.catalog.is_some())
|
||||
});
|
||||
for ch in git(&["rev-parse", "HEAD"]).chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Enter);
|
||||
ready(&mut session, &|editor| {
|
||||
editor
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.selected_file_loaded())
|
||||
});
|
||||
session.handle(Input::Char('\t'));
|
||||
session.handle(Input::Char('\t'));
|
||||
ready(&mut session, &|editor| {
|
||||
editor.line_blame() == Some("Uncommitted")
|
||||
});
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Uncommitted"));
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "unsaved baseline\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() {
|
||||
let directory = Directory::new();
|
||||
@@ -6545,11 +6762,6 @@ fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() {
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("Uncommitted"));
|
||||
let mut historical = readonly_diff_editor(deletion_file());
|
||||
assert!(historical.blame_source().is_none());
|
||||
historical.execute("tab.new");
|
||||
assert!(historical.diff().is_none());
|
||||
assert!(historical.blame_source().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7782,6 +7994,7 @@ fn deferred_lsp_definition_centers_only_successful_current_diff_results() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
@@ -7880,6 +8093,13 @@ fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: std::array::from_fn(|_| {
|
||||
Some(crate::diff::BlameRevision {
|
||||
store: editor.project_root().join(".git"),
|
||||
commit: "0123456789abcdef0123456789abcdef01234567".into(),
|
||||
keepalive: None,
|
||||
})
|
||||
}),
|
||||
root: editor.project_root().to_path_buf(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
@@ -8732,6 +8952,7 @@ fn diff_semantic_colors_stay_buffer_local_across_focus_changes() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: if writable {
|
||||
[std::path::PathBuf::from("live.rs")].into()
|
||||
@@ -8862,6 +9083,7 @@ fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt(
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: [std::path::PathBuf::from("live.rs")].into(),
|
||||
before: "baseline".into(),
|
||||
|
||||
Reference in New Issue
Block a user