From c543f0a7ba3cb1d7fbe528a78bf39b24e137c552 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Thu, 7 May 2026 23:18:17 +0200 Subject: [PATCH] feat(git/cmd): make :G diff output navigable --- lua/git/cmd.lua | 141 +++++++++++++++++++++++++++++++++++++++--- lua/git/object.lua | 101 ++++++++++++++++++------------ lua/git/repo.lua | 3 + syntax/gitdiff.vim | 7 +++ test/git/cmd_test.lua | 87 ++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 48 deletions(-) create mode 100644 syntax/gitdiff.vim diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index a2c1da5..4fc9caa 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -8,13 +8,89 @@ local M = {} ---@class ow.Git.Cmd.SplitHandler ---@field ft string ---@field needs_rev boolean? +---@field on_state? fun(state: ow.Git.Repo.BufState, r: ow.Git.Repo, args: string[]) + +---@param r ow.Git.Repo +---@param args string[] -- diff args including leading "diff" +---@return string left_ref +---@return string? right_ref -- nil means worktree +local function compute_diff_refs(r, args) + local cached = false + local positional = {} ---@type string[] + local saw_separator = false + for i = 2, #args do + local a = args[i] + if saw_separator then + break + elseif a == "--" then + saw_separator = true + elseif a == "--cached" or a == "--staged" then + cached = true + elseif a:sub(1, 1) ~= "-" then + table.insert(positional, a) + end + end + + local function defaults() + if cached then + return "HEAD", ":" + end + return ":", nil + end + + if #positional == 0 then + return defaults() + end + + local first = positional[1] --[[@as string]] + if #positional == 1 then + local lhs, rhs = first:match("^(.-)%.%.%.(.+)$") + if lhs then + return (lhs ~= "" and lhs or "HEAD"), rhs + end + lhs, rhs = first:match("^(.-)%.%.(.+)$") + if lhs then + return (lhs ~= "" and lhs or "HEAD"), + (rhs ~= "" and rhs or "HEAD") + end + if r:rev_parse(first, true) then + if cached then + return first, ":" + end + return first, nil + end + return defaults() + end + + local second = positional[2] --[[@as string]] + local first_ok = r:rev_parse(first, true) ~= nil + if first_ok and r:rev_parse(second, true) then + return first, second + end + if first_ok then + if cached then + return first, ":" + end + return first, nil + end + return defaults() +end ---@type table local SPLIT_HANDLERS = { log = { ft = "git" }, - diff = { ft = "git" }, + diff = { + ft = "gitdiff", + on_state = function(state, r, args) + local left, right = compute_diff_refs(r, args) + state.left_ref = left + state.right_ref = right + end, + }, } +M._compute_diff_refs = compute_diff_refs + ---@type string[]? local cached_cmds @@ -147,20 +223,57 @@ end ---@return integer buf local function place_split(name) local buf = vim.fn.bufnr("\\V" .. name) - if buf == -1 or not vim.api.nvim_buf_is_loaded(buf) then - buf = util.new_scratch() - pcall(vim.api.nvim_buf_set_name, buf, name) + if buf == -1 then + buf = util.new_scratch({ name = name, bufhidden = "hide" }) return buf end - local win_id = vim.fn.bufwinid(buf) - if win_id ~= -1 then - vim.api.nvim_set_current_win(win_id) + if not vim.api.nvim_buf_is_loaded(buf) then + vim.fn.bufload(buf) + end + local win = vim.fn.bufwinid(buf) + if win ~= -1 then + vim.api.nvim_set_current_win(win) else util.place_buf(buf, nil) end return buf end +---@param buf integer +local function clear_undo(buf) + local saved = vim.bo[buf].undolevels + vim.bo[buf].undolevels = -1 + vim.bo[buf].modifiable = true + vim.api.nvim_buf_call(buf, function() + vim.cmd('silent! exe "normal! a \\\\"') + end) + vim.bo[buf].modifiable = false + vim.bo[buf].undolevels = saved +end + +---@param buf integer +local function attach_history_keys(buf) + local function bypass(fn) + return function() + vim.bo[buf].modifiable = true + pcall(fn) + vim.bo[buf].modifiable = false + end + end + vim.keymap.set( + "n", + "u", + bypass(vim.cmd.undo), + { buffer = buf, desc = "Undo" } + ) + vim.keymap.set( + "n", + "", + bypass(vim.cmd.redo), + { buffer = buf, desc = "Redo" } + ) +end + ---@param r ow.Git.Repo ---@param args string[] ---@param conf ow.Git.Cmd.SplitHandler @@ -173,10 +286,10 @@ local function run_in_split(r, args, conf) if not stdout then return end - local name = "[git " .. table.concat(args, " ") .. "]" - local buf = place_split(name) + local buf = place_split("[Git " .. table.concat(args, " ") .. "]") repo.bind(buf, r) object.attach_dispatch(buf) + attach_history_keys(buf) local state = r:state(buf) --[[@as -nil]] state.sha = nil state.parent_sha = nil @@ -188,8 +301,18 @@ local function run_in_split(r, args, conf) state.parent_sha = r:rev_parse(user_rev .. "^", true) end end + if conf.on_state then + conf.on_state(state, r, args) + end vim.bo[buf].filetype = conf.ft + -- Force a new undo block so each rerun is its own undo step. + vim.bo[buf].undolevels = vim.bo[buf].undolevels + local first_run = not state.initialized util.set_buf_lines(buf, 0, -1, util.split_lines(stdout)) + if first_run then + clear_undo(buf) + state.initialized = true + end end, }) end diff --git a/lua/git/object.lua b/lua/git/object.lua index 7271afe..c2255d5 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -221,7 +221,7 @@ function M.read_uri(buf) local state = r:state(buf) --[[@as -nil]] vim.bo[buf].swapfile = false - vim.bo[buf].bufhidden = "hide" + vim.bo[buf].bufhidden = "delete" local rev_sha = r:rev_parse(rev:format(), true) if not rev_sha then @@ -284,42 +284,53 @@ local function refresh(buf, r) end ---@param r ow.Git.Repo ----@param blob string? +---@param ref string? -- nil = worktree, ":" = index, else commit/sha ---@param path string ----@param sha string +---@param blob string? -- diff section blob hash; if zero, side has no content ---@return integer? -local function blob_buf(r, blob, path, sha) - if is_zero(blob) then +local function side_buf(r, ref, path, blob) + if blob and is_zero(blob) then return nil end - return M.buf_for(r, Revision.new({ base = sha, path = path })) + if ref == nil then + local p = vim.fs.joinpath(r.worktree, path) + if not vim.uv.fs_stat(p) then + return nil + end + local buf = vim.fn.bufadd(p) + vim.fn.bufload(buf) + return buf + end + local rev = ref == ":" and Revision.new({ stage = 0, path = path }) + or Revision.new({ base = ref, path = path }) + return M.buf_for(r, rev) end ---@param r ow.Git.Repo ----@param blob string? +---@param ref string? ---@param path string ----@param sha string -local function load_blob(r, blob, path, sha) - local buf = blob_buf(r, blob, path, sha) +---@param blob string? +local function load_side(r, ref, path, blob) + local buf = side_buf(r, ref, path, blob) if not buf then - util.error("no content for %s at %s", path, sha) + util.error("no content for %s", path) return end vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_set_current_buf(buf) end ----@param s ow.Git.Repo.BufState +---@param r ow.Git.Repo +---@param left_ref string? +---@param right_ref string? ---@param section ow.Git.DiffSection -local function open_section(s, section) +local function open_section(r, left_ref, right_ref, section) if not section.blob_a or not section.blob_b then util.error("no index line, cannot determine blob SHAs") return end - local parent = s.parent_sha or "0" - local left = blob_buf(s.repo, section.blob_a, section.path_a, parent) - local right = - blob_buf(s.repo, section.blob_b, section.path_b, s.sha --[[@as string]]) + local left = side_buf(r, left_ref, section.path_a, section.blob_a) + local right = side_buf(r, right_ref, section.path_b, section.blob_b) if left and right then require("git.diff").open(left, right, true) return @@ -359,56 +370,68 @@ end ---@return boolean dispatched function M.open_under_cursor() local s = repo.state() - if not s or not s.sha then + if not s then return false end local line = vim.api.nvim_get_current_line() local r = s.repo - local sha = line:match("^commit (%x+)$") - or line:match("^parent (%x+)$") - or line:match("^tree (%x+)$") - or line:match("^object (%x+)$") - if sha then - M.open(r, sha, { split = false }) - return true + if s.sha and not s.left_ref then + local sha = line:match("^commit (%x+)$") + or line:match("^parent (%x+)$") + or line:match("^tree (%x+)$") + or line:match("^object (%x+)$") + if sha then + M.open(r, sha, { split = false }) + return true + end + + local entry_type, entry_sha, entry_name = + line:match("^%d+ (%w+) (%x+)\t(.+)$") + if entry_sha then + local nav_rev = entry_type == "blob" + and Revision.new({ base = s.sha, path = entry_name }):format() + or entry_sha + M.open(r, nav_rev, { split = false }) + return true + end end - local entry_type, entry_sha, entry_name = - line:match("^%d+ (%w+) (%x+)\t(.+)$") - if entry_sha then - local nav_rev = entry_type == "blob" - and Revision.new({ base = s.sha, path = entry_name }):format() - or entry_sha - M.open(r, nav_rev, { split = false }) - return true + local left_ref, right_ref + if s.left_ref then + left_ref = s.left_ref + right_ref = s.right_ref + elseif s.sha then + left_ref = s.parent_sha or "0" + right_ref = s.sha + else + return false end local section = diff_section() if not section then return false end - local parent = s.parent_sha or "0" if line:match("^diff %-%-git ") then - open_section(s, section) + open_section(r, left_ref, right_ref, section) return true end if line:match("^%-%-%- ") then - load_blob(r, section.blob_a, section.path_a, parent) + load_side(r, left_ref, section.path_a, section.blob_a) return true end if line:match("^%+%+%+ ") then - load_blob(r, section.blob_b, section.path_b, s.sha --[[@as string]]) + load_side(r, right_ref, section.path_b, section.blob_b) return true end local prefix = line:sub(1, 1) if prefix == "+" then - load_blob(r, section.blob_b, section.path_b, s.sha --[[@as string]]) + load_side(r, right_ref, section.path_b, section.blob_b) return true elseif prefix == "-" then - load_blob(r, section.blob_a, section.path_a, parent) + load_side(r, left_ref, section.path_a, section.blob_a) return true end return false diff --git a/lua/git/repo.lua b/lua/git/repo.lua index 8afacee..7b3adfd 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -16,6 +16,9 @@ end ---@field repo ow.Git.Repo ---@field sha string? ---@field parent_sha string? +---@field left_ref string? +---@field right_ref string? +---@field initialized boolean? ---@field immutable boolean? ---@field index_writer boolean? ---@field index_mode string? diff --git a/syntax/gitdiff.vim b/syntax/gitdiff.vim new file mode 100644 index 0000000..a23f9f3 --- /dev/null +++ b/syntax/gitdiff.vim @@ -0,0 +1,7 @@ +if exists("b:current_syntax") + finish +endif + +runtime! syntax/git.vim + +let b:current_syntax = "gitdiff" diff --git a/test/git/cmd_test.lua b/test/git/cmd_test.lua index 28efd6e..e0dc665 100644 --- a/test/git/cmd_test.lua +++ b/test/git/cmd_test.lua @@ -234,3 +234,90 @@ t.test("complete unknown subcommand falls back to tracked paths", function() local matches = cmd.complete("", "G nonexistent ", 14) eq_sorted(matches, { "a", "b" }) end) + +t.test("compute_diff_refs default is index vs worktree", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local left, right = cmd._compute_diff_refs(r, { "diff" }) + t.eq(left, ":") + t.eq(right, nil) +end) + +t.test("compute_diff_refs --cached is HEAD vs index", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local left, right = cmd._compute_diff_refs(r, { "diff", "--cached" }) + t.eq(left, "HEAD") + t.eq(right, ":") +end) + +t.test("compute_diff_refs --staged is HEAD vs index", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local left, right = cmd._compute_diff_refs(r, { "diff", "--staged" }) + t.eq(left, "HEAD") + t.eq(right, ":") +end) + +t.test("compute_diff_refs single rev is rev vs worktree", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD" }) + t.eq(left, "HEAD") + t.eq(right, nil) +end) + +t.test("compute_diff_refs single rev with --cached is rev vs index", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local left, right = + cmd._compute_diff_refs(r, { "diff", "--cached", "HEAD" }) + t.eq(left, "HEAD") + t.eq(right, ":") +end) + +t.test("compute_diff_refs two revs", function() + local dir = make_repo({ a = "x" }) + git(dir, "commit", "--allow-empty", "-m", "second") + local r = assert(require("git.repo").resolve(dir)) + local left, right = + cmd._compute_diff_refs(r, { "diff", "HEAD~1", "HEAD" }) + t.eq(left, "HEAD~1") + t.eq(right, "HEAD") +end) + +t.test("compute_diff_refs double-dot range", function() + local dir = make_repo({ a = "x" }) + git(dir, "commit", "--allow-empty", "-m", "second") + local r = assert(require("git.repo").resolve(dir)) + local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD~1..HEAD" }) + t.eq(left, "HEAD~1") + t.eq(right, "HEAD") +end) + +t.test("compute_diff_refs triple-dot range", function() + local dir = make_repo({ a = "x" }) + git(dir, "commit", "--allow-empty", "-m", "second") + local r = assert(require("git.repo").resolve(dir)) + local left, right = + cmd._compute_diff_refs(r, { "diff", "HEAD~1...HEAD" }) + t.eq(left, "HEAD~1") + t.eq(right, "HEAD") +end) + +t.test("compute_diff_refs path-only falls back to defaults", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local left, right = cmd._compute_diff_refs(r, { "diff", "a" }) + t.eq(left, ":") + t.eq(right, nil) +end) + +t.test("compute_diff_refs ignores args after --", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local left, right = + cmd._compute_diff_refs(r, { "diff", "--", "HEAD" }) + t.eq(left, ":") + t.eq(right, nil) +end)