Files
nvim/lua/git/object.lua
T
2026-04-29 14:08:04 +02:00

455 lines
16 KiB
Lua

local diff = require("git.diff")
local repo = require("git.repo")
local util = require("git.util")
local M = {}
---@class ow.Git.DiffSection
---@field pre_path string path on the parent side (`a/...`)
---@field post_path string path on the current side (`b/...`)
---@field pre_blob string?
---@field post_blob string?
---@class ow.Git.BufContext
---@field worktree string
---@field ref string resolved commit SHA of the gitobject buffer
---@field parent_ref string? resolved parent commit SHA, nil for root commits
---@return ow.Git.BufContext?
local function context()
local worktree = vim.b.git_worktree
local ref = vim.b.git_ref
if not worktree or not ref then
return nil
end
return { worktree = worktree, ref = ref, parent_ref = vim.b.git_parent_ref }
end
---Find the enclosing `diff --git` line and parse the section's pre/post
---paths plus the pre/post blob SHAs from the `index` line.
---
---Uses `vim.fn.search('bcnW')` (backward, accept cursor pos, no move, no
---wrap) so a giant `git show <merge>` buffer doesn't pay an O(cursor_lnum)
---array allocation on every <CR>.
---@return ow.Git.DiffSection?
local function diff_section()
local diff_lnum = vim.fn.search("^diff --git ", "bcnW")
if diff_lnum == 0 then
return nil
end
local diff_line =
vim.api.nvim_buf_get_lines(0, diff_lnum - 1, diff_lnum, false)[1]
if not diff_line then
return nil
end
local pre_path, post_path = diff_line:match("^diff %-%-git a/(.-) b/(.+)$")
if not pre_path or not post_path then
return nil
end
-- Header lines (mode/index/oldfile/newfile/etc) sit between the
-- `diff --git` line and the first `@@` hunk; cap the read at 20 to
-- bound work even for unusual diff headers.
local header =
vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false)
local pre_blob, post_blob
for _, l in ipairs(header) do
if l:sub(1, 3) == "@@ " or l:match("^diff %-%-git ") then
break
end
local pre, post = l:match("^index (%x+)%.%.(%x+)")
if pre then
pre_blob = pre
post_blob = post
break
end
end
return {
pre_path = pre_path,
post_path = post_path,
pre_blob = pre_blob,
post_blob = post_blob,
}
end
---@param sha string?
---@return boolean
local function is_zero(sha)
return sha == nil or sha:match("^0+$") ~= nil
end
---Stage-0 (`:<path>`) index entries are writable through the buffer:
---`:w` rewrites the entry via `hash-object` + `update-index`. All other
---revspecs (HEAD:, <sha>:, :1:, bare object refs) stay read-only.
---@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
---Pre-fetched content keyed by bufnr. Set by `buf_for(_, _, content)`
---and consumed by the next `read_uri` dispatch on that buffer. Lets
---callers fold validation + content fetch + buffer load into one
---`cat-file` call instead of preflighting separately.
---@type table<integer, string>
local pending_content = {}
---Return a buffer holding the content addressed by a git revspec. The
---URI is `git://<revspec>` and BufReadCmd routes through `M.read_uri`,
---which loads via `git cat-file -p`. If `content` is given, primes a
---cache so the BufReadCmd handler reuses it instead of running another
---`cat-file -p`.
---@param worktree string
---@param revspec string any revspec git understands (e.g. `HEAD:foo`, `:foo`, `:1:foo`, `<sha>`, `<sha>:foo`)
---@param content string?
---@return integer
function M.buf_for(worktree, revspec, content)
local buf = vim.fn.bufadd(util.uri(revspec))
vim.b[buf].git_worktree = worktree
if content then
pending_content[buf] = content
end
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. Stage-0 index entries (revspec
---form `:<path>`) 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 = util.parse_uri(name)
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"
---@type string?
local stdout = pending_content[buf]
pending_content[buf] = nil
if stdout == nil then
stdout = util.exec(
{ "git", "cat-file", "-p", revspec },
{ cwd = worktree }
)
end
if stdout then
vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout))
end
local parsed = util.parse_revspec(revspec)
local index_path = parsed.stage == 0 and parsed.path or nil
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
---Buffer for the file at `<ref>:<path>`. A zero/nil blob (file absent on
---this side of the diff) yields an empty placeholder.
---@param worktree string
---@param blob string?
---@param path string
---@param ref string the commit ref the blob represents (e.g. `<sha>` or `<sha>^`)
---@return integer
local function blob_buf(worktree, blob, path, ref)
local revspec = ref .. ":" .. path
if is_zero(blob) then
return diff.empty_buf({
name = util.uri(revspec),
bufhidden = "hide",
})
end
return M.buf_for(worktree, revspec)
end
---@param worktree string
---@param blob string?
---@param path string
---@param ref string
local function load_blob(worktree, blob, path, ref)
local buf = blob_buf(worktree, blob, path, ref)
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(buf)
end
---@param ctx ow.Git.BufContext
---@param section ow.Git.DiffSection
local function open_section(ctx, section)
if not section.pre_blob or not section.post_blob then
util.warning("no index line; cannot determine blob SHAs")
return
end
local parent = ctx.parent_ref or "0"
-- blob_buf handles the absent (zero-blob) case by returning an empty
-- buffer, which keeps `:diffsplit` from triggering BufReadCmd against
-- a non-existent revspec and logging an error.
local left =
blob_buf(ctx.worktree, section.pre_blob, section.pre_path, parent)
local right =
blob_buf(ctx.worktree, section.post_blob, section.post_path, ctx.ref)
diff.open(left, right, true)
end
---@class ow.Git.OpenObjectOpts
---@field split (false|"above"|"below"|"left"|"right")? forwarded to `util.new_scratch`. Default opens a new horizontal split.
---Place a `git://<revspec>` URI buffer in a window per `opts.split`.
---`bufadd` dedups against existing buffers, so re-opening the same URI
---reuses the buffer (and `bufload` no-ops). `read_uri` defaults to
---`bufhidden=wipe` (right for diff sides), but navigation buffers
---should persist across window closes, so override to `hide`.
---@param worktree string
---@param uri string
---@param sha string written to `b:git_ref` so `<CR>` navigation in the buffer can resolve relative paths
---@param opts ow.Git.OpenObjectOpts?
---@param default_ft string? applied if filetype detection didn't pick anything (bare-sha URIs have no path for the `filetype.add` pattern to match)
local function open_uri(worktree, uri, sha, opts, default_ft)
local buf = vim.fn.bufadd(uri)
vim.b[buf].git_worktree = worktree
vim.b[buf].git_ref = sha
vim.fn.bufload(buf)
vim.bo[buf].bufhidden = "hide"
if default_ft and vim.bo[buf].filetype == "" then
vim.bo[buf].filetype = default_ft
end
util.place_buf(buf, opts and opts.split)
end
---Open a commit's body via `git cat-file -p` for the header (raw object
---form, flush-left message) plus `git diff-tree -p` for the patch. The
---`-m --first-parent` flags collapse merges and stashes into single
---`diff --git` blocks per file, so `M.open_under_cursor`'s `<CR>` parser
---can navigate them. (`git show` would emit `diff --cc` combined diffs
---in those cases, which the parser can't follow.) Used by the gitlog
---`<CR>` flow.
---@param worktree string
---@param ref string
---@param opts ow.Git.OpenObjectOpts?
function M.open_commit(worktree, ref, opts)
local split = opts and opts.split
local sha = repo.rev_parse(worktree, ref, true) or ref
local name = util.uri(sha)
local existing = vim.fn.bufnr(name)
if existing ~= -1 and vim.api.nvim_buf_is_loaded(existing) then
util.place_buf(existing, split)
return
end
local header = util.exec(
{ "git", "cat-file", "-p", ref },
{ cwd = worktree }
)
if not header then
return
end
-- `--root` lets initial commits show their full tree.
local patch = util.exec({
"git",
"diff-tree",
"-p",
"-m",
"--first-parent",
"--root",
"--no-commit-id",
ref,
}, { cwd = worktree })
if not patch then
return
end
local parent = repo.rev_parse(worktree, ref .. "^", true)
local buf, _ = util.new_scratch({ name = name, split = split })
vim.b[buf].git_worktree = worktree
vim.b[buf].git_ref = sha
vim.b[buf].git_parent_ref = parent
vim.bo[buf].modifiable = true
-- Normalise to exactly one blank line between the message body and
-- the patch, regardless of trailing newlines on the header.
local content = (header:gsub("\n*$", "\n\n")) .. patch
vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(content))
vim.bo[buf].modifiable = false
vim.bo[buf].modified = false
vim.bo[buf].filetype = "git"
end
---Open any git object. Accepts either a bare ref (commit/tree/blob/tag
---SHA, branch name, etc.) or `<commit-ref>:<path>` form. Commits route
---to `M.open_commit` for the message + diff view; everything else flows
---through the BufReadCmd loader at the `git://<revspec>` URI.
---@param worktree string
---@param ref string
---@param opts ow.Git.OpenObjectOpts?
function M.open_object(worktree, ref, opts)
-- Path-form: resolve the commit-ref to a sha so the URI stays stable
-- if the ref later moves, and the `filetype.add` pattern can pick the
-- ft from the path segment.
local commit_ref, path = ref:match("^(.-):(.+)$")
if commit_ref then
local sha = repo.rev_parse(worktree, commit_ref, true) or commit_ref
open_uri(worktree, util.uri(sha .. ":" .. path), sha, opts)
return
end
local type_out = util.exec(
{ "git", "cat-file", "-t", ref },
{ cwd = worktree, silent = true }
)
local obj_type = type_out and vim.trim(type_out) or ""
if obj_type == "" then
util.warning("not a git object: %s", ref)
return
end
if obj_type == "commit" then
M.open_commit(worktree, ref, opts)
return
end
-- Trees, blobs, tags. The bare-sha URI has no path, so the
-- `filetype.add` pattern doesn't match; default to `git` so
-- tree / tag header lines syntax-highlight.
local sha = repo.rev_parse(worktree, ref, true) or ref
open_uri(worktree, util.uri(sha), sha, opts, "git")
end
---@return boolean dispatched true if the cursor was on an actionable line
function M.open_under_cursor()
local ctx = context()
if not ctx then
return false
end
local line = vim.api.nvim_get_current_line()
-- Cat-file header navigation: `parent <sha>` (commit), `tree <sha>`
-- (commit / tag), `object <sha>` (tag's referent) all reference
-- another git object.
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_object(ctx.worktree, sha, { split = false })
return true
end
-- Tree-entry navigation: `<mode> <type> <sha>\t<name>`. Blobs are
-- routed by path so the URI carries the entry name and filetype
-- detection picks it up. Subtrees navigate by sha so the resulting
-- buffer's `git_ref` is the subtree's own sha (correct anchor for
-- relative path navigation within it). Other types (submodule
-- commit refs, tags) also navigate by sha.
local entry_type, entry_sha, entry_name =
line:match("^%d+ (%w+) (%x+)\t(.+)$")
if entry_sha then
local nav_ref = entry_type == "blob" and (ctx.ref .. ":" .. entry_name)
or entry_sha
M.open_object(ctx.worktree, nav_ref, { split = false })
return true
end
local section = diff_section()
if not section then
return false
end
local parent = ctx.parent_ref or "0"
if line:match("^diff %-%-git ") then
open_section(ctx, section)
return true
end
if line:match("^%-%-%- ") then
load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent)
return true
end
if line:match("^%+%+%+ ") then
load_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref)
return true
end
local prefix = line:sub(1, 1)
if prefix == "+" then
load_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref)
return true
elseif prefix == "-" then
load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent)
return true
end
return false
end
return M