feat(git/cmd): make :G diff output navigable

This commit is contained in:
2026-05-07 23:18:17 +02:00
parent 93c9b6500a
commit c543f0a7ba
5 changed files with 291 additions and 48 deletions
+132 -9
View File
@@ -8,13 +8,89 @@ local M = {}
---@class ow.Git.Cmd.SplitHandler ---@class ow.Git.Cmd.SplitHandler
---@field ft string ---@field ft string
---@field needs_rev boolean? ---@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<string, ow.Git.Cmd.SplitHandler> ---@type table<string, ow.Git.Cmd.SplitHandler>
local SPLIT_HANDLERS = { local SPLIT_HANDLERS = {
log = { ft = "git" }, 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[]? ---@type string[]?
local cached_cmds local cached_cmds
@@ -147,20 +223,57 @@ end
---@return integer buf ---@return integer buf
local function place_split(name) local function place_split(name)
local buf = vim.fn.bufnr("\\V" .. name) local buf = vim.fn.bufnr("\\V" .. name)
if buf == -1 or not vim.api.nvim_buf_is_loaded(buf) then if buf == -1 then
buf = util.new_scratch() buf = util.new_scratch({ name = name, bufhidden = "hide" })
pcall(vim.api.nvim_buf_set_name, buf, name)
return buf return buf
end end
local win_id = vim.fn.bufwinid(buf) if not vim.api.nvim_buf_is_loaded(buf) then
if win_id ~= -1 then vim.fn.bufload(buf)
vim.api.nvim_set_current_win(win_id) end
local win = vim.fn.bufwinid(buf)
if win ~= -1 then
vim.api.nvim_set_current_win(win)
else else
util.place_buf(buf, nil) util.place_buf(buf, nil)
end end
return buf return buf
end 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 \\<BS>\\<Esc>"')
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",
"<C-r>",
bypass(vim.cmd.redo),
{ buffer = buf, desc = "Redo" }
)
end
---@param r ow.Git.Repo ---@param r ow.Git.Repo
---@param args string[] ---@param args string[]
---@param conf ow.Git.Cmd.SplitHandler ---@param conf ow.Git.Cmd.SplitHandler
@@ -173,10 +286,10 @@ local function run_in_split(r, args, conf)
if not stdout then if not stdout then
return return
end end
local name = "[git " .. table.concat(args, " ") .. "]" local buf = place_split("[Git " .. table.concat(args, " ") .. "]")
local buf = place_split(name)
repo.bind(buf, r) repo.bind(buf, r)
object.attach_dispatch(buf) object.attach_dispatch(buf)
attach_history_keys(buf)
local state = r:state(buf) --[[@as -nil]] local state = r:state(buf) --[[@as -nil]]
state.sha = nil state.sha = nil
state.parent_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) state.parent_sha = r:rev_parse(user_rev .. "^", true)
end end
end end
if conf.on_state then
conf.on_state(state, r, args)
end
vim.bo[buf].filetype = conf.ft 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)) util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
if first_run then
clear_undo(buf)
state.initialized = true
end
end, end,
}) })
end end
+62 -39
View File
@@ -221,7 +221,7 @@ function M.read_uri(buf)
local state = r:state(buf) --[[@as -nil]] local state = r:state(buf) --[[@as -nil]]
vim.bo[buf].swapfile = false vim.bo[buf].swapfile = false
vim.bo[buf].bufhidden = "hide" vim.bo[buf].bufhidden = "delete"
local rev_sha = r:rev_parse(rev:format(), true) local rev_sha = r:rev_parse(rev:format(), true)
if not rev_sha then if not rev_sha then
@@ -284,42 +284,53 @@ local function refresh(buf, r)
end end
---@param r ow.Git.Repo ---@param r ow.Git.Repo
---@param blob string? ---@param ref string? -- nil = worktree, ":" = index, else commit/sha
---@param path string ---@param path string
---@param sha string ---@param blob string? -- diff section blob hash; if zero, side has no content
---@return integer? ---@return integer?
local function blob_buf(r, blob, path, sha) local function side_buf(r, ref, path, blob)
if is_zero(blob) then if blob and is_zero(blob) then
return nil return nil
end 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 end
---@param r ow.Git.Repo ---@param r ow.Git.Repo
---@param blob string? ---@param ref string?
---@param path string ---@param path string
---@param sha string ---@param blob string?
local function load_blob(r, blob, path, sha) local function load_side(r, ref, path, blob)
local buf = blob_buf(r, blob, path, sha) local buf = side_buf(r, ref, path, blob)
if not buf then if not buf then
util.error("no content for %s at %s", path, sha) util.error("no content for %s", path)
return return
end end
vim.cmd.normal({ "m'", bang = true }) vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(buf) vim.api.nvim_set_current_buf(buf)
end 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 ---@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 if not section.blob_a or not section.blob_b then
util.error("no index line, cannot determine blob SHAs") util.error("no index line, cannot determine blob SHAs")
return return
end end
local parent = s.parent_sha or "0" local left = side_buf(r, left_ref, section.path_a, section.blob_a)
local left = blob_buf(s.repo, section.blob_a, section.path_a, parent) local right = side_buf(r, right_ref, section.path_b, section.blob_b)
local right =
blob_buf(s.repo, section.blob_b, section.path_b, s.sha --[[@as string]])
if left and right then if left and right then
require("git.diff").open(left, right, true) require("git.diff").open(left, right, true)
return return
@@ -359,56 +370,68 @@ end
---@return boolean dispatched ---@return boolean dispatched
function M.open_under_cursor() function M.open_under_cursor()
local s = repo.state() local s = repo.state()
if not s or not s.sha then if not s then
return false return false
end end
local line = vim.api.nvim_get_current_line() local line = vim.api.nvim_get_current_line()
local r = s.repo local r = s.repo
local sha = line:match("^commit (%x+)$") if s.sha and not s.left_ref then
or line:match("^parent (%x+)$") local sha = line:match("^commit (%x+)$")
or line:match("^tree (%x+)$") or line:match("^parent (%x+)$")
or line:match("^object (%x+)$") or line:match("^tree (%x+)$")
if sha then or line:match("^object (%x+)$")
M.open(r, sha, { split = false }) if sha then
return true 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 end
local entry_type, entry_sha, entry_name = local left_ref, right_ref
line:match("^%d+ (%w+) (%x+)\t(.+)$") if s.left_ref then
if entry_sha then left_ref = s.left_ref
local nav_rev = entry_type == "blob" right_ref = s.right_ref
and Revision.new({ base = s.sha, path = entry_name }):format() elseif s.sha then
or entry_sha left_ref = s.parent_sha or "0"
M.open(r, nav_rev, { split = false }) right_ref = s.sha
return true else
return false
end end
local section = diff_section() local section = diff_section()
if not section then if not section then
return false return false
end end
local parent = s.parent_sha or "0"
if line:match("^diff %-%-git ") then if line:match("^diff %-%-git ") then
open_section(s, section) open_section(r, left_ref, right_ref, section)
return true return true
end end
if line:match("^%-%-%- ") then 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 return true
end end
if line:match("^%+%+%+ ") then 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 return true
end end
local prefix = line:sub(1, 1) local prefix = line:sub(1, 1)
if prefix == "+" then 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 return true
elseif prefix == "-" then 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 return true
end end
return false return false
+3
View File
@@ -16,6 +16,9 @@ end
---@field repo ow.Git.Repo ---@field repo ow.Git.Repo
---@field sha string? ---@field sha string?
---@field parent_sha string? ---@field parent_sha string?
---@field left_ref string?
---@field right_ref string?
---@field initialized boolean?
---@field immutable boolean? ---@field immutable boolean?
---@field index_writer boolean? ---@field index_writer boolean?
---@field index_mode string? ---@field index_mode string?
+7
View File
@@ -0,0 +1,7 @@
if exists("b:current_syntax")
finish
endif
runtime! syntax/git.vim
let b:current_syntax = "gitdiff"
+87
View File
@@ -234,3 +234,90 @@ t.test("complete unknown subcommand falls back to tracked paths", function()
local matches = cmd.complete("", "G nonexistent ", 14) local matches = cmd.complete("", "G nonexistent ", 14)
eq_sorted(matches, { "a", "b" }) eq_sorted(matches, { "a", "b" })
end) 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)