From c62e1ebdd5b725764495f2a60d1ab7459a3991d8 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 16 Aug 2026 08:01:12 +0200 Subject: [PATCH] feat(git): add `--rename-threshold` option to `DiffviewOpen` and `DiffviewFileHistory` (#301) --- doc/diffview.txt | 16 ++ lua/diffview/config.lua | 9 + lua/diffview/scene/views/diff/diff_view.lua | 1 + .../tests/functional/git_adapter_spec.lua | 174 ++++++++++++++++++ lua/diffview/vcs/adapter.lua | 1 + lua/diffview/vcs/adapters/git/init.lua | 34 +++- lua/diffview/vcs/adapters/jj/init.lua | 8 + lua/diffview/vcs/utils.lua | 6 + 8 files changed, 247 insertions(+), 2 deletions(-) diff --git a/doc/diffview.txt b/doc/diffview.txt index 5ca5f351..6b17884c 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -201,6 +201,14 @@ COMMANDS *diffview-commands* invocation; pass `--no-panel=false` to force the panel shown. + --rename-threshold={n} + Rename detection similarity, as an integer in [0, 100] + (a trailing `%` is accepted). Overrides + |diffview-config-rename_threshold| for this view. + Invalid values are ignored with a warning. + NOTE: This is Git-specific. For Jujutsu, this option + is ignored with a warning. + *:DiffviewDiffFiles* :DiffviewDiffFiles {file1} {file2} @@ -503,6 +511,14 @@ COMMANDS *diffview-commands* YYYY-mm-dd, YYYY-mm-dd HH:mm:ss, or natural language (for instance "2 weeks 6 hours 12 minutes ago") + --rename-threshold={n} + Rename detection similarity, as an integer in [0, 100] + (a trailing `%` is accepted). Overrides + |diffview-config-rename_threshold| for this view. + Invalid values are ignored with a warning. + NOTE: This is Git-specific. For Jujutsu, this option is + ignored with a warning. + Mercurial Options: ~ --rev={rev} diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 09c432d5..48c23a22 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -795,6 +795,7 @@ M._config = M.defaults ---@field path_args string[] ---@field after? string ---@field before? string +---@field rename_threshold? integer Per-view rename similarity threshold (0-100). Overrides |diffview-config-rename_threshold| for this view. ---@class HgLogOptions ---@field follow? string @@ -842,6 +843,7 @@ M._config = M.defaults ---@field path_args? string[] ---@field after? string ---@field before? string +---@field rename_threshold? integer Per-view rename similarity threshold (0-100). Overrides |diffview-config-rename_threshold| for this view. ---@class HgLogOptions.user ---@field follow? string @@ -889,6 +891,7 @@ M.log_option_defaults = { G = nil, S = nil, path_args = {}, + rename_threshold = nil, }, ---@type HgLogOptions hg = { @@ -1886,4 +1889,10 @@ function M.setup(user_config) end M.actions = actions + +-- Shared value validators. Used internally by `setup()` for the config schema, +-- and re-exported for CLI arg parsing (e.g., `--rename-threshold`) so both +-- surfaces produce the same warn-and-fallback behaviour. +M.validate = validate + return M diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index 2f9f65d3..f570d7af 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -35,6 +35,7 @@ local same_rev = lazy.access(rev_lib, "same_rev") --[[@as fun(a: Rev?, b: Rev?): ---@field show_untracked? boolean ---@field selected_file? string Path to the preferred initially selected file. ---@field selected_row? integer Row to position the cursor on after opening the selected file. +---@field rename_threshold? integer Per-view rename similarity threshold (0-100). Overrides |diffview-config-rename_threshold| for this view. ---@class DiffView : StandardView ---@operator call : DiffView diff --git a/lua/diffview/tests/functional/git_adapter_spec.lua b/lua/diffview/tests/functional/git_adapter_spec.lua index 78310900..f0edcd95 100644 --- a/lua/diffview/tests/functional/git_adapter_spec.lua +++ b/lua/diffview/tests/functional/git_adapter_spec.lua @@ -4,6 +4,7 @@ local GitAdapter = require("diffview.vcs.adapters.git").GitAdapter local GitRev = require("diffview.vcs.adapters.git.rev").GitRev local Job = require("diffview.job").Job local RevType = require("diffview.vcs.rev").RevType +local arg_parser = require("diffview.arg_parser") local test_utils = require("diffview.tests.helpers") local run = test_utils.run @@ -1847,4 +1848,177 @@ describe("diffview.vcs.adapters.git", function() end) ) end) + + describe("diffview_options --rename-threshold", function() + it( + "parses an integer value onto DiffViewOptions", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local argo = arg_parser.parse({ "--rename-threshold=40" }) + local opt = adapter:diffview_options(argo) + assert.is_not_nil(opt) + assert.equals(40, opt.options.rename_threshold) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "accepts a trailing percent sign", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local argo = arg_parser.parse({ "--rename-threshold=30%" }) + local opt = adapter:diffview_options(argo) + assert.is_not_nil(opt) + assert.equals(30, opt.options.rename_threshold) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "leaves DiffViewOptions.rename_threshold nil for invalid values", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + -- Out of range and non-numeric both fall back to nil (with a warning). + for _, bad in ipairs({ "abc", "-5", "150", "12.5" }) do + local argo = arg_parser.parse({ "--rename-threshold=" .. bad }) + local opt = adapter:diffview_options(argo) + assert.is_not_nil(opt) + assert.is_nil(opt.options.rename_threshold) + end + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "leaves DiffViewOptions.rename_threshold nil when the flag is absent", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local argo = arg_parser.parse({}) + local opt = adapter:diffview_options(argo) + assert.is_not_nil(opt) + assert.is_nil(opt.options.rename_threshold) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "leaves DiffViewOptions.rename_threshold nil for a bare or empty value", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + -- Bare `--rename-threshold` (arg_parser records the value as "true" + -- and casts it to boolean) and an explicitly-empty `--rename-threshold=` + -- both distinct from "flag absent"; the parser warns and drops them. + for _, args in ipairs({ { "--rename-threshold" }, { "--rename-threshold=" } }) do + local argo = arg_parser.parse(args) + local opt = adapter:diffview_options(argo) + assert.is_not_nil(opt) + assert.is_nil(opt.options.rename_threshold) + end + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + end) + + describe("file_history_options --rename-threshold", function() + it( + "parses an integer value onto GitLogOptions", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local argo = arg_parser.parse({ "--rename-threshold=40" }) + local log_opt = adapter:file_history_options(nil, { "init.txt" }, argo) + assert.is_not_nil(log_opt) + assert.equals(40, log_opt.rename_threshold) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "leaves GitLogOptions.rename_threshold nil for invalid values", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local argo = arg_parser.parse({ "--rename-threshold=nope" }) + local log_opt = adapter:file_history_options(nil, { "init.txt" }, argo) + assert.is_not_nil(log_opt) + assert.is_nil(log_opt.rename_threshold) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + end) end) diff --git a/lua/diffview/vcs/adapter.lua b/lua/diffview/vcs/adapter.lua index 12d02093..4d3ed681 100644 --- a/lua/diffview/vcs/adapter.lua +++ b/lua/diffview/vcs/adapter.lua @@ -23,6 +23,7 @@ local M = {} ---@field pin_local? boolean # When true, file-history entries are constructed with revs.b = LOCAL so the b-window can pin to the working-tree file. ---@field pinned_path? string # Working-tree path used for the b-side File when `pin_local` is true for a single-file history; preserves the pin across renames in older commits. ---@field pinned_b_file_for? fun(path: string): vcs.File # Resolves the shared, view-owned working-tree File for a given path. Set by `FileHistoryPanel` when `pin_local` is active so adapters can hand the same `vcs.File` instance to every entry's b-side; see `FileHistoryView:get_pinned_b_file`. The returned file outlives entry/log destruction (its layout symbol lives in `Diff2*Pinned.shared_symbols`), so adapters must not destroy it. +---@field rename_threshold? integer # Per-view rename similarity threshold (0-100) forwarded from `DiffViewOptions.rename_threshold`. Overrides the global config for this view's diff calls; adapters that don't support rename detection ignore it. ---@class vcs.adapter.VCSAdapter.Bootstrap ---@field done boolean # Did the bootstrapping diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 24709464..a9f02596 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -28,6 +28,32 @@ local uv = vim.uv local M = {} +---Parse a `--rename-threshold` CLI value. +---Accepts `"40"` or `"40%"` (trailing `%` matches git's own `--find-renames` +---spelling); the value must be an integer in [0, 100]. On invalid input warns +---and returns nil so the caller falls back to the global config. `nil` (flag +---absent) is the only silent return path; bare `--rename-threshold` (boolean +---`true` from `arg_parser`), an empty value (`--rename-threshold=`), and any +---non-integer or out-of-range string all warn via `config.validate.integer`. +---@param raw string[]|string|boolean|nil +---@return integer? +local function parse_rename_threshold_flag(raw) + if raw == nil then + return nil + end + -- `tonumber("40%")` returns nil, so strip a trailing `%` before delegating + -- to `validate.integer`. Non-string inputs (e.g., a boolean from a bare + -- flag) pass through and the validator warns as expected. + local wrap = { v = type(raw) == "string" and (raw:gsub("%%$", "")) or raw } + config.validate.integer(wrap, "v", nil, { + min = 0, + max = 100, + nilable = true, + path = "--rename-threshold", + }) + return wrap.v --[[@as integer? ]] +end + ---@class GitAdapter : VCSAdapter ---@operator call : GitAdapter local GitAdapter = oop.create_class("GitAdapter", VCSAdapter) @@ -575,7 +601,7 @@ function GitAdapter:stream_fh_data(state) "--no-show-signature", "--pretty=format:%x00%n" .. GitAdapter.COMMIT_PRETTY_FMT, (function() - local t = config.get_config().rename_threshold + local t = state.log_options.rename_threshold or config.get_config().rename_threshold return t and ("-M" .. t .. "%") or nil end)(), "--numstat", @@ -877,6 +903,7 @@ function GitAdapter:file_history_options(range, paths, argo) ---@diagnostic disable-next-line: assign-type-mismatch log_options[key] = v end + log_options.rename_threshold = parse_rename_threshold_flag(argo:get_flag("rename-threshold")) if range then paths, rel_paths = {}, {} @@ -1707,6 +1734,7 @@ function GitAdapter:diffview_options(argo) or nil ) --[[@as string? ]], selected_row = tonumber(argo:get_flag("selected-row", { no_empty = true })), + rename_threshold = parse_rename_threshold_flag(argo:get_flag("rename-threshold")), } return { left = left, right = right, options = options } @@ -2260,7 +2288,7 @@ GitAdapter.tracked_files = async.wrap(function(self, left, right, args, kind, op ---@type FileEntry[] local conflicts = {} local log_opt = { label = "GitAdapter:tracked_files()" } - local rename_threshold = config.get_config().rename_threshold + local rename_threshold = opt.rename_threshold or config.get_config().rename_threshold local rename_flag = rename_threshold and ("-M" .. rename_threshold .. "%") or nil local namestat_job = Job({ @@ -2776,6 +2804,7 @@ function GitAdapter:init_completion() end) self.comp.open:put({ "selected-row" }) self.comp.open:put({ "no-panel" }) + self.comp.open:put({ "rename-threshold" }, {}) self.comp.file_history:put({ "base" }, function(_, arg_lead) return utils.vec_join("LOCAL", self:rev_candidates(arg_lead)) @@ -2819,6 +2848,7 @@ function GitAdapter:init_completion() self.comp.file_history:put({ "-S" }, {}) self.comp.file_history:put({ "--after", "--since" }, {}) self.comp.file_history:put({ "--before", "--until" }, {}) + self.comp.file_history:put({ "--rename-threshold" }, {}) end M.GitAdapter = GitAdapter diff --git a/lua/diffview/vcs/adapters/jj/init.lua b/lua/diffview/vcs/adapters/jj/init.lua index 1dba31e1..b59a241e 100644 --- a/lua/diffview/vcs/adapters/jj/init.lua +++ b/lua/diffview/vcs/adapters/jj/init.lua @@ -732,6 +732,10 @@ function JjAdapter:diffview_options(argo) logger:fmt_debug("Parsed revs: left = %s, right = %s", left, right) + if argo:get_flag("rename-threshold") then + utils.warn("The '--rename-threshold' option is not supported for Jujutsu. Ignoring.") + end + local options = { show_untracked = arg_parser.ambiguous_bool( argo:get_flag({ "u", "untracked-files" }, { plain = true }), @@ -833,6 +837,10 @@ function JjAdapter:file_history_options(range, paths, argo) return end + if argo:get_flag("rename-threshold") then + utils.warn("The '--rename-threshold' option is not supported for Jujutsu. Ignoring.") + end + local rel_paths = vim.tbl_map(function(v) return v == "." and "." or pl:relative(v, ".") end, paths) --[[@as string[] ]] diff --git a/lua/diffview/vcs/utils.lua b/lua/diffview/vcs/utils.lua index 7d34a842..4d3bd974 100644 --- a/lua/diffview/vcs/utils.lua +++ b/lua/diffview/vcs/utils.lua @@ -156,6 +156,12 @@ M.diff_file_list = async.wrap(function(adapter, left, right, path_args, dv_opt, local files = FileDict() local rev_args = adapter:rev_to_args(left, right) local errors = {} + + -- Forward the CLI `--rename-threshold` override into the per-view `opt` so + -- `tracked_files` picks it up without widening its signature. + if dv_opt.rename_threshold then + opt.rename_threshold = dv_opt.rename_threshold + end (function() local err, tfiles, tconflicts = await( adapter:tracked_files(left, right, utils.vec_join(rev_args, "--", path_args), "working", opt)