diff --git a/after/ftplugin/git.lua b/after/ftplugin/git.lua index b3ca741..f6d04c1 100644 --- a/after/ftplugin/git.lua +++ b/after/ftplugin/git.lua @@ -10,7 +10,7 @@ end local cr = vim.api.nvim_replace_termcodes("", true, false, true) vim.keymap.set("n", "", function() - if not require("git.show").open_at_cursor() then + if not require("git.object").open_under_cursor() then -- "n" mode = no remap, so this doesn't recurse into our mapping. vim.api.nvim_feedkeys(cr, "n", false) end diff --git a/ftplugin/gitlog.lua b/ftplugin/gitlog.lua index 60aed0f..572e996 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.show").open_commit_object(worktree, sha, { split = false }) + require("git.object").open_commit(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 new file mode 100644 index 0000000..882a619 --- /dev/null +++ b/lua/git/object.lua @@ -0,0 +1,243 @@ +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 diff --git a/lua/git/show.lua b/lua/git/show.lua index cfdc25f..5b437af 100644 --- a/lua/git/show.lua +++ b/lua/git/show.lua @@ -1,138 +1,19 @@ -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 +---@class ow.Git.ShowOpts ---@field split (false|"above"|"below"|"left"|"right")? forwarded to `git.new_scratch`. Default opens a new horizontal split. +---Open a commit's `git show ` output in a buffer (indented message, +---unified-diff body). For the navigable raw-object form used by the +---gitlog `` flow, see `git.object` `M.open_commit`. ---@param worktree string ---@param ref string ----@param opts ow.Git.OpenCommitOpts? -function M.open_commit(worktree, ref, opts) +---@param opts ow.Git.ShowOpts? +function M.show(worktree, ref, opts) local split = opts and opts.split local sha = repo.rev_parse(worktree, ref, true) or ref local name = "git://" .. sha .. "//" @@ -168,115 +49,4 @@ function M.open_commit(worktree, ref, opts) vim.bo[buf].filetype = "git" 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_at_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_object(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_at_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_object` buffers.) - local parent_sha = line:match("^parent (%x+)$") - if parent_sha then - M.open_commit_object(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