diff --git a/ftplugin/gitlog.lua b/ftplugin/gitlog.lua index 572e996..07c3a80 100644 --- a/ftplugin/gitlog.lua +++ b/ftplugin/gitlog.lua @@ -9,7 +9,7 @@ vim.keymap.set("n", "", function() .nvim_get_current_line() :match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)") if sha then - require("git.object").open_commit(worktree, sha, { split = false }) + require("git.object").open_object(worktree, sha, { split = false }) else -- "n" mode = no remap, so this doesn't recurse into our mapping. vim.api.nvim_feedkeys(cr, "n", false) diff --git a/lua/git/object.lua b/lua/git/object.lua index 8256b23..d6855ad 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -193,11 +193,51 @@ function M.read_uri(buf) { cwd = worktree } ) end + + local parsed = util.parse_revspec(revspec) + + -- Bare-ref objects that dereference to a commit (commits, stashes, + -- annotated tags pointing at a commit, lightweight tags) get their + -- `diff-tree -p` patch appended so the buffer is navigable: the + -- `` parser walks `diff --git` blocks. `^{commit}` is git's + -- standard "deref to commit" suffix; rev-parse fails for non-commit + -- objects (trees, blobs, tags pointing at non-commits) so they + -- naturally skip the append. `-m --first-parent` collapses merges + -- and stashes into one diff per file (vs `diff --cc` combined + -- diffs, which the parser can't follow). + if stdout and parsed.path == nil then + local commit_sha = + repo.rev_parse(worktree, revspec .. "^{commit}", true) + if commit_sha then + local patch = util.exec({ + "git", + "diff-tree", + "-p", + "-m", + "--first-parent", + "--root", + "--no-commit-id", + commit_sha, + }, { cwd = worktree }) + if patch then + stdout = (stdout:gsub("\n*$", "\n\n")) .. patch + end + vim.b[buf].git_parent_ref = + repo.rev_parse(worktree, commit_sha .. "^", true) + end + 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) + -- `b:git_ref` anchors ``-driven navigation in this buffer. Set + -- here once per load instead of having every caller do it. + local ref_sha = repo.rev_parse(worktree, revspec, true) + if ref_sha then + vim.b[buf].git_ref = ref_sha + end + if parsed.stage == 0 and parsed.path then vim.bo[buf].buftype = "acwrite" -- Re-running BufReadCmd (e.g. on `:edit`) would otherwise stack @@ -218,12 +258,15 @@ function M.read_uri(buf) -- `://` to `:/`) before matching, breaking any pattern keyed on the -- raw scheme as well as any built-in pattern that doesn't catch a -- recognisable extension on the mangled form (.Xresources is the - -- canonical example). + -- canonical example). Bare-ref content (commit/tag headers + tree + -- listings) uses the built-in `git` filetype. if parsed.path then local ft = vim.filetype.match({ filename = parsed.path, buf = buf }) if ft then vim.bo[buf].filetype = ft end + else + vim.bo[buf].filetype = "git" end -- BufReadCmd suppresses the normal BufReadPost dispatch, so @@ -277,120 +320,38 @@ local function open_section(ctx, section) end ---@class ow.Git.OpenObjectOpts ----@field split (false|"above"|"below"|"left"|"right")? forwarded to `util.new_scratch`. Default opens a new horizontal split. +---@field split (false|"above"|"below"|"left"|"right")? forwarded to `util.place_buf`. Default opens a new horizontal split. ----Place a `git://` URI buffer in a window per `opts.split`. ----`bufadd` dedups against existing buffers; `bufload` no-ops if loaded. ----@param worktree string ----@param uri string ----@param sha string written to `b:git_ref` so `` 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) - 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 `` 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 ----`` 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 `:` form. Commits route ----to `M.open_commit` for the message + diff view; everything else flows ----through the BufReadCmd loader at the `git://` URI. +---Open any git object. Accepts a bare ref (commit / tree / blob / tag +---sha, branch, tag name, `stash@{N}`, etc.) or `:` +---form. Resolves the revspec to a sha so the URI stays stable if the +---ref later moves, primes the `read_uri` cache with the `cat-file -p` +---output (one subprocess instead of two — preflight + load), then goes +---through the unified URI pipeline. `read_uri` then appends the +---`diff-tree -p` patch for any revspec that dereferences to a commit +---(commits, stashes, annotated tags pointing at commits). ---@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("^(.-):(.+)$") + local revspec 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 + revspec = sha .. ":" .. path + else + revspec = repo.rev_parse(worktree, ref, true) or ref end - - local type_out = util.exec( - { "git", "cat-file", "-t", ref }, + local content = util.exec( + { "git", "cat-file", "-p", revspec }, { cwd = worktree, silent = true } ) - local obj_type = type_out and vim.trim(type_out) or "" - if obj_type == "" then + if not content 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") + local buf = M.buf_for(worktree, revspec, content) + util.place_buf(buf, opts and opts.split) end ---@return boolean dispatched true if the cursor was on an actionable line