refactor(git): load git:// URI buffers via BufReadCmd

This commit is contained in:
2026-04-28 07:34:07 +02:00
parent 4f7edfa184
commit f8cf18a2c0
5 changed files with 127 additions and 95 deletions
+30 -1
View File
@@ -68,10 +68,39 @@ local function first_positional(args, start)
end end
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)
repo.rev_parse(worktree, user_ref, true, function(sha)
local label = sha 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)
end
---@param worktree string ---@param worktree string
---@param args string[] ---@param args string[]
---@param conf ow.Git.SplitHandler ---@param conf ow.Git.SplitHandler
local function run_in_split(worktree, args, conf) 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
local buf = git.new_scratch() local buf = git.new_scratch()
vim.b[buf].git_worktree = worktree vim.b[buf].git_worktree = worktree
if conf.needs_ref then if conf.needs_ref then
@@ -80,7 +109,7 @@ local function run_in_split(worktree, args, conf)
if not vim.api.nvim_buf_is_valid(buf) then if not vim.api.nvim_buf_is_valid(buf) then
return return
end end
pcall(vim.api.nvim_buf_set_name, buf, "git://" .. label .. "/") pcall(vim.api.nvim_buf_set_name, buf, "git://" .. label .. "//")
vim.bo[buf].filetype = conf.ft vim.bo[buf].filetype = conf.ft
end end
repo.rev_parse(worktree, user_ref, true, function(sha) repo.rev_parse(worktree, user_ref, true, function(sha)
+79 -63
View File
@@ -59,33 +59,58 @@ local function attach_index_writer(buf, worktree, path)
}) })
end end
---Internal builder: create a scratch buffer and fill it with the output of ---Return a buffer holding the content at `<ref>:<path>`. `ref` is
---`git show <revspec>`. Synchronous so the buffer is ready by the time the ---"index", "HEAD", or a commit revspec.
---caller wires up windows / `:diffthis`. An empty buffer briefly visible
---to the diff engine produces a spurious whole-file diff.
---@param worktree string ---@param worktree string
---@param revspec string ---@param ref string
---@param is_index boolean ---@param path string
---@param index_path string? required when is_index is true
---@return integer ---@return integer
local function build_show_buf(worktree, revspec, is_index, index_path) function M.git_show_buf(worktree, ref, path)
local buf = vim.api.nvim_create_buf(false, true) local name = "git://" .. ref .. "//" .. path
vim.bo[buf].buftype = "nofile" local buf = vim.fn.bufadd(name)
vim.bo[buf].bufhidden = "wipe" vim.b[buf].git_worktree = worktree
vim.bo[buf].swapfile = false vim.fn.bufload(buf)
return buf
end
---BufReadCmd handler for `git://<ref>//<path>` URIs. Worktree comes from
---`vim.b[buf].git_worktree` if set, else from cwd. Ref of "index" maps
---to `git show :<path>`; "worktree" leaves the buffer empty (placeholder
---for missing files); anything else is a revspec.
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local ref, path = name:match("^git://(.-)//(.*)$")
if not ref or path == "" then
return
end
---@cast path -nil
local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd())
if not worktree then
log.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"
if ref == "worktree" then
vim.bo[buf].buftype = "nofile"
vim.bo[buf].modifiable = false
vim.bo[buf].modified = false
vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf })
return
end
local revspec = ref == "index" and (":" .. path) or (ref .. ":" .. path)
local result = vim.system( local result = vim.system(
{ "git", "show", revspec }, { "git", "show", revspec },
{ cwd = worktree, text = true } { cwd = worktree, text = true }
) )
:wait() :wait()
if result.code ~= 0 then if result.code == 0 then
log.error(
"git show %s failed: %s",
revspec,
vim.trim(result.stderr or "")
)
else
vim.api.nvim_buf_set_lines( vim.api.nvim_buf_set_lines(
buf, buf,
0, 0,
@@ -93,45 +118,51 @@ local function build_show_buf(worktree, revspec, is_index, index_path)
false, false,
util.split_lines(result.stdout or "") util.split_lines(result.stdout or "")
) )
else
log.error(
"git show %s failed: %s",
revspec,
vim.trim(result.stderr or "")
)
end end
if is_index then if ref == "index" then
vim.bo[buf].buftype = "acwrite" vim.bo[buf].buftype = "acwrite"
attach_index_writer(buf, worktree, assert(index_path)) -- 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, path)
vim.b[buf].git_index_writer = true
end
else else
vim.bo[buf].buftype = "nofile"
vim.bo[buf].modifiable = false vim.bo[buf].modifiable = false
end end
vim.bo[buf].modified = false vim.bo[buf].modified = false
return buf
end
---@param worktree string -- BufReadCmd suppresses the normal BufReadPost dispatch, so filetype
---@param ref string '' for index, 'HEAD' or a sha for committed refs -- detection and modeline parsing don't run unless we fire it ourselves.
---@param path string vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf })
---@param is_index boolean? true to hook :w to update the git index
---@return integer
function M.git_show_buf(worktree, ref, path, is_index)
return build_show_buf(worktree, ref .. ":" .. path, is_index or false, path)
end
---@param worktree string
---@param blob string the blob SHA (full or abbreviated)
---@return integer
function M.git_show_blob(worktree, blob)
return build_show_buf(worktree, blob, false, nil)
end end
---@class ow.Git.EmptyBufOpts ---@class ow.Git.EmptyBufOpts
---@field name string? ---@field name string?
---@field bufhidden ("hide"|"wipe")? defaults to "wipe" ---@field bufhidden ("hide"|"wipe")? defaults to "wipe"
---Build a read-only scratch buffer, optionally naming it via ---Build a read-only scratch buffer, optionally naming it. When `opts.name`
---`nvim_buf_set_name` (silently no-op if a buffer with that name ---is set and a loaded buffer with that name already exists, returns it
---already exists). ---instead of creating a duplicate.
---@param opts ow.Git.EmptyBufOpts? ---@param opts ow.Git.EmptyBufOpts?
---@return integer ---@return integer
function M.empty_buf(opts) function M.empty_buf(opts)
opts = opts or {} opts = opts or {}
if opts.name then
local existing = vim.fn.bufnr(opts.name)
if existing ~= -1 and vim.api.nvim_buf_is_loaded(existing) then
return existing
end
end
local buf = vim.api.nvim_create_buf(false, true) local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].buftype = "nofile" vim.bo[buf].buftype = "nofile"
vim.bo[buf].bufhidden = opts.bufhidden or "wipe" vim.bo[buf].bufhidden = opts.bufhidden or "wipe"
@@ -215,30 +246,15 @@ function M.split(opts)
return return
end end
local is_index = opts.ref == "" local label = opts.ref == "" and "index" or opts.ref
local other = M.git_show_buf(worktree, opts.ref, rel, is_index) local uri = "git://" .. label .. "//" .. rel
local label = is_index and "index" or opts.ref -- Stash the worktree on the buffer so the BufReadCmd handler doesn't
M.set_buf_name_and_filetype(other, "git://" .. label .. "/" .. rel) -- fall back to cwd resolution (wrong when cwd != worktree).
local buf = vim.fn.bufadd(uri)
vim.b[buf].git_worktree = worktree
local cur_win = vim.api.nvim_get_current_win() local prefix = opts.vertical and "leftabove vert " or "leftabove "
local other_win = vim.api.nvim_open_win(other, true, { vim.cmd(prefix .. "diffsplit " .. vim.fn.fnameescape(uri))
split = opts.vertical and "left" or "above",
win = cur_win,
})
-- The synthetic index/HEAD buffer can't run BufRead, so its filetype
-- detection in `set_buf_name_and_filetype` only catches
-- filename-pattern matches. Mirror cur_buf's filetype, since this is
-- the same logical file at a different version. Done after the
-- window opens so any BufWinEnter / BufEnter autocmds that fire on
-- nvim_open_win can't undo it.
local cur_ft = vim.bo[cur_buf].filetype
if cur_ft ~= "" then
vim.bo[other].filetype = cur_ft
end
M.set_diff(cur_win, true)
M.set_diff(other_win, true)
end end
return M return M
+8 -1
View File
@@ -58,12 +58,19 @@ function M.setup()
end end
vim.filetype.add({ vim.filetype.add({
pattern = { pattern = {
["git://[^/]+/(.+)"] = function(_, bufnr, inner) ["git://.-//(.+)"] = function(_, bufnr, inner)
return vim.filetype.match({ filename = inner, buf = bufnr }) return vim.filetype.match({ filename = inner, buf = bufnr })
end, end,
}, },
}) })
local group = vim.api.nvim_create_augroup("ow.git", { clear = true }) local group = vim.api.nvim_create_augroup("ow.git", { clear = true })
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "git://*",
group = group,
callback = function(args)
require("git.diff").read_uri(args.buf)
end,
})
vim.api.nvim_create_autocmd( vim.api.nvim_create_autocmd(
{ "BufReadPost", "BufNewFile", "BufWritePost", "FileChangedShellPost" }, { "BufReadPost", "BufNewFile", "BufWritePost", "FileChangedShellPost" },
{ {
+5 -16
View File
@@ -80,30 +80,19 @@ local function is_zero(sha)
return sha == nil or sha:match("^0+$") ~= nil return sha == nil or sha:match("^0+$") ~= nil
end end
---Build a buffer holding the file's content at a given blob, named after the ---Buffer for the file at `<ref>:<path>`. A zero/nil blob (file absent on
---commit ref it corresponds to (so the name lines up with `git log` output ---this side of the diff) yields an empty placeholder.
---instead of an opaque blob hash).
---@param worktree string ---@param worktree string
---@param blob string? ---@param blob string?
---@param path string ---@param path string
---@param ref string the commit ref the blob represents (e.g. `<sha>` or `<sha>^`) ---@param ref string the commit ref the blob represents (e.g. `<sha>` or `<sha>^`)
---@return integer ---@return integer
local function blob_buf(worktree, blob, path, ref) local function blob_buf(worktree, blob, path, ref)
local name = "git://" .. ref .. "/" .. path local name = "git://" .. ref .. "//" .. path
-- Reuse an existing buffer with this name (commit refs and blobs are
-- immutable, so the content is stable); avoids E95 collisions and
-- prevents accumulating duplicate buffers as the user navigates.
local existing = vim.fn.bufnr(name)
if existing ~= -1 and vim.api.nvim_buf_is_loaded(existing) then
return existing
end
if is_zero(blob) then if is_zero(blob) then
return diff.empty_buf({ name = name, bufhidden = "hide" }) return diff.empty_buf({ name = name, bufhidden = "hide" })
end end
---@cast blob string return diff.git_show_buf(worktree, ref, path)
local buf = diff.git_show_blob(worktree, blob)
diff.set_buf_name_and_filetype(buf, name)
return buf
end end
---@param worktree string ---@param worktree string
@@ -143,7 +132,7 @@ end
function M.open_commit(worktree, ref) function M.open_commit(worktree, ref)
repo.rev_parse(worktree, ref, true, function(resolved) repo.rev_parse(worktree, ref, true, function(resolved)
local sha = resolved or ref local sha = resolved or ref
local name = "git://" .. sha .. "/" local name = "git://" .. sha .. "//"
-- Reuse a previously-opened buffer for the same commit; commit SHAs -- Reuse a previously-opened buffer for the same commit; commit SHAs
-- are immutable so the content is stable. -- are immutable so the content is stable.
local existing = vim.fn.bufnr(name) local existing = vim.fn.bufnr(name)
+5 -14
View File
@@ -485,14 +485,14 @@ end
local function head_pane(worktree, path) local function head_pane(worktree, path)
return { return {
buf = diff.git_show_buf(worktree, "HEAD", path), buf = diff.git_show_buf(worktree, "HEAD", path),
name = "git://HEAD/" .. path, name = "git://HEAD//" .. path,
} }
end end
---@param path string ---@param path string
---@return ow.Git.DiffSide ---@return ow.Git.DiffSide
local function head_empty_pane(path) local function head_empty_pane(path)
return { buf = diff.empty_buf(), name = "git://HEAD/" .. path } return { buf = diff.empty_buf(), name = "git://HEAD//" .. path }
end end
---@param worktree string ---@param worktree string
@@ -508,7 +508,7 @@ end
---@param path string ---@param path string
---@return ow.Git.DiffSide ---@return ow.Git.DiffSide
local function worktree_empty_pane(path) local function worktree_empty_pane(path)
return { buf = diff.empty_buf(), name = "git://worktree/" .. path } return { buf = diff.empty_buf(), name = "git://worktree//" .. path }
end end
---@param s ow.Git.SidebarState ---@param s ow.Git.SidebarState
@@ -520,9 +520,9 @@ local function index_pane(s, entry)
or (entry.section == "Staged" and entry.x == "D") or (entry.section == "Staged" and entry.x == "D")
) )
return { return {
buf = in_index and diff.git_show_buf(s.worktree, "", entry.path, true) buf = in_index and diff.git_show_buf(s.worktree, "index", entry.path)
or diff.empty_buf(), or diff.empty_buf(),
name = "git://index/" .. entry.path, name = "git://index//" .. entry.path,
} }
end end
@@ -727,15 +727,6 @@ local function show_diff(s, entry, focus_left)
diff.set_buf_name_and_filetype(side.buf, side.name) diff.set_buf_name_and_filetype(side.buf, side.name)
end end
end end
-- Synthetic index/HEAD buffers never run BufRead, so modeline-only
-- filetypes aren't detected. Copy from the side that did resolve.
local left_ft = vim.bo[pair.left.buf].filetype
local right_ft = vim.bo[pair.right.buf].filetype
if left_ft == "" and right_ft ~= "" then
vim.bo[pair.left.buf].filetype = right_ft
elseif right_ft == "" and left_ft ~= "" then
vim.bo[pair.right.buf].filetype = left_ft
end
diff.set_diff(left_win, true) diff.set_diff(left_win, true)
diff.set_diff(right_win, true) diff.set_diff(right_win, true)
s.last_shown_key = key s.last_shown_key = key