329 lines
11 KiB
Lua
329 lines
11 KiB
Lua
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 <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
|
|
|
|
---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 = "git://" .. revspec,
|
|
bufhidden = "hide",
|
|
})
|
|
end
|
|
return diff.git_show_buf(worktree, revspec)
|
|
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"
|
|
-- 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)
|
|
vim.cmd.normal({ "m'", bang = true })
|
|
vim.api.nvim_set_current_buf(right)
|
|
-- `:diffsplit` is the same path `M.split` uses; Vim's built-in diff
|
|
-- machinery handles the diff option setup on both windows.
|
|
vim.cmd(
|
|
"leftabove vert diffsplit "
|
|
.. vim.fn.fnameescape(vim.api.nvim_buf_get_name(left))
|
|
)
|
|
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 `<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.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
|
|
|
|
---Open any git object in a buffer. Commits get the full
|
|
---`M.open_commit` view (cat-file + diff-tree). Trees, blobs, and tags
|
|
---dump `git cat-file -p` output as-is. The object type is detected via
|
|
---`git cat-file -t`.
|
|
---@param worktree string
|
|
---@param ref string
|
|
---@param opts ow.Git.OpenCommitOpts?
|
|
function M.open_object(worktree, ref, opts)
|
|
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
|
|
|
|
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 stdout = util.exec(
|
|
{ "git", "cat-file", "-p", ref },
|
|
{ cwd = worktree }
|
|
)
|
|
if not stdout then
|
|
return
|
|
end
|
|
|
|
local buf, _ = git.new_scratch({ name = name, split = split })
|
|
vim.b[buf].git_worktree = worktree
|
|
vim.b[buf].git_ref = sha
|
|
vim.bo[buf].modifiable = true
|
|
vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout))
|
|
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 <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>`. Blob entries
|
|
-- route through `diff.git_show_buf` so the buffer URI carries the
|
|
-- entry name and BufReadCmd / BufReadPost resolve filetype from it.
|
|
-- Other types (subtrees, submodule commit refs, tags) fall through
|
|
-- to the generic SHA-based opener.
|
|
local entry_type, entry_sha, entry_name =
|
|
line:match("^%d+ (%w+) (%x+)\t(.+)$")
|
|
if entry_sha then
|
|
if entry_type == "blob" then
|
|
local buf =
|
|
diff.git_show_buf(ctx.worktree, ctx.ref .. ":" .. entry_name)
|
|
vim.cmd.normal({ "m'", bang = true })
|
|
vim.api.nvim_set_current_buf(buf)
|
|
else
|
|
M.open_object(ctx.worktree, entry_sha, { split = false })
|
|
end
|
|
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
|