From 554a6326b34fa7d768527bab1341cb1db823a7b5 Mon Sep 17 00:00:00 2001 From: Jarek Rosiek Date: Tue, 6 Oct 2026 20:53:56 +0200 Subject: [PATCH 1/5] fix: load the old content of git renames and detect renames for single revisions difft reports only the new path of a renamed file, so the old content was looked up at the new path and came back empty. With edits, the stale highlights on the empty lines crashed rendering with an out-of-range end_col. The rename map is now built before content lookup and supplies the old path, while the status difft reported is kept so rows are built as a change. The rename map also passed a single revision straight to `git diff --name-status`, which compares it against the working tree. It now resolves the revision the same way as the content diff (`rev^..rev`). --- src/lib.rs | 88 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index aee2b1a..ebfc319 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -339,6 +339,7 @@ fn split_display_path(path: &Path) -> (PathBuf, PathBuf) { fn prepare_file_for_display( file: &mut difftastic::DifftFile, stats: &FileStats, + renames: &HashMap, ) -> (Option<(u32, u32)>, PathBuf, PathBuf, Option) { let (old_path, new_path) = split_display_path(&file.path); let file_stats = stats @@ -355,6 +356,14 @@ fn prepare_file_for_display( None }; + // difft reports only the new path for a git rename, yet diffs it against the + // old file, so load the old content from the rename source. The status is left + // as difft reported it, so the rows are built as a change, not as a creation. + let old_path = match (&moved_from, renames.get(&new_path)) { + (None, Some(renamed_from)) => renamed_from.clone(), + _ => old_path, + }; + (file_stats, old_path, new_path, moved_from) } @@ -408,21 +417,31 @@ fn parse_git_name_status_renames(output: &str) -> HashMap { .collect() } -fn git_rename_map(mode: &DiffMode) -> HashMap { - let mut cmd = Command::new("git"); - cmd.args(["diff", "--name-status", "-M"]); +/// Arguments for `git diff` that select the same sides as the content diff. +fn git_rename_args(mode: &DiffMode) -> Vec { + let mut args: Vec = ["diff", "--name-status", "-M"].map(String::from).into(); match mode { DiffMode::Range(range) => { - cmd.arg(range); + // Resolve like the content diff does: a bare revision means `rev^..rev`, + // not `rev` against the working tree. + let (old_ref, new_ref) = parse_git_range(range); + args.push(format!("{old_ref}..{new_ref}")); } DiffMode::Unstaged => {} DiffMode::Staged => { - cmd.arg("--cached"); + args.push("--cached".to_string()); } } - let output = cmd.output().ok(); + args +} + +fn git_rename_map(mode: &DiffMode) -> HashMap { + let output = Command::new("git") + .args(git_rename_args(mode)) + .output() + .ok(); let Some(output) = output.filter(|o| o.status.success()) else { return HashMap::new(); }; @@ -527,6 +546,13 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { // are repo-root-relative, but jj file show resolves relative to CWD). let vcs_root = if vcs != "git" { jj_root() } else { git_root() }; + // Needed before content lookup: a renamed file's old content lives at its old path. + let renames = if vcs == "git" { + git_rename_map(&mode) + } else { + jj_rename_map(&mode) + }; + // Process files based on mode and VCS let mut display_files: Vec<_> = match (&mode, vcs) { (DiffMode::Range(range), "git") => { @@ -535,7 +561,7 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .into_par_iter() .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = - prepare_file_for_display(&mut file, &stats); + prepare_file_for_display(&mut file, &stats, &renames); let old_lines = into_lines(git_file_content(&old_ref, &old_path)); let new_lines = into_lines(git_file_content(&new_ref, &new_path)); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) @@ -550,7 +576,7 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .into_par_iter() .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = - prepare_file_for_display(&mut file, &stats); + prepare_file_for_display(&mut file, &stats, &renames); let old_lines = into_lines(jj_file_content(root, &old_ref, &old_path)); let new_lines = into_lines(jj_file_content(root, &new_ref, &new_path)); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) @@ -561,7 +587,7 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .into_par_iter() .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = - prepare_file_for_display(&mut file, &stats); + prepare_file_for_display(&mut file, &stats, &renames); let old_lines = into_lines(git_index_content(&old_path)); let new_lines = into_lines(working_tree_content_for_vcs(&new_path, "git")); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) @@ -573,7 +599,7 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .into_par_iter() .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = - prepare_file_for_display(&mut file, &stats); + prepare_file_for_display(&mut file, &stats, &renames); let old_lines = into_lines(jj_file_content(root, "@-", &old_path)); let new_lines = into_lines(working_tree_content_for_vcs(&new_path, "jj")); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) @@ -584,7 +610,7 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .into_par_iter() .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = - prepare_file_for_display(&mut file, &stats); + prepare_file_for_display(&mut file, &stats, &renames); let old_lines = into_lines(git_file_content("HEAD", &old_path)); let new_lines = into_lines(git_index_content(&new_path)); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) @@ -596,7 +622,7 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .into_par_iter() .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = - prepare_file_for_display(&mut file, &stats); + prepare_file_for_display(&mut file, &stats, &renames); let old_lines = into_lines(jj_file_content(root, "@-", &old_path)); let new_lines = into_lines(jj_file_content(root, "@", &new_path)); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) @@ -605,11 +631,6 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { } }; - let renames = if vcs == "git" { - git_rename_map(&mode) - } else { - jj_rename_map(&mode) - }; if !renames.is_empty() { let old_paths: HashSet = renames.values().cloned().collect(); @@ -830,7 +851,7 @@ mod tests { }; let (file_stats, old_path, new_path, moved_from) = - prepare_file_for_display(&mut file, &stats); + prepare_file_for_display(&mut file, &stats, &HashMap::new()); assert_eq!(file_stats, Some((3, 2))); assert_eq!(old_path, PathBuf::from("src/old.rs")); @@ -838,6 +859,37 @@ mod tests { assert_eq!(moved_from, Some(PathBuf::from("src/old.rs"))); } + #[test] + fn test_prepare_file_for_display_takes_old_path_from_rename_map() { + let mut renames = HashMap::new(); + renames.insert(PathBuf::from("src/new.rs"), PathBuf::from("src/old.rs")); + + let mut file = difftastic::DifftFile { + path: PathBuf::from("src/new.rs"), + language: "Rust".to_string(), + status: difftastic::Status::Changed, + aligned_lines: Vec::new(), + chunks: Vec::new(), + }; + + let (_, old_path, new_path, _) = + prepare_file_for_display(&mut file, &HashMap::new(), &renames); + + assert_eq!(old_path, PathBuf::from("src/old.rs")); + assert_eq!(new_path, PathBuf::from("src/new.rs")); + // Rows must be built as a change against the old content, not as a creation. + assert_eq!(file.status, difftastic::Status::Changed); + } + + #[test] + fn test_git_rename_args_resolve_single_revision_like_content_diff() { + let args = git_rename_args(&DiffMode::Range("HEAD".to_string())); + assert_eq!(args.last().map(String::as_str), Some("HEAD^..HEAD")); + + let args = git_rename_args(&DiffMode::Range("a..b".to_string())); + assert_eq!(args.last().map(String::as_str), Some("a..b")); + } + #[test] fn test_parse_jj_summary_rename_simple() { let parsed = parse_jj_summary_rename("R src/old.rs => src/new.rs").unwrap(); From 4531311419ad15200740670b05164239f62a20cf Mon Sep 17 00:00:00 2001 From: Jarek Rosiek Date: Wed, 7 Oct 2026 15:10:43 +0200 Subject: [PATCH 2/5] fix: load the old content of git renames and detect renames for single revisions Explain in the rename map why a single revision is resolved as `rev^..rev`: the map decides where a renamed file's old content is read from, so it must compare the same two sides as the content diff. --- src/lib.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ebfc319..a1deea1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -423,8 +423,11 @@ fn git_rename_args(mode: &DiffMode) -> Vec { match mode { DiffMode::Range(range) => { - // Resolve like the content diff does: a bare revision means `rev^..rev`, - // not `rev` against the working tree. + // The rename map decides where a renamed file's old content is read from, + // so it must compare the same two sides as the content diff. Given a + // bare revision, `git diff` compares it with the working tree: it would + // miss the renames made in that revision and report unrelated + // working-tree renames. Resolve it like the content diff, as `rev^..rev`. let (old_ref, new_ref) = parse_git_range(range); args.push(format!("{old_ref}..{new_ref}")); } From 91a22205f8862db1ae12b4af30c30f09a9fb06fb Mon Sep 17 00:00:00 2001 From: Jarek Rosiek Date: Tue, 6 Oct 2026 20:55:12 +0200 Subject: [PATCH 3/5] fix: show line counts for git renames and the content of pure renames git --numstat keys a rename as `old => new` (or `dir/{old => new}`), so the lookup by the new path missed and renamed files showed +0/-0. The numstat parser now keys renames by their new path. A pure rename comes from difft as unchanged with no line alignment, so it rendered as an empty view. Identical content without alignment is now aligned line by line and shown, with no hunks. --- src/lib.rs | 14 ++++++++++++-- src/processor.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a1deea1..a33620f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -143,8 +143,10 @@ fn parse_git_numstat(output: &str) -> FileStats { let mut parts = line.split('\t'); let add = parts.next()?.parse().ok()?; let del = parts.next()?.parse().ok()?; - let path = parts.next()?; - Some((PathBuf::from(path), (add, del))) + // A rename is reported as `old => new` (or `dir/{old => new}`); key it by + // the new path, which is the path the diff records carry. + let (_, new_path) = split_display_path(Path::new(parts.next()?)); + Some((new_path, (add, del))) }) .collect() } @@ -749,6 +751,14 @@ mod tests { assert_eq!(stats.get(Path::new("README.md")), Some(&(0, 2))); } + #[test] + fn test_parse_git_numstat_keys_renames_by_new_path() { + let stats = parse_git_numstat("1\t1\told.rs => sub/new.rs\n2\t0\tsrc/{a => b}.rs\n"); + + assert_eq!(stats.get(Path::new("sub/new.rs")), Some(&(1, 1))); + assert_eq!(stats.get(Path::new("src/b.rs")), Some(&(2, 0))); + } + #[test] fn test_parse_git_numstat_skips_binary_files() { let stats = parse_git_numstat("-\t-\timage.png\n1\t0\ttext.txt\n"); diff --git a/src/processor.rs b/src/processor.rs index c9d1574..b6c40a7 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -318,11 +318,19 @@ fn extract_changes( /// properly aligned rows. Computes highlights based on the change /// information in the chunks. fn process_changed( - file: DifftFile, + mut file: DifftFile, old_lines: &[String], new_lines: &[String], stats: Option<(u32, u32)>, ) -> DisplayFile { + // Identical content (a pure rename) comes without alignment; align it line by + // line so the file is shown instead of an empty view. + if file.aligned_lines.is_empty() && !old_lines.is_empty() && old_lines == new_lines { + file.aligned_lines = (0..old_lines.len() as u32) + .map(|i| (Some(i), Some(i))) + .collect(); + } + let (lhs_changes, rhs_changes) = extract_changes(&file.chunks); let num_rows = file.aligned_lines.len(); @@ -592,6 +600,25 @@ mod tests { assert!(!result.rows[1].right.highlights.is_empty()); } + #[test] + fn identical_content_without_alignment_shows_all_lines() { + let file = DifftFile { + path: "renamed.rs".into(), + language: "Rust".into(), + status: Status::Unchanged, + aligned_lines: Vec::new(), + chunks: Vec::new(), + }; + let lines: Vec = vec!["a".into(), "b".into()]; + let result = process_file(file, lines.clone(), lines, None); + + assert_eq!(result.rows.len(), 2); + assert_eq!(result.rows[1].left.content, "b"); + assert_eq!(result.rows[1].right.content, "b"); + assert!(!result.rows[1].left.is_filler); + assert!(result.hunk_starts.is_empty()); + } + #[test] fn addition_with_filler_line() { let file = DifftFile { From f9ff882aaba472913a6be2345c014e0fc1d76165 Mon Sep 17 00:00:00 2001 From: Jarek Rosiek Date: Wed, 7 Oct 2026 14:45:16 +0200 Subject: [PATCH 4/5] fix: diff jj renames against their old content jj hands difft the two revisions as whole directory trees, so a renamed file arrives as a deletion of the old path and a creation of the new one, and its contents are never compared. The view showed the renamed file as entirely added, with an empty old side. For each rename that `jj diff --summary` reports, the old and new contents are now diffed by difft directly, as a pair of files named like the new path so the language is detected the same way. When that fails, the entry is kept as jj reported it. git diffs are unaffected. --- src/lib.rs | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 179 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a33620f..5b72f82 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,7 @@ use rayon::prelude::*; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; mod difftastic; mod processor; @@ -261,6 +262,61 @@ fn run_jj_diff_uncommitted() -> Result, String> { .map_err(|e| format!("Failed to parse difftastic JSON: {e}")) } +/// Runs difftastic on two contents of one file and returns its entry for `path`. +/// +/// The contents are written to two temporary files named like `path`, so difft +/// detects the same language as for the original file. +fn run_difft_on_contents(path: &Path, old: &str, new: &str) -> Option { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("difftastic-nvim-{}-{id}", std::process::id())); + let name = path.file_name()?; + let old_file = dir.join("old").join(name); + let new_file = dir.join("new").join(name); + + let run = || -> Option { + std::fs::create_dir_all(old_file.parent()?).ok()?; + std::fs::create_dir_all(new_file.parent()?).ok()?; + std::fs::write(&old_file, old).ok()?; + std::fs::write(&new_file, new).ok()?; + let output = Command::new("difft") + .arg(&old_file) + .arg(&new_file) + .env("DFT_DISPLAY", "json") + .env("DFT_UNSTABLE", "yes") + .output() + .ok() + .filter(|o| o.status.success())?; + let mut file = difftastic::parse(&String::from_utf8_lossy(&output.stdout)) + .ok()? + .into_iter() + .next()?; + file.path = path.to_path_buf(); + Some(file) + }; + let file = run(); + let _ = std::fs::remove_dir_all(&dir); + file +} + +/// jj hands difft whole trees, so a renamed file arrives as a creation of its new +/// path that was never compared with the old one. Diffs the two contents instead; +/// keeps the entry as is when that fails. +fn pair_jj_rename( + file: difftastic::DifftFile, + renames: &HashMap, + old: Option<&str>, + new: Option<&str>, +) -> difftastic::DifftFile { + if file.status != difftastic::Status::Created || !renames.contains_key(&file.path) { + return file; + } + let (Some(old), Some(new)) = (old, new) else { + return file; + }; + run_difft_on_contents(&file.path, old, new).unwrap_or(file) +} + /// Runs difftastic via git and parses the JSON output. /// Executes `git diff` with difftastic as the external diff tool. /// @@ -358,9 +414,10 @@ fn prepare_file_for_display( None }; - // difft reports only the new path for a git rename, yet diffs it against the - // old file, so load the old content from the rename source. The status is left - // as difft reported it, so the rows are built as a change, not as a creation. + // A renamed file is reported under its new path only, yet diffed against the old + // file (by git, or by `pair_jj_rename` for jj), so load the old content from the + // rename source. The status is left as the diff reported it, so the rows are + // built as a change, not as a creation. let old_path = match (&moved_from, renames.get(&new_path)) { (None, Some(renamed_from)) => renamed_from.clone(), _ => old_path, @@ -582,8 +639,10 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = prepare_file_for_display(&mut file, &stats, &renames); - let old_lines = into_lines(jj_file_content(root, &old_ref, &old_path)); - let new_lines = into_lines(jj_file_content(root, &new_ref, &new_path)); + let old = jj_file_content(root, &old_ref, &old_path); + let new = jj_file_content(root, &new_ref, &new_path); + let file = pair_jj_rename(file, &renames, old.as_deref(), new.as_deref()); + let (old_lines, new_lines) = (into_lines(old), into_lines(new)); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) }) .collect() @@ -605,8 +664,10 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = prepare_file_for_display(&mut file, &stats, &renames); - let old_lines = into_lines(jj_file_content(root, "@-", &old_path)); - let new_lines = into_lines(working_tree_content_for_vcs(&new_path, "jj")); + let old = jj_file_content(root, "@-", &old_path); + let new = working_tree_content_for_vcs(&new_path, "jj"); + let file = pair_jj_rename(file, &renames, old.as_deref(), new.as_deref()); + let (old_lines, new_lines) = (into_lines(old), into_lines(new)); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) }) .collect() @@ -628,8 +689,10 @@ fn run_diff_impl(lua: &Lua, mode: DiffMode, vcs: &str) -> LuaResult { .map(|mut file| { let (file_stats, old_path, new_path, moved_from) = prepare_file_for_display(&mut file, &stats, &renames); - let old_lines = into_lines(jj_file_content(root, "@-", &old_path)); - let new_lines = into_lines(jj_file_content(root, "@", &new_path)); + let old = jj_file_content(root, "@-", &old_path); + let new = jj_file_content(root, "@", &new_path); + let file = pair_jj_rename(file, &renames, old.as_deref(), new.as_deref()); + let (old_lines, new_lines) = (into_lines(old), into_lines(new)); process_prepared_file(file, old_lines, new_lines, file_stats, moved_from) }) .collect() @@ -943,4 +1006,111 @@ mod tests { ); assert!(!renames.contains_key(Path::new("c.txt"))); } + + #[test] + fn test_parse_jj_summary_renames_brace_forms() { + let renames = parse_jj_summary_renames( + "R {a.txt => b.txt}\nR dir/{old.txt => new.txt}\nR {mv.txt => sub/mv.txt}\n", + ); + assert_eq!( + renames.get(Path::new("b.txt")), + Some(&PathBuf::from("a.txt")) + ); + assert_eq!( + renames.get(Path::new("dir/new.txt")), + Some(&PathBuf::from("dir/old.txt")) + ); + assert_eq!( + renames.get(Path::new("sub/mv.txt")), + Some(&PathBuf::from("mv.txt")) + ); + } + + #[test] + fn test_parse_jj_summary_renames_ignores_copies() { + let renames = parse_jj_summary_renames("C {orig.txt => copy.txt}\nM orig.txt\n"); + assert!(renames.is_empty()); + } + + fn created(path: &str) -> difftastic::DifftFile { + difftastic::DifftFile { + path: PathBuf::from(path), + language: "Text".to_string(), + status: difftastic::Status::Created, + aligned_lines: Vec::new(), + chunks: Vec::new(), + } + } + + fn has_difft() -> bool { + Command::new("difft").arg("--version").output().is_ok() + } + + #[test] + fn test_pair_jj_rename_keeps_entries_that_are_not_renames() { + let mut renames = HashMap::new(); + renames.insert(PathBuf::from("b.txt"), PathBuf::from("a.txt")); + + let added = created("other.txt"); + assert_eq!( + pair_jj_rename(added.clone(), &renames, None, Some("x\n")), + added + ); + + let mut changed = created("b.txt"); + changed.status = difftastic::Status::Changed; + assert_eq!( + pair_jj_rename(changed.clone(), &renames, Some("x\n"), Some("y\n")), + changed + ); + } + + #[test] + fn test_pair_jj_rename_keeps_entry_without_old_content() { + let mut renames = HashMap::new(); + renames.insert(PathBuf::from("b.txt"), PathBuf::from("a.txt")); + + let file = created("b.txt"); + assert_eq!( + pair_jj_rename(file.clone(), &renames, None, Some("x\n")), + file + ); + } + + #[test] + fn test_pair_jj_rename_diffs_edited_rename() { + if !has_difft() { + return; + } + let mut renames = HashMap::new(); + renames.insert(PathBuf::from("src/new.rs"), PathBuf::from("src/old.rs")); + + let file = pair_jj_rename( + created("src/new.rs"), + &renames, + Some("fn a() {}\nfn b() {}\n"), + Some("fn a() {}\nfn c() {}\n"), + ); + + assert_eq!(file.path, PathBuf::from("src/new.rs")); + assert_eq!(file.status, difftastic::Status::Changed); + assert_eq!(file.language, "Rust"); + assert!(!file.chunks.is_empty()); + assert!(!file.aligned_lines.is_empty()); + } + + #[test] + fn test_pair_jj_rename_reports_pure_rename_as_unchanged() { + if !has_difft() { + return; + } + let mut renames = HashMap::new(); + renames.insert(PathBuf::from("b.txt"), PathBuf::from("a.txt")); + + let file = pair_jj_rename(created("b.txt"), &renames, Some("x\ny\n"), Some("x\ny\n")); + + assert_eq!(file.path, PathBuf::from("b.txt")); + assert_eq!(file.status, difftastic::Status::Unchanged); + assert!(file.chunks.is_empty()); + } } From f56e67eff0f97d30c2932ec0eed1324906407739 Mon Sep 17 00:00:00 2001 From: Jarek Rosiek Date: Wed, 7 Oct 2026 14:45:16 +0200 Subject: [PATCH 5/5] chore: add end-to-end tests for renamed files The tests build real git and jj repositories (jj both colocated with git and not) and check the native library's output for renames: single revisions, ranges, staged and working-copy changes, moves within and across directories, several renames at once, and that unrelated deletions and additions or copies are not paired. Each suite is skipped when the native library, difft or the VCS binary is missing. The test init now adds the plugin to the runtime path by its absolute path. With a relative path, the library loader linked the built library through a broken relative symlink and silently fell back to a downloaded library. --- tests/minimal_init.lua | 2 +- tests/rename_spec.lua | 424 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 tests/rename_spec.lua diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index 93e603d..38e0ea7 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -1,6 +1,6 @@ -- Minimal init for running tests -- Add plugin to runtime path -local plugin_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") +local plugin_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") vim.opt.rtp:prepend(plugin_root) vim.opt.swapfile = false diff --git a/tests/rename_spec.lua b/tests/rename_spec.lua new file mode 100644 index 0000000..d3af040 --- /dev/null +++ b/tests/rename_spec.lua @@ -0,0 +1,424 @@ +--- End-to-end tests for renamed files through the native library, against real +--- scratch repositories. Each suite is skipped when the library, difft or the VCS +--- binary is missing. + +local binary = require("difftastic-nvim.binary") + +local lib_ok, lib = pcall(binary.get) +local has_difft = vim.fn.executable("difft") == 1 + +--- Lines of one side of a file, without filler rows. difft may align one empty +--- line past the end of the file; it is dropped. +local function side(file, which) + local lines = {} + for _, row in ipairs(file.rows) do + if not row[which].is_filler then + table.insert(lines, row[which].content) + end + end + if lines[#lines] == "" then + table.remove(lines) + end + return lines +end + +--- Files of a diff result keyed by path. +local function by_path(result) + local files = {} + for _, file in ipairs(result.files) do + files[file.path] = file + end + return files +end + +local function count(result) + return #result.files +end + +local BODY = { "one", "two", "three", "four", "five", "six" } +local EDITED = { "one", "two", "THREE", "four", "five", "six" } + +--- Writes lines to a repo-relative path, creating parent directories. +local function write(repo, path, lines) + local full = repo .. "/" .. path + vim.fn.mkdir(vim.fn.fnamemodify(full, ":h"), "p") + vim.fn.writefile(lines, full) +end + +local function run(cmd, cwd) + local result = vim.system(cmd, { text = true, cwd = cwd }):wait() + assert(result.code == 0, table.concat(cmd, " ") .. ": " .. (result.stderr or "")) + return vim.trim(result.stdout) +end + +--- Shared expectations for a rename of `old` to `new`. +local function assert_renamed(result, old, new, old_lines, new_lines) + local files = by_path(result) + local file = files[new] + assert.is_not_nil(file, "renamed file missing: " .. new) + assert.is_nil(files[old], "old path still listed: " .. old) + assert.are.equal(old, file.moved_from) + assert.are.same(old_lines, side(file, "left")) + assert.are.same(new_lines, side(file, "right")) +end + +local function assert_pure_rename(result, old, new) + assert_renamed(result, old, new, BODY, BODY) + local file = by_path(result)[new] + assert.are.equal(0, file.additions) + assert.are.equal(0, file.deletions) + assert.are.same({}, file.hunk_starts) +end + +local function assert_edited_rename(result, old, new, with_stats) + assert_renamed(result, old, new, BODY, EDITED) + local file = by_path(result)[new] + assert.are.equal(1, #file.hunk_starts) + if with_stats then + assert.are.equal(1, file.additions) + assert.are.equal(1, file.deletions) + end +end + +describe("git renames", function() + if not (lib_ok and has_difft and vim.fn.executable("git") == 1) then + it("is skipped without the native library, difft or git", function() end) + return + end + + local repo, original_cwd + + local function git(args) + local cmd = { "git", "-c", "user.name=t", "-c", "user.email=t@t" } + vim.list_extend(cmd, args) + return run(cmd, repo) + end + + local function commit(message) + git({ "add", "-A" }) + git({ "commit", "-q", "-m", message }) + end + + before_each(function() + original_cwd = vim.fn.getcwd() + repo = vim.fn.resolve(vim.fn.tempname()) + vim.fn.mkdir(repo, "p") + git({ "init", "-q" }) + write(repo, "a.txt", BODY) + write(repo, "src/old.txt", BODY) + write(repo, "keep.txt", { "keep" }) + commit("initial") + vim.fn.chdir(repo) + end) + + after_each(function() + vim.fn.chdir(original_cwd) + vim.fn.delete(repo, "rf") + end) + + it("shows a pure rename in a single commit", function() + git({ "mv", "a.txt", "b.txt" }) + commit("rename") + + local result = lib.run_diff("HEAD", "git") + + assert.are.equal(1, count(result)) + assert_pure_rename(result, "a.txt", "b.txt") + end) + + it("shows an edited rename in a single commit", function() + git({ "mv", "a.txt", "b.txt" }) + write(repo, "b.txt", EDITED) + commit("rename and edit") + + local result = lib.run_diff("HEAD", "git") + + assert.are.equal(1, count(result)) + assert_edited_rename(result, "a.txt", "b.txt", true) + end) + + it("shows a rename into another directory", function() + vim.fn.mkdir(repo .. "/dir", "p") + git({ "mv", "a.txt", "dir/moved.txt" }) + write(repo, "dir/moved.txt", EDITED) + commit("move") + + local result = lib.run_diff("HEAD", "git") + + assert_edited_rename(result, "a.txt", "dir/moved.txt", true) + end) + + it("shows a rename within a directory", function() + git({ "mv", "src/old.txt", "src/new.txt" }) + write(repo, "src/new.txt", EDITED) + commit("rename in dir") + + local result = lib.run_diff("HEAD", "git") + + assert_edited_rename(result, "src/old.txt", "src/new.txt", true) + end) + + it("shows a rename inside a range", function() + write(repo, "keep.txt", { "kept" }) + commit("unrelated") + git({ "mv", "a.txt", "b.txt" }) + write(repo, "b.txt", EDITED) + commit("rename") + + local result = lib.run_diff("HEAD~2..HEAD", "git") + + assert.are.equal(2, count(result)) + assert_edited_rename(result, "a.txt", "b.txt", true) + end) + + it("ignores a rename the working tree adds on top of the commit", function() + write(repo, "keep.txt", { "kept" }) + commit("unrelated") + git({ "mv", "a.txt", "b.txt" }) + + local result = lib.run_diff("HEAD", "git") + + assert.are.equal(1, count(result)) + assert.are.equal("keep.txt", result.files[1].path) + assert.is_nil(result.files[1].moved_from) + end) + + it("shows a staged rename", function() + git({ "mv", "a.txt", "b.txt" }) + write(repo, "b.txt", EDITED) + git({ "add", "b.txt" }) + + local result = lib.run_diff_staged("git") + + assert.are.equal(1, count(result)) + assert_edited_rename(result, "a.txt", "b.txt", true) + end) + + it("does not pair a deletion with an unrelated addition", function() + vim.fn.delete(repo .. "/a.txt") + write(repo, "other.txt", { "something", "else", "entirely" }) + commit("delete and add") + + local result = lib.run_diff("HEAD", "git") + local files = by_path(result) + + assert.are.equal(2, count(result)) + assert.are.equal("deleted", files["a.txt"].status) + assert.are.equal("created", files["other.txt"].status) + assert.is_nil(files["other.txt"].moved_from) + assert.are.same(BODY, side(files["a.txt"], "left")) + end) +end) + +for _, colocate in ipairs({ true, false }) do + local kind = colocate and "colocated" or "non-colocated" + + describe("jj renames (" .. kind .. ")", function() + if not (lib_ok and has_difft and vim.fn.executable("jj") == 1) then + it("is skipped without the native library, difft or jj", function() end) + return + end + + local repo, original_cwd, saved_env + + local function jj(args) + local cmd = { "jj" } + vim.list_extend(cmd, args) + return run(cmd, repo) + end + + local function commit(message) + jj({ "commit", "-m", message }) + end + + local function move(from, to) + vim.fn.mkdir(vim.fn.fnamemodify(repo .. "/" .. to, ":h"), "p") + assert.are.equal(0, vim.fn.rename(repo .. "/" .. from, repo .. "/" .. to)) + end + + --- Line counts come from git, so only a colocated repo has them. + local function assert_stats(file, additions, deletions) + if colocate then + assert.are.equal(additions, file.additions) + assert.are.equal(deletions, file.deletions) + end + end + + before_each(function() + saved_env = { JJ_USER = vim.env.JJ_USER, JJ_EMAIL = vim.env.JJ_EMAIL } + vim.env.JJ_USER = "t" + vim.env.JJ_EMAIL = "t@t" + original_cwd = vim.fn.getcwd() + repo = vim.fn.resolve(vim.fn.tempname()) + vim.fn.mkdir(repo, "p") + jj(colocate and { "git", "init", "--colocate" } or { "git", "init" }) + write(repo, "a.txt", BODY) + write(repo, "src/old.txt", BODY) + write(repo, "keep.txt", { "keep" }) + commit("initial") + vim.fn.chdir(repo) + end) + + after_each(function() + vim.fn.chdir(original_cwd) + vim.fn.delete(repo, "rf") + vim.env.JJ_USER = saved_env.JJ_USER + vim.env.JJ_EMAIL = saved_env.JJ_EMAIL + end) + + it("shows a pure rename in a single revision", function() + move("a.txt", "b.txt") + commit("rename") + + local result = lib.run_diff("@-", "jj") + + assert.are.equal(1, count(result)) + assert_pure_rename(result, "a.txt", "b.txt") + end) + + it("shows an edited rename in a single revision", function() + move("a.txt", "b.txt") + write(repo, "b.txt", EDITED) + commit("rename and edit") + + local result = lib.run_diff("@-", "jj") + + assert.are.equal(1, count(result)) + assert_edited_rename(result, "a.txt", "b.txt") + assert_stats(by_path(result)["b.txt"], 1, 1) + end) + + it("highlights only the edited line of a renamed file", function() + move("a.txt", "b.txt") + write(repo, "b.txt", EDITED) + commit("rename and edit") + + local file = by_path(lib.run_diff("@-", "jj"))["b.txt"] + local highlighted = {} + for _, row in ipairs(file.rows) do + if #row.left.highlights > 0 or #row.right.highlights > 0 then + table.insert(highlighted, row.right.content) + end + end + + assert.are.same({ "THREE" }, highlighted) + end) + + it("detects the language from the new file name", function() + write(repo, "lib.rs", { "fn a() {}", "fn b() {}" }) + commit("add rust file") + move("lib.rs", "main.rs") + write(repo, "main.rs", { "fn a() {}", "fn c() {}" }) + commit("rename rust file") + + local file = by_path(lib.run_diff("@-", "jj"))["main.rs"] + + assert.are.equal("Rust", file.language) + assert.are.equal("lib.rs", file.moved_from) + assert.are.same({ "fn a() {}", "fn b() {}" }, side(file, "left")) + assert.are.equal(1, #file.hunk_starts) + end) + + it("shows a rename into another directory", function() + move("a.txt", "dir/moved.txt") + write(repo, "dir/moved.txt", EDITED) + commit("move") + + local result = lib.run_diff("@-", "jj") + + assert.are.equal(1, count(result)) + assert_edited_rename(result, "a.txt", "dir/moved.txt") + assert_stats(by_path(result)["dir/moved.txt"], 1, 1) + end) + + it("shows a rename within a directory", function() + move("src/old.txt", "src/new.txt") + write(repo, "src/new.txt", EDITED) + commit("rename in dir") + + local result = lib.run_diff("@-", "jj") + + assert.are.equal(1, count(result)) + assert_edited_rename(result, "src/old.txt", "src/new.txt") + assert_stats(by_path(result)["src/new.txt"], 1, 1) + end) + + it("shows several renames in one revision", function() + move("a.txt", "b.txt") + move("src/old.txt", "src/new.txt") + write(repo, "src/new.txt", EDITED) + commit("two renames") + + local result = lib.run_diff("@-", "jj") + + assert.are.equal(2, count(result)) + assert_pure_rename(result, "a.txt", "b.txt") + assert_edited_rename(result, "src/old.txt", "src/new.txt") + end) + + it("shows a rename inside a range", function() + local base = jj({ "log", "-r", "@-", "--no-graph", "-T", "commit_id" }) + write(repo, "keep.txt", { "kept" }) + commit("unrelated") + move("a.txt", "b.txt") + write(repo, "b.txt", EDITED) + commit("rename") + + local result = lib.run_diff(base .. "..@-", "jj") + + assert.are.equal(2, count(result)) + assert_edited_rename(result, "a.txt", "b.txt") + assert_stats(by_path(result)["b.txt"], 1, 1) + end) + + it("shows a rename in the working copy", function() + move("a.txt", "b.txt") + write(repo, "b.txt", EDITED) + + local result = lib.run_diff_unstaged("jj") + + assert.are.equal(1, count(result)) + assert_edited_rename(result, "a.txt", "b.txt") + end) + + it("shows a working-copy rename for the staged fallback", function() + move("a.txt", "b.txt") + + local result = lib.run_diff_staged("jj") + + assert.are.equal(1, count(result)) + assert_pure_rename(result, "a.txt", "b.txt") + end) + + it("does not pair a deletion with an unrelated addition", function() + vim.fn.delete(repo .. "/a.txt") + write(repo, "other.txt", { "something", "else", "entirely" }) + commit("delete and add") + + local result = lib.run_diff("@-", "jj") + local files = by_path(result) + + assert.are.equal(2, count(result)) + assert.are.equal("deleted", files["a.txt"].status) + assert.are.equal("created", files["other.txt"].status) + assert.is_nil(files["other.txt"].moved_from) + assert.are.same(BODY, side(files["a.txt"], "left")) + end) + + it("keeps a copied file as an addition next to its source", function() + vim.fn.writefile(BODY, repo .. "/copy.txt") + write(repo, "a.txt", EDITED) + commit("copy and edit source") + + local result = lib.run_diff("@-", "jj") + local files = by_path(result) + + assert.are.equal(2, count(result)) + assert.are.equal("created", files["copy.txt"].status) + assert.is_nil(files["copy.txt"].moved_from) + assert.are.same(BODY, side(files["copy.txt"], "right")) + assert.are.same(BODY, side(files["a.txt"], "left")) + assert.are.same(EDITED, side(files["a.txt"], "right")) + end) + end) +end