refactor(git): unify diff/object dispatch, codify naming, add :Gdiffsplit

This commit is contained in:
2026-04-29 09:47:51 +02:00
parent 44f9503960
commit 5c5da7a854
10 changed files with 587 additions and 443 deletions
+173 -145
View File
@@ -3,129 +3,6 @@ local util = require("git.util")
local M = {}
---@param buf integer
---@param worktree string
---@param path string
local function attach_index_writer(buf, worktree, path)
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf,
callback = function()
local body = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n"
local hash_stdout = util.exec(
{ "git", "hash-object", "-w", "--stdin" },
{ cwd = worktree, stdin = body }
)
if not hash_stdout then
return
end
local sha = vim.trim(hash_stdout)
local mode = vim.b[buf].git_index_mode
if not mode then
mode = "100644"
local ls = util.exec(
{ "git", "ls-files", "-s", "--", path },
{ cwd = worktree, silent = true }
)
if ls then
local m = ls:match("^(%d+)")
if m then
mode = m
end
end
vim.b[buf].git_index_mode = mode
end
-- Use the 3-arg form (mode sha path) instead of the comma form
-- (mode,sha,path), which doesn't survive paths containing a
-- comma.
if
not util.exec({
"git",
"update-index",
"--cacheinfo",
mode,
sha,
path,
}, { cwd = worktree })
then
return
end
vim.bo[buf].modified = false
end,
})
end
---Return a buffer holding the content addressed by a git revspec. The
---URI is `git://<revspec>` and BufReadCmd loads via `git cat-file -p`.
---@param worktree string
---@param revspec string any revspec git understands (e.g. `HEAD:foo`, `:foo`, `:1:foo`, `<sha>`, `<sha>:foo`)
---@return integer
function M.git_show_buf(worktree, revspec)
local name = "git://" .. revspec
local buf = vim.fn.bufadd(name)
vim.b[buf].git_worktree = worktree
vim.fn.bufload(buf)
return buf
end
---BufReadCmd handler for `git://<revspec>` URIs. Loads content via
---`git cat-file -p <revspec>`. Worktree comes from `vim.b[buf]
---.git_worktree` if set, else from cwd. Index entries (revspec form
---`:<path>` for stage 0) are made writable via `attach_index_writer`,
---so `:w` updates the index. Other revspecs are read-only.
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local revspec = name:match("^git://(.+)$")
if not revspec then
return
end
local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd())
if not worktree then
util.error("git BufReadCmd %s: cannot resolve worktree", name)
return
end
vim.b[buf].git_worktree = worktree
vim.bo[buf].swapfile = false
vim.bo[buf].bufhidden = "wipe"
local stdout = util.exec(
{ "git", "cat-file", "-p", revspec },
{ cwd = worktree }
)
if stdout then
vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout))
end
-- Stage-0 index entries (`:<path>` with no further `:`) are
-- editable; `:w` rewrites the index entry via `attach_index_writer`.
-- Anything else (HEAD:, <sha>:, :1:, :2:, :3:, bare object refs)
-- is read-only.
local index_path = revspec:match("^:([^:]+)$")
if index_path then
vim.bo[buf].buftype = "acwrite"
-- Re-running BufReadCmd (e.g. on `:edit`) would otherwise stack
-- another BufWriteCmd on the same buffer, so each `:w` runs
-- hash-object + update-index N times.
if not vim.b[buf].git_index_writer then
attach_index_writer(buf, worktree, index_path)
vim.b[buf].git_index_writer = true
end
else
vim.bo[buf].buftype = "nofile"
vim.bo[buf].modifiable = false
end
vim.bo[buf].modified = false
-- BufReadCmd suppresses the normal BufReadPost dispatch, so filetype
-- detection and modeline parsing don't run unless we fire it ourselves.
vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf })
end
---@class ow.Git.EmptyBufOpts
---@field name string?
---@field bufhidden ("hide"|"wipe")? defaults to "wipe"
@@ -155,21 +32,12 @@ function M.empty_buf(opts)
return buf
end
---@param abs_path string
---@return integer
function M.load_file_buf(abs_path)
local buf = vim.fn.bufadd(abs_path)
vim.fn.bufload(buf)
return buf
end
---Name a scratch buffer with a `git://...` URI and apply the filetype
---inferred from the inner path segment. The `nvim_buf_set_name` call is
---wrapped in pcall because a buffer with that name may already exist
---Name a buffer and re-run filetype detection from the (re-)set name.
---Wrapped in `pcall` because a buffer with that name may already exist
---(E95).
---@param buf integer
---@param name string
function M.set_buf_name_and_filetype(buf, name)
local function set_buf_name_and_filetype(buf, name)
pcall(vim.api.nvim_buf_set_name, buf, name)
local ft = vim.filetype.match({ buf = buf })
if ft then
@@ -186,20 +54,170 @@ end
---joining the tabpage's diff group and corrupting its render.
---@param win integer
---@param enabled boolean
function M.set_diff(win, enabled)
local function set_diff(win, enabled)
vim.api.nvim_win_call(win, function()
vim.cmd(enabled and "diffthis" or "diffoff")
end)
end
---Render two buffers as a diff pair. The right buffer takes the current
---window; the left opens in a leftabove split (vertical or horizontal
---per `vertical`). Both windows enter Vim's diff mode via `:diffsplit`'s
---built-in setup. Drops a `'` jumplist mark before reassigning the
---current window.
---@param left integer
---@param right integer
---@param vertical boolean
function M.open(left, right, vertical)
-- Read the name first: if `left` is the current window's buffer
-- and has `bufhidden=wipe` (a freshly-loaded `git://` URI), the
-- `nvim_set_current_buf(right)` below wipes it, and a later name
-- lookup would fail. `:diffsplit` re-bufadds + reloads from the
-- name, so the wipe-then-recreate sequence is fine.
local left_name = vim.api.nvim_buf_get_name(left)
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(right)
local prefix = vertical and "leftabove vert " or "leftabove "
vim.cmd(prefix .. "diffsplit " .. vim.fn.fnameescape(left_name))
end
---Repoint two existing diff windows at a new pair of buffers.
---Toggles diff mode off around the swap so Vim tears down the old diff
---group and re-establishes a fresh one. `nvim_win_set_buf` swaps the
---buffer pointer without invalidating cached diff state, and
---`:diffupdate` alone doesn't reliably force a recompute when no buffer
---contents have actually changed. Sides with `name` set get renamed +
---filetype-refreshed (used to relabel a fresh `empty_buf` placeholder
---as `[absent] <abs>`, or to re-run ft detection on a `git://` buffer).
---@param left_win integer
---@param right_win integer
---@param pair ow.Git.DiffPair
function M.update_pair(left_win, right_win, pair)
set_diff(left_win, false)
set_diff(right_win, false)
vim.api.nvim_win_set_buf(left_win, pair.left.buf)
vim.api.nvim_win_set_buf(right_win, pair.right.buf)
for _, side in ipairs({ pair.left, pair.right }) do
if side.name then
set_buf_name_and_filetype(side.buf, side.name)
end
end
set_diff(left_win, true)
set_diff(right_win, true)
end
---Open two buffers as a diff. `a_left` decides which one goes in the
---leftabove slot (where `M.open` parks the cursor). Caller picks per
---its own rule (writable-on-left for routine flows, anchor-on-left
---when neither side is writable, etc).
---@param buf_a integer
---@param buf_b integer
---@param a_left boolean
---@param vertical boolean
local function place_pair(buf_a, buf_b, a_left, vertical)
if a_left then
M.open(buf_a, buf_b, vertical)
else
M.open(buf_b, buf_a, vertical)
end
end
---Dispatch for `M.split` when the current buffer is a `git://<revspec>`
---URI. Placement is "writable on the left" via `place_pair`.
---
---gd/gh: pair cur with the worktree file at the URI's path.
---
---gD/gH: pair cur with the next layer toward HEAD —
--- * stage 0 -> `HEAD:<p>`
--- * stage 2 (ours) <-> stage 3 (theirs)
--- * stage 1 (base) -> bail; ambiguous (suggest `:Gdiffsplit <ref>`)
--- * any other ref -> `:0:<p>`
---
---A `<ref>` containing `:` (from `:Gdiffsplit`) short-circuits all the
---above and pairs cur with that revspec literally. This is the escape
---hatch the merge-base warning points at.
---@param opts ow.Git.SplitOpts
---@param cur_buf integer
---@param cur_revspec string
local function uri_split(opts, cur_buf, cur_revspec)
local worktree = vim.b[cur_buf].git_worktree
or select(2, repo.resolve_cwd())
if not worktree then
util.warning("git URI buffer has no worktree")
return
end
local cur = util.parse_revspec(cur_revspec)
if not cur.path then
util.warning("git URI has no path; cannot diff against worktree")
return
end
-- `git.object` is lazy-required to break the load-time cycle.
-- It requires `git.diff` itself for `empty_buf`.
local object = require("git.object")
local cur_writable = cur.stage == 0
if opts.revspec ~= "" and opts.revspec:find(":", 1, true) then
if not repo.object_exists(worktree, opts.revspec) then
util.warning("invalid revspec: %s", opts.revspec)
return
end
place_pair(
cur_buf,
object.buf_for(worktree, opts.revspec),
cur_writable,
opts.vertical
)
return
end
if opts.revspec == "" then
local worktree_buf = vim.fn.bufadd(vim.fs.joinpath(worktree, cur.path))
vim.fn.bufload(worktree_buf)
place_pair(cur_buf, worktree_buf, cur_writable, opts.vertical)
return
end
if cur.stage == 1 then
util.warning("gD on merge base is ambiguous; use :Gdiffsplit <ref>")
return
end
local other_revspec
if cur.stage == 2 then
other_revspec = ":3:" .. cur.path
elseif cur.stage == 3 then
other_revspec = ":2:" .. cur.path
elseif cur.stage == 0 then
other_revspec = "HEAD:" .. cur.path
else
other_revspec = ":0:" .. cur.path
end
if not repo.object_exists(worktree, other_revspec) then
util.warning("invalid revspec: %s", other_revspec)
return
end
place_pair(
cur_buf,
object.buf_for(worktree, other_revspec),
cur.stage ~= nil,
opts.vertical
)
end
---@class ow.Git.SplitOpts
---@field ref string '' for index, 'HEAD' for HEAD
---@field revspec string '' for the smart-default routing (index vs worktree); a plain ref like `'HEAD'` to compare `<ref>:<rel>` against the current path; or a full revspec containing `:` (e.g. `':2:foo'`, `'HEAD~1:other.lua'`) used as-is.
---@field vertical boolean
---@param opts ow.Git.SplitOpts
function M.split(opts)
local cur_buf = vim.api.nvim_get_current_buf()
local cur_path = vim.api.nvim_buf_get_name(cur_buf)
local cur_revspec = cur_path:match("^git://(.+)$")
if cur_revspec then
return uri_split(opts, cur_buf, cur_revspec)
end
if cur_path == "" then
util.warning("no file in current buffer")
return
@@ -219,16 +237,26 @@ function M.split(opts)
return
end
-- Stage 0 (index) is `:<path>`; named refs are `<ref>:<path>`.
local revspec = opts.ref == "" and (":" .. rel) or (opts.ref .. ":" .. rel)
local uri = "git://" .. revspec
-- Stash the worktree on the buffer so the BufReadCmd handler doesn't
-- fall back to cwd resolution (wrong when cwd != worktree).
local buf = vim.fn.bufadd(uri)
-- A `<revspec>` containing `:` is treated as a full revspec;
-- otherwise the worktree-relative path is appended (the common
-- keymap form).
local revspec
if opts.revspec == "" then
revspec = ":0:" .. rel
elseif opts.revspec:find(":", 1, true) then
revspec = opts.revspec
else
revspec = opts.revspec .. ":" .. rel
end
if not repo.object_exists(worktree, revspec) then
util.warning("invalid revspec: %s", revspec)
return
end
local buf = vim.fn.bufadd("git://" .. revspec)
vim.b[buf].git_worktree = worktree
local prefix = opts.vertical and "leftabove vert " or "leftabove "
vim.cmd(prefix .. "diffsplit " .. vim.fn.fnameescape(uri))
local other_writable = util.parse_revspec(revspec).stage == 0
place_pair(buf, cur_buf, other_writable, opts.vertical)
end
return M