From 5c5da7a854544c0aa140a119daf273265efc480f Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Wed, 29 Apr 2026 09:47:51 +0200 Subject: [PATCH] refactor(git): unify diff/object dispatch, codify naming, add :Gdiffsplit --- lua/git/cmd.lua | 167 +++++++++++------------ lua/git/commit.lua | 3 +- lua/git/diff.lua | 318 ++++++++++++++++++++++++-------------------- lua/git/init.lua | 89 ++++++------- lua/git/log.lua | 3 +- lua/git/object.lua | 272 ++++++++++++++++++++++++++----------- lua/git/repo.lua | 14 ++ lua/git/show.lua | 52 -------- lua/git/sidebar.lua | 55 +++----- lua/git/util.lua | 57 ++++++++ 10 files changed, 587 insertions(+), 443 deletions(-) delete mode 100644 lua/git/show.lua diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index 803c64d..a5bbe77 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -1,4 +1,5 @@ -local git = require("git") +local commit = require("git.commit") +local object = require("git.object") local repo = require("git.repo") local util = require("git.util") @@ -8,11 +9,12 @@ local M = {} ---@field ft string ---@field needs_ref boolean? +---Subcommands whose output goes to a buffer. Subcommands with their +---own dispatch (`commit`, `show`, `cat-file`) call `run_in_split` +---directly with a one-off conf. ---@type table local SPLIT_HANDLERS = { log = { ft = "git" }, - show = { ft = "git", needs_ref = true }, - ["cat-file"] = { ft = "git", needs_ref = true }, diff = { ft = "diff" }, } @@ -67,109 +69,70 @@ local function first_positional(args, start) end end ----Open `:` in a split via the `git://` BufReadCmd loader. ----Resolves to a sha first so the URI stays stable if the ref moves. ----@param worktree string ----@param user_ref string ----@param path string -local function show_file_in_split(worktree, user_ref, path) - local label = repo.rev_parse(worktree, user_ref, true) or user_ref - local uri = "git://" .. label .. ":" .. path - local buf = vim.fn.bufadd(uri) - vim.b[buf].git_worktree = worktree - vim.cmd("split " .. vim.fn.fnameescape(uri)) +---Find or create the named scratch buffer and place it in a window. +---@param name string +---@return integer buf +local function place_split(name) + local buf = vim.fn.bufnr("\\V" .. name) + if buf == -1 or not vim.api.nvim_buf_is_loaded(buf) then + buf = util.new_scratch() + pcall(vim.api.nvim_buf_set_name, buf, name) + return buf + end + local win_id = vim.fn.bufwinid(buf) + if win_id ~= -1 then + vim.api.nvim_set_current_win(win_id) + else + vim.api.nvim_open_win(buf, true, { + split = vim.o.splitbelow and "below" or "above", + }) + end + return buf end +---Run `git ` async. On success, drop the output into a named +---scratch split (creating or reusing as needed). On failure, `util.exec` +---notifies and the split is never opened, so a bad ref doesn't leave a +---stray buffer behind. ---@param worktree string ---@param args string[] ---@param conf ow.Git.SplitHandler local function run_in_split(worktree, args, conf) - -- `:` is a file lookup; the URI must carry the path so - -- filetype detection has something to match against. - if args[1] == "show" then - local arg = first_positional(args, 2) - if arg then - local ref, path = arg:match("^(.-):(.+)$") - if ref then - ---@cast path -nil - show_file_in_split(worktree, ref, path) - return - end - end - end - - -- `cat-file -p ` routes to the gitobject viewer so commits get - -- the full message + diff view and other types render via cat-file. - -- Other modes (-t, -s, -e) fall through to the generic dump. - if args[1] == "cat-file" and vim.list_contains(args, "-p") then - local ref = first_positional(args, 2) - if ref then - require("git.object").open_object(worktree, ref) - return - end - end - - local name = "[git " .. table.concat(args, " ") .. "]" - local buf = vim.fn.bufnr("\\V" .. name) - if buf == -1 or not vim.api.nvim_buf_is_loaded(buf) then - buf = git.new_scratch() - pcall(vim.api.nvim_buf_set_name, buf, name) - else - local win_id = vim.fn.bufwinid(buf) - if win_id ~= -1 then - vim.api.nvim_set_current_win(win_id) - else - vim.api.nvim_open_win(buf, true, { - split = vim.o.splitbelow and "below" or "above", - }) - end - vim.bo[buf].modifiable = true - vim.api.nvim_buf_set_lines(buf, 0, -1, false, {}) - end - - vim.b[buf].git_worktree = worktree - vim.b[buf].git_ref = nil - vim.b[buf].git_parent_ref = nil - if conf.needs_ref then - local user_ref = first_positional(args, 2) or "HEAD" - local sha = repo.rev_parse(worktree, user_ref, true) - if sha then - vim.b[buf].git_ref = sha - vim.b[buf].git_parent_ref = - repo.rev_parse(worktree, user_ref .. "^", true) - end - end - vim.bo[buf].filetype = conf.ft - local cmd = { "git" } vim.list_extend(cmd, args) - vim.system( - cmd, - { cwd = worktree, text = true }, - vim.schedule_wrap(function(obj) - if not vim.api.nvim_buf_is_valid(buf) then + util.exec(cmd, { + cwd = worktree, + on_done = function(stdout) + if not stdout then return end - local content = (obj.stdout or "") .. (obj.stderr or "") + local name = "[git " .. table.concat(args, " ") .. "]" + local buf = place_split(name) + vim.b[buf].git_worktree = worktree + vim.b[buf].git_ref = nil + vim.b[buf].git_parent_ref = nil + if conf.needs_ref then + local user_ref = first_positional(args, 2) or "HEAD" + local sha = repo.rev_parse(worktree, user_ref, true) + if sha then + vim.b[buf].git_ref = sha + vim.b[buf].git_parent_ref = + repo.rev_parse(worktree, user_ref .. "^", true) + end + end + vim.bo[buf].filetype = conf.ft vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines( buf, 0, -1, false, - util.split_lines(content) + util.split_lines(stdout) ) vim.bo[buf].modifiable = false vim.bo[buf].modified = false - if obj.code ~= 0 then - util.error( - "git %s failed: %s", - args[1] or "", - vim.trim(obj.stderr or "") - ) - end - end) - ) + end, + }) end ---@param worktree string @@ -244,7 +207,35 @@ function M.run(args) local sub = args[1] if sub == "commit" and not has_message(args) then - require("git.commit").commit({ amend = has_flag(args, "--amend") }) + commit.commit({ amend = has_flag(args, "--amend") }) + return + end + + -- `:G show :` opens the blob via the BufReadCmd loader + -- so the URI carries the path and filetype detection has something + -- to match against. Other show invocations dump output to a buffer. + if sub == "show" then + local arg = first_positional(args, 2) + if arg and arg:find(":", 1, true) then + object.open_object(worktree, arg) + return + end + run_in_split(worktree, args, { ft = "git", needs_ref = true }) + return + end + + -- `:G cat-file -p ` routes to the gitobject viewer so commits + -- get the full message + diff view and other types render via + -- cat-file. Other modes (-t, -s, -e) dump to a buffer. + if sub == "cat-file" then + if vim.list_contains(args, "-p") then + local ref = first_positional(args, 2) + if ref then + object.open_object(worktree, ref) + return + end + end + run_in_split(worktree, args, { ft = "git", needs_ref = true }) return end diff --git a/lua/git/commit.lua b/lua/git/commit.lua index 86add35..820b073 100644 --- a/lua/git/commit.lua +++ b/lua/git/commit.lua @@ -1,5 +1,4 @@ local editor = require("git.editor") -local git = require("git") local repo = require("git.repo") local util = require("git.util") @@ -30,7 +29,7 @@ function M.commit(opts) f:close() end - local buf, win = git.new_scratch({ name = file_path }) + local buf, win = util.new_scratch({ name = file_path }) proxy_buf = buf proxy_win = win vim.bo[buf].buftype = "acwrite" diff --git a/lua/git/diff.lua b/lua/git/diff.lua index 8560250..d84152f 100644 --- a/lua/git/diff.lua +++ b/lua/git/diff.lua @@ -3,129 +3,6 @@ local util = require("git.util") local M = {} ----@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 - ----Return a buffer holding the content addressed by a git revspec. The ----URI is `git://` and BufReadCmd loads via `git cat-file -p`. ----@param worktree string ----@param revspec string any revspec git understands (e.g. `HEAD:foo`, `:foo`, `:1:foo`, ``, `:foo`) ----@return integer -function M.git_show_buf(worktree, revspec) - local name = "git://" .. revspec - local buf = vim.fn.bufadd(name) - vim.b[buf].git_worktree = worktree - vim.fn.bufload(buf) - return buf -end - ----BufReadCmd handler for `git://` URIs. Loads content via ----`git cat-file -p `. Worktree comes from `vim.b[buf] ----.git_worktree` if set, else from cwd. Index entries (revspec form ----`:` for stage 0) are made writable via `attach_index_writer`, ----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 = name:match("^git://(.+)$") - 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 = "wipe" - - local stdout = util.exec( - { "git", "cat-file", "-p", revspec }, - { cwd = worktree } - ) - if stdout then - vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout)) - end - - -- Stage-0 index entries (`:` with no further `:`) are - -- editable; `:w` rewrites the index entry via `attach_index_writer`. - -- Anything else (HEAD:, :, :1:, :2:, :3:, bare object refs) - -- is read-only. - local index_path = revspec:match("^:([^:]+)$") - if index_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, index_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 - - -- BufReadCmd suppresses the normal BufReadPost dispatch, so filetype - -- detection and modeline parsing don't run unless we fire it ourselves. - vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) -end - ---@class ow.Git.EmptyBufOpts ---@field name string? ---@field bufhidden ("hide"|"wipe")? defaults to "wipe" @@ -155,21 +32,12 @@ function M.empty_buf(opts) return buf end ----@param abs_path string ----@return integer -function M.load_file_buf(abs_path) - local buf = vim.fn.bufadd(abs_path) - vim.fn.bufload(buf) - return buf -end - ----Name a scratch buffer with a `git://...` URI and apply the filetype ----inferred from the inner path segment. The `nvim_buf_set_name` call is ----wrapped in pcall because a buffer with that name may already exist +---Name a buffer and re-run filetype detection from the (re-)set name. +---Wrapped in `pcall` because a buffer with that name may already exist ---(E95). ---@param buf integer ---@param name string -function M.set_buf_name_and_filetype(buf, name) +local function set_buf_name_and_filetype(buf, name) pcall(vim.api.nvim_buf_set_name, buf, name) local ft = vim.filetype.match({ buf = buf }) if ft then @@ -186,20 +54,170 @@ end ---joining the tabpage's diff group and corrupting its render. ---@param win integer ---@param enabled boolean -function M.set_diff(win, enabled) +local function set_diff(win, enabled) vim.api.nvim_win_call(win, function() vim.cmd(enabled and "diffthis" or "diffoff") end) end +---Render two buffers as a diff pair. The right buffer takes the current +---window; the left opens in a leftabove split (vertical or horizontal +---per `vertical`). Both windows enter Vim's diff mode via `:diffsplit`'s +---built-in setup. Drops a `'` jumplist mark before reassigning the +---current window. +---@param left integer +---@param right integer +---@param vertical boolean +function M.open(left, right, vertical) + -- Read the name first: if `left` is the current window's buffer + -- and has `bufhidden=wipe` (a freshly-loaded `git://` URI), the + -- `nvim_set_current_buf(right)` below wipes it, and a later name + -- lookup would fail. `:diffsplit` re-bufadds + reloads from the + -- name, so the wipe-then-recreate sequence is fine. + local left_name = vim.api.nvim_buf_get_name(left) + vim.cmd.normal({ "m'", bang = true }) + vim.api.nvim_set_current_buf(right) + local prefix = vertical and "leftabove vert " or "leftabove " + vim.cmd(prefix .. "diffsplit " .. vim.fn.fnameescape(left_name)) +end + +---Repoint two existing diff windows at a new pair of buffers. +---Toggles diff mode off around the swap so Vim tears down the old diff +---group and re-establishes a fresh one. `nvim_win_set_buf` swaps the +---buffer pointer without invalidating cached diff state, and +---`:diffupdate` alone doesn't reliably force a recompute when no buffer +---contents have actually changed. Sides with `name` set get renamed + +---filetype-refreshed (used to relabel a fresh `empty_buf` placeholder +---as `[absent] `, or to re-run ft detection on a `git://` buffer). +---@param left_win integer +---@param right_win integer +---@param pair ow.Git.DiffPair +function M.update_pair(left_win, right_win, pair) + set_diff(left_win, false) + set_diff(right_win, false) + vim.api.nvim_win_set_buf(left_win, pair.left.buf) + vim.api.nvim_win_set_buf(right_win, pair.right.buf) + for _, side in ipairs({ pair.left, pair.right }) do + if side.name then + set_buf_name_and_filetype(side.buf, side.name) + end + end + set_diff(left_win, true) + set_diff(right_win, true) +end + +---Open two buffers as a diff. `a_left` decides which one goes in the +---leftabove slot (where `M.open` parks the cursor). Caller picks per +---its own rule (writable-on-left for routine flows, anchor-on-left +---when neither side is writable, etc). +---@param buf_a integer +---@param buf_b integer +---@param a_left boolean +---@param vertical boolean +local function place_pair(buf_a, buf_b, a_left, vertical) + if a_left then + M.open(buf_a, buf_b, vertical) + else + M.open(buf_b, buf_a, vertical) + end +end + +---Dispatch for `M.split` when the current buffer is a `git://` +---URI. Placement is "writable on the left" via `place_pair`. +--- +---gd/gh: pair cur with the worktree file at the URI's path. +--- +---gD/gH: pair cur with the next layer toward HEAD — +--- * stage 0 -> `HEAD:

` +--- * stage 2 (ours) <-> stage 3 (theirs) +--- * stage 1 (base) -> bail; ambiguous (suggest `:Gdiffsplit `) +--- * any other ref -> `:0:

` +--- +---A `` containing `:` (from `:Gdiffsplit`) short-circuits all the +---above and pairs cur with that revspec literally. This is the escape +---hatch the merge-base warning points at. +---@param opts ow.Git.SplitOpts +---@param cur_buf integer +---@param cur_revspec string +local function uri_split(opts, cur_buf, cur_revspec) + local worktree = vim.b[cur_buf].git_worktree + or select(2, repo.resolve_cwd()) + if not worktree then + util.warning("git URI buffer has no worktree") + return + end + local cur = util.parse_revspec(cur_revspec) + if not cur.path then + util.warning("git URI has no path; cannot diff against worktree") + return + end + -- `git.object` is lazy-required to break the load-time cycle. + -- It requires `git.diff` itself for `empty_buf`. + local object = require("git.object") + local cur_writable = cur.stage == 0 + + if opts.revspec ~= "" and opts.revspec:find(":", 1, true) then + if not repo.object_exists(worktree, opts.revspec) then + util.warning("invalid revspec: %s", opts.revspec) + return + end + place_pair( + cur_buf, + object.buf_for(worktree, opts.revspec), + cur_writable, + opts.vertical + ) + return + end + + if opts.revspec == "" then + local worktree_buf = vim.fn.bufadd(vim.fs.joinpath(worktree, cur.path)) + vim.fn.bufload(worktree_buf) + place_pair(cur_buf, worktree_buf, cur_writable, opts.vertical) + return + end + + if cur.stage == 1 then + util.warning("gD on merge base is ambiguous; use :Gdiffsplit ") + return + end + + local other_revspec + if cur.stage == 2 then + other_revspec = ":3:" .. cur.path + elseif cur.stage == 3 then + other_revspec = ":2:" .. cur.path + elseif cur.stage == 0 then + other_revspec = "HEAD:" .. cur.path + else + other_revspec = ":0:" .. cur.path + end + if not repo.object_exists(worktree, other_revspec) then + util.warning("invalid revspec: %s", other_revspec) + return + end + place_pair( + cur_buf, + object.buf_for(worktree, other_revspec), + cur.stage ~= nil, + opts.vertical + ) +end + ---@class ow.Git.SplitOpts ----@field ref string '' for index, 'HEAD' for HEAD +---@field revspec string '' for the smart-default routing (index vs worktree); a plain ref like `'HEAD'` to compare `:` against the current path; or a full revspec containing `:` (e.g. `':2:foo'`, `'HEAD~1:other.lua'`) used as-is. ---@field vertical boolean ---@param opts ow.Git.SplitOpts function M.split(opts) local cur_buf = vim.api.nvim_get_current_buf() local cur_path = vim.api.nvim_buf_get_name(cur_buf) + + local cur_revspec = cur_path:match("^git://(.+)$") + if cur_revspec then + return uri_split(opts, cur_buf, cur_revspec) + end + if cur_path == "" then util.warning("no file in current buffer") return @@ -219,16 +237,26 @@ function M.split(opts) return end - -- Stage 0 (index) is `:`; named refs are `:`. - local revspec = opts.ref == "" and (":" .. rel) or (opts.ref .. ":" .. rel) - local uri = "git://" .. revspec - -- Stash the worktree on the buffer so the BufReadCmd handler doesn't - -- fall back to cwd resolution (wrong when cwd != worktree). - local buf = vim.fn.bufadd(uri) + -- A `` containing `:` is treated as a full revspec; + -- otherwise the worktree-relative path is appended (the common + -- keymap form). + local revspec + if opts.revspec == "" then + revspec = ":0:" .. rel + elseif opts.revspec:find(":", 1, true) then + revspec = opts.revspec + else + revspec = opts.revspec .. ":" .. rel + end + if not repo.object_exists(worktree, revspec) then + util.warning("invalid revspec: %s", revspec) + return + end + local buf = vim.fn.bufadd("git://" .. revspec) vim.b[buf].git_worktree = worktree - local prefix = opts.vertical and "leftabove vert " or "leftabove " - vim.cmd(prefix .. "diffsplit " .. vim.fn.fnameescape(uri)) + local other_writable = util.parse_revspec(revspec).stage == 0 + place_pair(buf, cur_buf, other_writable, opts.vertical) end return M diff --git a/lua/git/init.lua b/lua/git/init.lua index 6eabbaf..89ed641 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -1,4 +1,10 @@ +local cmd = require("git.cmd") +local commit = require("git.commit") +local diff = require("git.diff") +local log = require("git.log") +local object = require("git.object") local repo = require("git.repo") +local sidebar = require("git.sidebar") local HIGHLIGHTS = { GitDeleted = "Removed", @@ -25,38 +31,6 @@ function M.head(path) return repo.head(path) end ----@class ow.Git.NewScratchOpts ----@field name string? ----@field bufhidden ("hide"|"wipe")? defaults to "hide" ----@field split (false|"above"|"below"|"left"|"right")? defaults to splitbelow-aware horizontal. `false` places the buffer in the current window (drops a `'` mark first so the user can jump back). - ----Create a fresh non-modifiable scratch buffer and place it. Default split ----direction is horizontal, honouring `splitbelow`. Caller flips ----`modifiable`, fills the buffer, and sets `filetype` once content lands. ----@param opts ow.Git.NewScratchOpts? ----@return integer buf ----@return integer win -function M.new_scratch(opts) - opts = opts or {} - local buf = vim.api.nvim_create_buf(false, true) - vim.bo[buf].buftype = "nofile" - vim.bo[buf].bufhidden = opts.bufhidden or "hide" - vim.bo[buf].swapfile = false - vim.bo[buf].modifiable = false - vim.bo[buf].modified = false - if opts.name then - pcall(vim.api.nvim_buf_set_name, buf, opts.name) - end - if opts.split == false then - vim.cmd.normal({ "m'", bang = true }) - vim.api.nvim_set_current_buf(buf) - return buf, vim.api.nvim_get_current_win() - end - local split = opts.split or (vim.o.splitbelow and "below" or "above") - local win = vim.api.nvim_open_win(buf, true, { split = split }) - return buf, win -end - function M.setup() for name, link in pairs(HIGHLIGHTS) do vim.api.nvim_set_hl(0, name, { link = link, default = true }) @@ -73,7 +47,7 @@ function M.setup() pattern = "git://*", group = group, callback = function(args) - require("git.diff").read_uri(args.buf) + object.read_uri(args.buf) end, }) vim.api.nvim_create_autocmd( @@ -104,34 +78,55 @@ function M.setup() end, }) - vim.keymap.set("n", "gg", function() - require("git.sidebar").toggle() - end, { desc = "Toggle git status sidebar" }) - vim.keymap.set("n", "gl", function() - require("git.log").show() - end, { desc = "Show git log" }) + vim.keymap.set( + "n", + "gg", + sidebar.toggle, + { desc = "Toggle git status sidebar" } + ) + vim.keymap.set("n", "gl", log.show, { desc = "Show git log" }) vim.keymap.set("n", "gd", function() - require("git.diff").split({ ref = "", vertical = true }) + diff.split({ revspec = "", vertical = true }) end, { desc = "Diff index vs worktree (vsplit)" }) vim.keymap.set("n", "gD", function() - require("git.diff").split({ ref = "HEAD", vertical = true }) + diff.split({ revspec = "HEAD", vertical = true }) end, { desc = "Diff HEAD vs worktree (vsplit)" }) vim.keymap.set("n", "gh", function() - require("git.diff").split({ ref = "", vertical = false }) + diff.split({ revspec = "", vertical = false }) end, { desc = "Diff index vs worktree (split)" }) vim.keymap.set("n", "gH", function() - require("git.diff").split({ ref = "HEAD", vertical = false }) + diff.split({ revspec = "HEAD", vertical = false }) end, { desc = "Diff HEAD vs worktree (split)" }) vim.keymap.set("n", "gc", function() - require("git.commit").commit() + commit.commit() end, { desc = "Git commit" }) vim.keymap.set("n", "ga", function() - require("git.commit").commit({ amend = true }) + commit.commit({ amend = true }) end, { desc = "Git commit --amend" }) vim.keymap.set("n", "gp", function() - require("git.cmd").run({ "push" }) + cmd.run({ "push" }) end, { desc = "Git push" }) - require("git.cmd").setup() + + local function diff_split_cmd(vertical) + return function(opts) + diff.split({ + revspec = opts.args, + vertical = vertical, + }) + end + end + vim.api.nvim_create_user_command( + "Gdiffsplit", + diff_split_cmd(true), + { nargs = "?", desc = "Diff against (vsplit)" } + ) + vim.api.nvim_create_user_command( + "Ghdiffsplit", + diff_split_cmd(false), + { nargs = "?", desc = "Diff against (split)" } + ) + + cmd.setup() end return M diff --git a/lua/git/log.lua b/lua/git/log.lua index 25f1af2..d5b4328 100644 --- a/lua/git/log.lua +++ b/lua/git/log.lua @@ -1,4 +1,3 @@ -local git = require("git") local repo = require("git.repo") local util = require("git.util") @@ -42,7 +41,7 @@ function M.show(opts) return end - local buf = git.new_scratch() + local buf = util.new_scratch() vim.b[buf].git_worktree = worktree vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout)) diff --git a/lua/git/object.lua b/lua/git/object.lua index c1eea25..8dd751a 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -1,5 +1,4 @@ local diff = require("git.diff") -local git = require("git") local repo = require("git.repo") local util = require("git.util") @@ -11,12 +10,12 @@ local M = {} ---@field pre_blob string? ---@field post_blob string? ----@class ow.Git.ShowContext +---@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.ShowContext? +---@return ow.Git.BufContext? local function context() local worktree = vim.b.git_worktree local ref = vim.b.git_ref @@ -79,6 +78,130 @@ 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 + +---Return a buffer holding the content addressed by a git revspec. The +---URI is `git://` and BufReadCmd routes through `M.read_uri`, +---which loads via `git cat-file -p`. +---@param worktree string +---@param revspec string any revspec git understands (e.g. `HEAD:foo`, `:foo`, `:1:foo`, ``, `:foo`) +---@return integer +function M.buf_for(worktree, revspec) + local name = "git://" .. revspec + local buf = vim.fn.bufadd(name) + vim.b[buf].git_worktree = worktree + vim.fn.bufload(buf) + return buf +end + +---BufReadCmd handler for `git://` URIs. Loads content via +---`git cat-file -p `. Worktree comes from `vim.b[buf] +---.git_worktree` if set, else from cwd. Stage-0 index entries (revspec +---form `:`) are made writable via `attach_index_writer` 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 = name:match("^git://(.+)$") + 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 = "wipe" + + local stdout = util.exec( + { "git", "cat-file", "-p", revspec }, + { cwd = worktree } + ) + if stdout then + vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout)) + end + + local parsed = util.parse_revspec(revspec) + local index_path = parsed.stage == 0 and parsed.path or nil + if index_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, index_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 + + -- BufReadCmd suppresses the normal BufReadPost dispatch, so filetype + -- detection and modeline parsing don't run unless we fire it ourselves. + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) +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 @@ -94,22 +217,22 @@ local function blob_buf(worktree, blob, path, ref) bufhidden = "hide", }) end - return diff.git_show_buf(worktree, revspec) + return M.buf_for(worktree, revspec) end ---@param worktree string ---@param blob string? ---@param path string ---@param ref string -local function show_blob(worktree, blob, path, ref) +local function load_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 ctx ow.Git.BufContext ---@param section ow.Git.DiffSection -local function show_diff(ctx, section) +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 @@ -122,18 +245,41 @@ local function show_diff(ctx, section) 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)) - ) + diff.open(left, right, true) end ----@class ow.Git.OpenCommitOpts ----@field split (false|"above"|"below"|"left"|"right")? forwarded to `git.new_scratch`. Default opens a new horizontal split. +---@class ow.Git.OpenObjectOpts +---@field split (false|"above"|"below"|"left"|"right")? forwarded to `util.new_scratch`. Default opens a new horizontal split. + +---Place a `git://` URI buffer in a window per `opts.split`. +---`bufadd` dedups against existing buffers, so re-opening the same URI +---reuses the buffer (and `bufload` no-ops). `read_uri` defaults to +---`bufhidden=wipe` (right for diff sides), but navigation buffers +---should persist across window closes, so override to `hide`. +---@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) + vim.bo[buf].bufhidden = "hide" + if default_ft and vim.bo[buf].filetype == "" then + vim.bo[buf].filetype = default_ft + end + local split = opts and opts.split + if split == false then + vim.cmd.normal({ "m'", bang = true }) + vim.api.nvim_set_current_buf(buf) + return + end + vim.api.nvim_open_win(buf, true, { + split = split or (vim.o.splitbelow and "below" or "above"), + }) +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 @@ -144,7 +290,7 @@ end ---`` flow. ---@param worktree string ---@param ref string ----@param opts ow.Git.OpenCommitOpts? +---@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 @@ -185,7 +331,7 @@ function M.open_commit(worktree, ref, opts) end local parent = repo.rev_parse(worktree, ref .. "^", true) - local buf, _ = git.new_scratch({ name = name, split = split }) + 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 @@ -199,14 +345,24 @@ function M.open_commit(worktree, ref, opts) 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`. +---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. ---@param worktree string ---@param ref string ----@param opts ow.Git.OpenCommitOpts? +---@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("^(.-):(.+)$") + if commit_ref then + local sha = repo.rev_parse(worktree, commit_ref, true) or commit_ref + open_uri(worktree, "git://" .. sha .. ":" .. path, sha, opts) + return + end + local type_out = util.exec( { "git", "cat-file", "-t", ref }, { cwd = worktree, silent = true } @@ -216,44 +372,16 @@ function M.open_object(worktree, ref, opts) 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 + -- 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 - 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" + open_uri(worktree, "git://" .. sha, sha, opts, "git") end ---@return boolean dispatched true if the cursor was on an actionable line @@ -277,22 +405,18 @@ function M.open_under_cursor() return true end - -- Tree-entry navigation: ` \t`. 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. + -- Tree-entry navigation: ` \t`. Blobs are + -- routed by path so the URI carries the entry name and filetype + -- detection picks it up. Subtrees navigate by sha so the resulting + -- buffer's `git_ref` is the subtree's own sha (correct anchor for + -- relative path navigation within it). Other types (submodule + -- commit refs, tags) also navigate by sha. 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 + 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 @@ -303,23 +427,23 @@ function M.open_under_cursor() local parent = ctx.parent_ref or "0" if line:match("^diff %-%-git ") then - show_diff(ctx, section) + open_section(ctx, section) return true end if line:match("^%-%-%- ") then - show_blob(ctx.worktree, section.pre_blob, section.pre_path, parent) + load_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) + load_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) + load_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) + load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent) return true end return false diff --git a/lua/git/repo.lua b/lua/git/repo.lua index e975b50..91d3794 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -343,4 +343,18 @@ function M.rev_parse(worktree, ref, short) return trimmed ~= "" and trimmed or nil end +---Verify a revspec resolves to an existing git object. `cat-file -e` is +---git's cheapest existence check, and unlike `rev-parse --verify` it +---also accepts the `:` form that BufReadCmd revspec URIs +---use. +---@param worktree string +---@param revspec string +---@return boolean +function M.object_exists(worktree, revspec) + return util.exec( + { "git", "cat-file", "-e", revspec }, + { cwd = worktree, silent = true } + ) ~= nil +end + return M diff --git a/lua/git/show.lua b/lua/git/show.lua deleted file mode 100644 index a3391e2..0000000 --- a/lua/git/show.lua +++ /dev/null @@ -1,52 +0,0 @@ -local git = require("git") -local repo = require("git.repo") -local util = require("git.util") - -local M = {} - ----@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.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 - -- Commit SHAs are immutable so a previously-opened buffer is still - -- valid. Reuse it instead of refetching. - 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", "show", ref }, { cwd = worktree }) - if not stdout 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 - 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 M diff --git a/lua/git/sidebar.lua b/lua/git/sidebar.lua index b5d65f2..1894a08 100644 --- a/lua/git/sidebar.lua +++ b/lua/git/sidebar.lua @@ -1,5 +1,5 @@ local diff = require("git.diff") -local git = require("git") +local object = require("git.object") local repo = require("git.repo") local util = require("git.util") @@ -433,7 +433,7 @@ local function refresh(bufnr, prefetched_stdout) end -- Any fs-event that triggered this refresh might have changed the -- worktree under the diff buffers we last opened; invalidate the - -- cache so the next show_diff recomputes panes. + -- cache so the next view_entry recomputes panes. s.last_shown_key = nil local fp = fingerprint(branch, groups) if fp == s.last_render_key then @@ -484,18 +484,23 @@ end ---@return ow.Git.DiffSide local function head_pane(worktree, path) return { - buf = diff.git_show_buf(worktree, "HEAD:" .. path), + buf = object.buf_for(worktree, "HEAD:" .. path), name = "git://HEAD:" .. path, } end ---@param worktree string ---@param path string +---@param kind "index"|"HEAD"|"worktree" ---@return ow.Git.DiffSide -local function absent_pane(worktree, path) +local function absent_pane(worktree, path, kind) return { buf = diff.empty_buf(), - name = "[absent] " .. vim.fs.joinpath(worktree, path), + name = string.format( + "[absent %s] %s", + kind, + vim.fs.joinpath(worktree, path) + ), } end @@ -503,10 +508,9 @@ end ---@param path string ---@return ow.Git.DiffSide local function worktree_pane(worktree, path) - return { - buf = diff.load_file_buf(vim.fs.joinpath(worktree, path)), - name = nil, - } + local buf = vim.fn.bufadd(vim.fs.joinpath(worktree, path)) + vim.fn.bufload(buf) + return { buf = buf, name = nil } end ---@param s ow.Git.SidebarState @@ -518,11 +522,11 @@ local function index_pane(s, entry) or (entry.section == "Staged" and entry.x == "D") ) if not in_index then - return absent_pane(s.worktree, entry.path) + return absent_pane(s.worktree, entry.path, "index") end return { - buf = diff.git_show_buf(s.worktree, ":" .. entry.path), - name = "git://:" .. entry.path, + buf = object.buf_for(s.worktree, ":0:" .. entry.path), + name = "git://:0:" .. entry.path, } end @@ -534,7 +538,7 @@ local function other_pane(s, entry) local worktree = s.worktree if entry.section == "Staged" then if entry.x == "A" then - return absent_pane(worktree, p) + return absent_pane(worktree, p, "HEAD") end if entry.x == "D" then return head_pane(worktree, p) @@ -544,7 +548,7 @@ local function other_pane(s, entry) end if entry.section == "Unstaged" then if entry.y == "D" then - return absent_pane(worktree, p) + return absent_pane(worktree, p, "worktree") end return worktree_pane(worktree, p) end @@ -649,7 +653,7 @@ end ---@param s ow.Git.SidebarState ---@param entry ow.Git.SidebarEntry ---@param focus_left boolean -local function show_diff(s, entry, focus_left) +local function view_entry(s, entry, focus_left) if not entry.path then return end @@ -713,22 +717,7 @@ local function show_diff(s, entry, focus_left) s.diff_left_win = left_win s.diff_right_win = right_win - -- Toggle diff off around the buffer swap so Vim tears down the old - -- diff group and re-establishes a fresh one against the new pair. - -- nvim_win_set_buf swaps the buffer pointer without invalidating - -- cached diff state, and :diffupdate alone doesn't reliably force a - -- recompute when no buffer contents have actually changed. - diff.set_diff(left_win, false) - diff.set_diff(right_win, false) - vim.api.nvim_win_set_buf(left_win, pair.left.buf) - vim.api.nvim_win_set_buf(right_win, pair.right.buf) - for _, side in ipairs({ pair.left, pair.right }) do - if side.name then - diff.set_buf_name_and_filetype(side.buf, side.name) - end - end - diff.set_diff(left_win, true) - diff.set_diff(right_win, true) + diff.update_pair(left_win, right_win, pair) s.last_shown_key = key if focus_left then @@ -744,7 +733,7 @@ local function preview_or_open(focus_left) if not s or not entry then return end - show_diff(s, entry, focus_left) + view_entry(s, entry, focus_left) end local function action_stage() @@ -876,7 +865,7 @@ local function open(worktree) end local previous_win = vim.api.nvim_get_current_win() - local bufnr, win = git.new_scratch({ split = "left", bufhidden = "wipe" }) + local bufnr, win = util.new_scratch({ split = "left", bufhidden = "wipe" }) vim.bo[bufnr].filetype = "gitsidebar" vim.wo[win].number = false diff --git a/lua/git/util.lua b/lua/git/util.lua index 000a83f..76792e7 100644 --- a/lua/git/util.lua +++ b/lua/git/util.lua @@ -1,5 +1,62 @@ local M = {} +---@class ow.Git.ParsedRevspec +---@field stage 0|1|2|3? index stage when the revspec is `:` / `:0:` / `:N:`; nil otherwise +---@field path string? path component when the revspec carries one; nil for bare object refs + +---Classify a `git://` revspec into its stage / path components. +---Recognised forms: +--- * `:` and `:0:` -> stage 0 (the resolved index entry) +--- * `:1:` / `:2:` / `:3:` -> merge stages base / ours / theirs +--- * `:` -> stage = nil, path set +--- * bare object ref (no `:`) -> stage = nil, path = nil +---@param revspec string +---@return ow.Git.ParsedRevspec +function M.parse_revspec(revspec) + local stage, path = revspec:match("^:([0123]):(.+)$") + if stage then + return { stage = tonumber(stage), path = path } + end + path = revspec:match("^:([^:]+)$") + if path then + return { stage = 0, path = path } + end + path = (revspec:match("^[^:]+:(.+)$")) + return { stage = nil, path = path } +end + +---@class ow.Git.NewScratchOpts +---@field name string? +---@field bufhidden ("hide"|"wipe")? defaults to "hide" +---@field split (false|"above"|"below"|"left"|"right")? defaults to splitbelow-aware horizontal. `false` places the buffer in the current window (drops a `'` mark first so the user can jump back). + +---Create a fresh non-modifiable scratch buffer and place it. Default split +---direction is horizontal, honouring `splitbelow`. Caller flips +---`modifiable`, fills the buffer, and sets `filetype` once content lands. +---@param opts ow.Git.NewScratchOpts? +---@return integer buf +---@return integer win +function M.new_scratch(opts) + opts = opts or {} + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = opts.bufhidden or "hide" + vim.bo[buf].swapfile = false + vim.bo[buf].modifiable = false + vim.bo[buf].modified = false + if opts.name then + pcall(vim.api.nvim_buf_set_name, buf, opts.name) + end + if opts.split == false then + vim.cmd.normal({ "m'", bang = true }) + vim.api.nvim_set_current_buf(buf) + return buf, vim.api.nvim_get_current_win() + end + local split = opts.split or (vim.o.splitbelow and "below" or "above") + local win = vim.api.nvim_open_win(buf, true, { split = split }) + return buf, win +end + ---@param fmt string ---@param ... any function M.error(fmt, ...)