local diff = require("git.diff") local git = require("git") 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.ShowContext ---@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.ShowContext? 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 ---Buffer for the file at `:`. 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. `` or `^`) ---@return integer local function blob_buf(worktree, blob, path, ref) local name = "git://" .. ref .. "//" .. path if is_zero(blob) then return diff.empty_buf({ name = name, bufhidden = "hide" }) end return diff.git_show_buf(worktree, ref, path) end ---@param worktree string ---@param blob string? ---@param path string ---@param ref string local function show_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.ShowContext ---@param section ow.Git.DiffSection local function show_diff(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) vim.cmd.normal({ "m'", bang = true }) local left_win = vim.api.nvim_get_current_win() vim.api.nvim_set_current_buf(left) vim.wo[left_win].diff = true local right_win = vim.api.nvim_open_win(right, true, { split = "right", win = left_win }) vim.wo[right_win].diff = true vim.api.nvim_set_current_win(left_win) end ---@class ow.Git.OpenCommitOpts ---@field split (false|"above"|"below"|"left"|"right")? forwarded to `git.new_scratch`. Default opens a new horizontal split. ---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.OpenCommitOpts? 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 = "git://" .. sha .. "//" local existing = vim.fn.bufnr(name) if existing ~= -1 and vim.api.nvim_buf_is_loaded(existing) then if split == false then vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_set_current_buf(existing) else vim.api.nvim_open_win(existing, true, { split = split or (vim.o.splitbelow and "below" or "above"), }) end 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, _ = git.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 ---@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 ` opens the referenced -- commit. (`git show` doesn't emit a `parent` line, so this only -- fires inside `M.open_commit` buffers.) local parent_sha = line:match("^parent (%x+)$") if parent_sha then M.open_commit(ctx.worktree, parent_sha, { 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 show_diff(ctx, section) return true end if line:match("^%-%-%- ") then show_blob(ctx.worktree, section.pre_blob, section.pre_path, parent) return true end if line:match("^%+%+%+ ") then show_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref) return true end local prefix = line:sub(1, 1) if prefix == "+" then show_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref) return true elseif prefix == "-" then show_blob(ctx.worktree, section.pre_blob, section.pre_path, parent) return true end return false end return M