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 ` buffer doesn't pay an O(cursor_lnum) ---array allocation on every . ---@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 (`:`) index entries are writable through the buffer: ---`:w` rewrites the entry via `hash-object` + `update-index`. All other ---revspecs (HEAD:, :, :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, consumed once by `read_uri`. ---@type table local pending_content = {} ---Return a `git://` URI buffer. Pass `content` to prime ---`read_uri`'s cache and skip its `cat-file -p` fetch. ---@param worktree string ---@param revspec string any revspec git understands (e.g. `HEAD:foo`, `:foo`, `:1:foo`, ``, `: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://` URIs. Worktree comes from ---`b:git_worktree` if set, else from cwd. Stage-0 index entries ---(`:`) are made writable 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 = "hide" ---@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 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 -- Reload paths (`:e`, `` to an unloaded buf) re-enter -- with `modifiable = false` from the prior load. vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout)) end -- `b:git_ref` anchors ``-driven navigation in this buffer. 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 -- 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, parsed.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 -- Filetype from the inner path. We can't lean on `vim.filetype.add` -- because Vim normalises `git://` filenames (cwd-prefix + collapses -- `://` 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). 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 -- modeline parsing doesn't run unless we fire it ourselves. The -- modeline can still override the filetype set above (standard Vim -- precedence). vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) end ---Buffer for the file at `:`. Returns nil for a zero/nil blob. ---@param worktree string ---@param blob string? ---@param path string ---@param ref string the commit ref the blob represents (e.g. `` or `^`) ---@return integer? local function blob_buf(worktree, blob, path, ref) if is_zero(blob) then return nil end return M.buf_for(worktree, ref .. ":" .. path) 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) if not buf then util.warning("no content for %s at %s", path, ref) return end 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" 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) if left and right then diff.open(left, right, true) return end if not left and not right then util.warning("no content for %s", section.post_path) return end local buf = left or right ---@cast buf -nil vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_set_current_buf(buf) end ---@class ow.Git.OpenObjectOpts ---@field split (false|"above"|"below"|"left"|"right")? forwarded to `util.place_buf`. Default opens a new horizontal split. ---Open any git object. Accepts a bare ref (commit / tree / blob / tag ---sha, branch, tag name, `stash@{N}`, etc.) or `:`. ---Resolves to a sha so the URI stays stable if the ref later moves. ---@param worktree string ---@param ref string ---@param opts ow.Git.OpenObjectOpts? function M.open_object(worktree, ref, opts) local commit_ref, path = ref:match("^(.-):(.+)$") local revspec if commit_ref then local sha = repo.rev_parse(worktree, commit_ref, true) or commit_ref revspec = sha .. ":" .. path else revspec = repo.rev_parse(worktree, ref, true) or ref end local content = util.exec( { "git", "cat-file", "-p", revspec }, { cwd = worktree, silent = true } ) if not content then util.warning("not a git object: %s", ref) return end 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 function M.open_under_cursor() local ctx = context() if not ctx then return false end local line = vim.api.nvim_get_current_line() 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 -- Blobs navigate by path so the URI carries the entry name (filetype -- detection wants the extension). Other types 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