diff --git a/src/lib.rs b/src/lib.rs index aee2b1a..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; @@ -143,8 +144,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() } @@ -259,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. /// @@ -339,6 +397,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 +414,15 @@ fn prepare_file_for_display( None }; + // 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, + }; + (file_stats, old_path, new_path, moved_from) } @@ -408,21 +476,34 @@ 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); + // 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}")); } 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 +608,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 +623,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,9 +638,11 @@ 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); - 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)); + prepare_file_for_display(&mut file, &stats, &renames); + 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() @@ -561,7 +651,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,9 +663,11 @@ 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); - let old_lines = into_lines(jj_file_content(root, "@-", &old_path)); - let new_lines = into_lines(working_tree_content_for_vcs(&new_path, "jj")); + prepare_file_for_display(&mut file, &stats, &renames); + 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() @@ -584,7 +676,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,20 +688,17 @@ 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); - let old_lines = into_lines(jj_file_content(root, "@-", &old_path)); - let new_lines = into_lines(jj_file_content(root, "@", &new_path)); + prepare_file_for_display(&mut file, &stats, &renames); + 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() } }; - 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(); @@ -725,6 +814,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"); @@ -830,7 +927,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 +935,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(); @@ -878,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()); + } } 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 { 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