refactor(git): unify diff/object dispatch, codify naming, add :Gdiffsplit

This commit is contained in:
2026-04-29 09:47:51 +02:00
parent 44f9503960
commit 5c5da7a854
10 changed files with 587 additions and 443 deletions
+63 -72
View File
@@ -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<string, ow.Git.SplitHandler>
local SPLIT_HANDLERS = {
log = { ft = "git" },
show = { ft = "git", needs_ref = true },
["cat-file"] = { ft = "git", needs_ref = true },
diff = { ft = "diff" },
}
@@ -67,54 +69,16 @@ local function first_positional(args, start)
end
end
---Open `<ref>:<path>` 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))
end
---@param worktree string
---@param args string[]
---@param conf ow.Git.SplitHandler
local function run_in_split(worktree, args, conf)
-- `<ref>:<path>` 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 <sha>` 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, " ") .. "]"
---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 = git.new_scratch()
buf = util.new_scratch()
pcall(vim.api.nvim_buf_set_name, buf, name)
else
return buf
end
local win_id = vim.fn.bufwinid(buf)
if win_id ~= -1 then
vim.api.nvim_set_current_win(win_id)
@@ -123,10 +87,27 @@ local function run_in_split(worktree, args, conf)
split = vim.o.splitbelow and "below" or "above",
})
end
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {})
return buf
end
---Run `git <args>` 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)
local cmd = { "git" }
vim.list_extend(cmd, args)
util.exec(cmd, {
cwd = worktree,
on_done = function(stdout)
if not stdout then
return
end
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
@@ -140,36 +121,18 @@ local function run_in_split(worktree, args, conf)
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
return
end
local content = (obj.stdout or "") .. (obj.stderr or "")
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 <ref>:<path>` 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 <sha>` 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
+1 -2
View File
@@ -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"
+173 -145
View File
@@ -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://<revspec>` 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`, `<sha>`, `<sha>: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://<revspec>` URIs. Loads content via
---`git cat-file -p <revspec>`. Worktree comes from `vim.b[buf]
---.git_worktree` if set, else from cwd. Index entries (revspec form
---`:<path>` 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 (`:<path>` with no further `:`) are
-- editable; `:w` rewrites the index entry via `attach_index_writer`.
-- Anything else (HEAD:, <sha>:, :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] <abs>`, 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://<revspec>`
---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:<p>`
--- * stage 2 (ours) <-> stage 3 (theirs)
--- * stage 1 (base) -> bail; ambiguous (suggest `:Gdiffsplit <ref>`)
--- * any other ref -> `:0:<p>`
---
---A `<ref>` 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 <ref>")
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 `<ref>:<rel>` 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 `:<path>`; named refs are `<ref>:<path>`.
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 `<revspec>` 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
+42 -47
View File
@@ -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", "<leader>gg", function()
require("git.sidebar").toggle()
end, { desc = "Toggle git status sidebar" })
vim.keymap.set("n", "<leader>gl", function()
require("git.log").show()
end, { desc = "Show git log" })
vim.keymap.set(
"n",
"<leader>gg",
sidebar.toggle,
{ desc = "Toggle git status sidebar" }
)
vim.keymap.set("n", "<leader>gl", log.show, { desc = "Show git log" })
vim.keymap.set("n", "<leader>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", "<leader>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", "<leader>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", "<leader>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", "<leader>gc", function()
require("git.commit").commit()
commit.commit()
end, { desc = "Git commit" })
vim.keymap.set("n", "<leader>ga", function()
require("git.commit").commit({ amend = true })
commit.commit({ amend = true })
end, { desc = "Git commit --amend" })
vim.keymap.set("n", "<leader>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 <revspec> (vsplit)" }
)
vim.api.nvim_create_user_command(
"Ghdiffsplit",
diff_split_cmd(false),
{ nargs = "?", desc = "Diff against <revspec> (split)" }
)
cmd.setup()
end
return M
+1 -2
View File
@@ -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))
+198 -74
View File
@@ -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 (`:<path>`) index entries are writable through the buffer:
---`:w` rewrites the entry via `hash-object` + `update-index`. All other
---revspecs (HEAD:, <sha>:, :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://<revspec>` 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`, `<sha>`, `<sha>: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://<revspec>` URIs. Loads content via
---`git cat-file -p <revspec>`. Worktree comes from `vim.b[buf]
---.git_worktree` if set, else from cwd. Stage-0 index entries (revspec
---form `:<path>`) 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 `<ref>:<path>`. 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://<revspec>` 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 `<CR>` 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
---`<CR>` 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 `<commit-ref>:<path>` form. Commits route
---to `M.open_commit` for the message + diff view; everything else flows
---through the BufReadCmd loader at the `git://<revspec>` 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: `<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.
-- Tree-entry navigation: `<mode> <type> <sha>\t<name>`. 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
+14
View File
@@ -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 `<commit>:<path>` 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
-52
View File
@@ -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 <ref>` output in a buffer (indented message,
---unified-diff body). For the navigable raw-object form used by the
---gitlog `<CR>` 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
+22 -33
View File
@@ -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
+57
View File
@@ -1,5 +1,62 @@
local M = {}
---@class ow.Git.ParsedRevspec
---@field stage 0|1|2|3? index stage when the revspec is `:<path>` / `:0:<path>` / `:N:<path>`; nil otherwise
---@field path string? path component when the revspec carries one; nil for bare object refs
---Classify a `git://<revspec>` revspec into its stage / path components.
---Recognised forms:
--- * `:<path>` and `:0:<path>` -> stage 0 (the resolved index entry)
--- * `:1:<path>` / `:2:<path>` / `:3:<path>` -> merge stages base / ours / theirs
--- * `<commit-ref>:<path>` -> 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, ...)