refactor(git): introduce Revision class, normalize naming, slim docs

This commit is contained in:
2026-04-30 09:44:24 +02:00
parent 775add9b15
commit d95de4bc1d
10 changed files with 244 additions and 428 deletions
+14 -26
View File
@@ -7,9 +7,8 @@ local M = {}
---@class ow.Git.SplitHandler ---@class ow.Git.SplitHandler
---@field ft string ---@field ft string
---@field needs_ref boolean? ---@field needs_rev boolean?
---Subcommands whose output goes to a buffer.
---@type table<string, ow.Git.SplitHandler> ---@type table<string, ow.Git.SplitHandler>
local SPLIT_HANDLERS = { local SPLIT_HANDLERS = {
log = { ft = "git" }, log = { ft = "git" },
@@ -34,7 +33,6 @@ local function populate_cached_cmds(result)
table.sort(cached_cmds) table.sort(cached_cmds)
end end
---Prime `cached_cmds` asynchronously so the first `:G <Tab>` doesn't block.
local function prefetch_cmds() local function prefetch_cmds()
vim.system( vim.system(
{ "git", "--list-cmds=main,others,alias" }, { "git", "--list-cmds=main,others,alias" },
@@ -85,10 +83,6 @@ local function place_split(name)
return buf return buf
end 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 worktree string
---@param args string[] ---@param args string[]
---@param conf ow.Git.SplitHandler ---@param conf ow.Git.SplitHandler
@@ -104,15 +98,15 @@ local function run_in_split(worktree, args, conf)
local name = "[git " .. table.concat(args, " ") .. "]" local name = "[git " .. table.concat(args, " ") .. "]"
local buf = place_split(name) local buf = place_split(name)
vim.b[buf].git_worktree = worktree vim.b[buf].git_worktree = worktree
vim.b[buf].git_ref = nil vim.b[buf].git_sha = nil
vim.b[buf].git_parent_ref = nil vim.b[buf].git_parent_sha = nil
if conf.needs_ref then if conf.needs_rev then
local user_ref = first_positional(args, 2) or "HEAD" local user_rev = first_positional(args, 2) or "HEAD"
local sha = repo.rev_parse(worktree, user_ref, true) local sha = repo.rev_parse(worktree, user_rev, true)
if sha then if sha then
vim.b[buf].git_ref = sha vim.b[buf].git_sha = sha
vim.b[buf].git_parent_ref = vim.b[buf].git_parent_sha =
repo.rev_parse(worktree, user_ref .. "^", true) repo.rev_parse(worktree, user_rev .. "^", true)
end end
end end
vim.bo[buf].filetype = conf.ft vim.bo[buf].filetype = conf.ft
@@ -206,31 +200,25 @@ function M.run(args)
return return
end 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 if sub == "show" then
local arg = first_positional(args, 2) local arg = first_positional(args, 2)
if arg and arg:find(":", 1, true) then if arg and arg:find(":", 1, true) then
object.open_object(worktree, arg) object.open_object(worktree, arg)
return return
end end
run_in_split(worktree, args, { ft = "git", needs_ref = true }) run_in_split(worktree, args, { ft = "git", needs_rev = true })
return return
end 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 sub == "cat-file" then
if vim.list_contains(args, "-p") then if vim.list_contains(args, "-p") then
local ref = first_positional(args, 2) local rev = first_positional(args, 2)
if ref then if rev then
object.open_object(worktree, ref) object.open_object(worktree, rev)
return return
end end
end end
run_in_split(worktree, args, { ft = "git", needs_ref = true }) run_in_split(worktree, args, { ft = "git", needs_rev = true })
return return
end end
+41 -79
View File
@@ -1,15 +1,9 @@
local Revision = require("git.revision")
local repo = require("git.repo") local repo = require("git.repo")
local util = require("git.util") local util = require("git.util")
local M = {} local M = {}
---Toggle a window into or out of Vim's diff mode. Goes through the
---`:diffthis` / `:diffoff` commands rather than `vim.wo[win].diff = X`.
---The command path runs Vim's full `diff_win_options` setup, which sets
---an internal flag that prevents subsequently-created floats from
---inheriting `'diff' = 1` when opened from a focused diff pane. The raw
---option setter skips that setup, so floats end up joining the
---tabpage's diff group and corrupting its render.
---@param win integer ---@param win integer
---@param enabled boolean ---@param enabled boolean
function M.set_diff(win, enabled) function M.set_diff(win, enabled)
@@ -18,16 +12,10 @@ function M.set_diff(win, enabled)
end) end)
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`). Drops a `'` jumplist mark first so `''` jumps back.
---@param left integer ---@param left integer
---@param right integer ---@param right integer
---@param vertical boolean ---@param vertical boolean
function M.open(left, right, vertical) function M.open(left, right, vertical)
-- Read the name first: an empty placeholder has `bufhidden=wipe`,
-- so `nvim_set_current_buf(right)` below wipes `left` before a
-- name lookup could see it. `:diffsplit` re-creates from the name.
local left_name = vim.api.nvim_buf_get_name(left) local left_name = vim.api.nvim_buf_get_name(left)
vim.cmd.normal({ "m'", bang = true }) vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(right) vim.api.nvim_set_current_buf(right)
@@ -38,13 +26,6 @@ function M.open(left, right, vertical)
}) })
end 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 are renamed
---and re-run filetype detection.
---@param left_win integer ---@param left_win integer
---@param right_win integer ---@param right_win integer
---@param pair ow.Git.DiffPair ---@param pair ow.Git.DiffPair
@@ -65,8 +46,6 @@ function M.update_pair(left_win, right_win, pair)
vim.cmd.syncbind() vim.cmd.syncbind()
end end
---Open two buffers as a diff. `a_left` puts `buf_a` in the leftabove
---slot (where `M.open` parks the cursor).
---@param buf_a integer ---@param buf_a integer
---@param buf_b integer ---@param buf_b integer
---@param a_left boolean ---@param a_left boolean
@@ -79,97 +58,82 @@ local function place_pair(buf_a, buf_b, a_left, vertical)
end end
end end
---Dispatch for `M.split` when the current buffer is a `git://<revspec>`
---URI. Placement follows the older-on-left convention.
---
---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 the
---above and pairs cur with that revspec literally.
---@param opts ow.Git.SplitOpts ---@param opts ow.Git.SplitOpts
---@param cur_buf integer ---@param buf integer
---@param cur_revspec string ---@param rev ow.Git.Revision
local function uri_split(opts, cur_buf, cur_revspec) local function uri_split(opts, buf, rev)
local worktree = vim.b[cur_buf].git_worktree local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd())
or select(2, repo.resolve_cwd())
if not worktree then if not worktree then
util.warning("git URI buffer has no worktree") util.warning("git URI buffer has no worktree")
return return
end end
local cur = util.parse_revspec(cur_revspec) if not rev.path then
if not cur.path then
util.warning("git URI has no path, cannot diff against worktree") util.warning("git URI has no path, cannot diff against worktree")
return return
end end
-- Lazy-required to break the load-time cycle with `git.object`.
local object = require("git.object") local object = require("git.object")
if opts.revspec ~= "" and opts.revspec:find(":", 1, true) then if opts.rev and opts.rev:find(":", 1, true) then
local content = util.exec( local content = util.exec(
{ "git", "cat-file", "-p", opts.revspec }, { "git", "cat-file", "-p", opts.rev },
{ cwd = worktree, silent = true } { cwd = worktree, silent = true }
) )
if not content then if not content then
util.warning("invalid revspec: %s", opts.revspec) util.warning("invalid rev: %s", opts.rev)
return return
end end
place_pair( place_pair(
cur_buf, buf,
object.buf_for(worktree, opts.revspec, content), object.buf_for(worktree, Revision.parse(opts.rev), content),
false, false,
opts.vertical opts.vertical
) )
return return
end end
if opts.revspec == "" then if not opts.rev then
local worktree_path = vim.fs.joinpath(worktree, cur.path) local worktree_path = vim.fs.joinpath(worktree, rev.path)
if not vim.uv.fs_stat(worktree_path) then if not vim.uv.fs_stat(worktree_path) then
util.warning("worktree file does not exist: %s", cur.path) util.warning("worktree file does not exist: %s", rev.path)
return return
end end
local worktree_buf = vim.fn.bufadd(worktree_path) local worktree_buf = vim.fn.bufadd(worktree_path)
vim.fn.bufload(worktree_buf) vim.fn.bufload(worktree_buf)
place_pair(cur_buf, worktree_buf, true, opts.vertical) place_pair(buf, worktree_buf, true, opts.vertical)
return return
end end
if cur.stage == 1 then if rev.stage == 1 then
util.warning("gD on merge base is ambiguous, use :Gdiffsplit <ref>") util.warning("gD on merge base is ambiguous, use :Gdiffsplit <rev>")
return return
end end
local mapping = { local mapping = {
[2] = { ":3:" .. cur.path, true }, [2] = { Revision.new({ stage = 3, path = rev.path }), true },
[3] = { ":2:" .. cur.path, false }, [3] = { Revision.new({ stage = 2, path = rev.path }), false },
[0] = { "HEAD:" .. cur.path, false }, [0] = { Revision.new({ base = "HEAD", path = rev.path }), false },
} }
local m = mapping[cur.stage] or { ":0:" .. cur.path, true } local m = mapping[rev.stage]
local other_revspec, cur_left = m[1], m[2] or { Revision.new({ stage = 0, path = rev.path }), true }
local other_rev, left = m[1], m[2]
local content = util.exec( local content = util.exec(
{ "git", "cat-file", "-p", other_revspec }, { "git", "cat-file", "-p", other_rev:format() },
{ cwd = worktree, silent = true } { cwd = worktree, silent = true }
) )
if not content then if not content then
util.warning("invalid revspec: %s", other_revspec) util.warning("invalid rev: %s", other_rev:format())
return return
end end
place_pair( place_pair(
cur_buf, buf,
object.buf_for(worktree, other_revspec, content), object.buf_for(worktree, other_rev, content),
cur_left, left,
opts.vertical opts.vertical
) )
end end
---@class ow.Git.SplitOpts ---@class ow.Git.SplitOpts
---@field revspec string `''` for smart-default routing (index vs worktree). A plain ref like `'HEAD'` compares `<ref>:<rel>` against the current path. A full revspec containing `:` (e.g. `':2:foo'`, `'HEAD~1:other.lua'`) is used as-is. ---@field rev string?
---@field vertical boolean ---@field vertical boolean
---@param opts ow.Git.SplitOpts ---@param opts ow.Git.SplitOpts
@@ -177,9 +141,9 @@ function M.split(opts)
local cur_buf = vim.api.nvim_get_current_buf() local cur_buf = vim.api.nvim_get_current_buf()
local cur_path = vim.api.nvim_buf_get_name(cur_buf) local cur_path = vim.api.nvim_buf_get_name(cur_buf)
local cur_revspec = util.parse_uri(cur_path) local cur_rev = Revision.from_uri(cur_path)
if cur_revspec then if cur_rev then
return uri_split(opts, cur_buf, cur_revspec) return uri_split(opts, cur_buf, cur_rev)
end end
if cur_path == "" then if cur_path == "" then
@@ -201,25 +165,23 @@ function M.split(opts)
return return
end end
local revspec local rev
if opts.revspec == "" then if not opts.rev then
revspec = ":0:" .. rel rev = Revision.new({ stage = 0, path = rel })
elseif opts.revspec:find(":", 1, true) then elseif opts.rev:find(":", 1, true) then
revspec = opts.revspec rev = Revision.parse(opts.rev)
else else
revspec = opts.revspec .. ":" .. rel rev = Revision.new({ base = opts.rev, path = rel })
end end
local content = util.exec( local content = util.exec(
{ "git", "cat-file", "-p", revspec }, { "git", "cat-file", "-p", rev:format() },
{ cwd = worktree, silent = true } { cwd = worktree, silent = true }
) )
if not content then if not content then
util.warning("invalid revspec: %s", revspec) util.warning("invalid rev: %s", rev:format())
return return
end end
local object = require("git.object") local buf = require("git.object").buf_for(worktree, rev, content)
local buf = object.buf_for(worktree, revspec, content)
-- Revspec snapshot is older than the worktree, so it goes left.
place_pair(buf, cur_buf, true, opts.vertical) place_pair(buf, cur_buf, true, opts.vertical)
end end
-22
View File
@@ -4,11 +4,6 @@ local M = {}
local SENTINEL = "__NVIM_GIT_EDIT__" local SENTINEL = "__NVIM_GIT_EDIT__"
-- Per-invocation: each `sh -c` body picks a flag file via `$$`,
-- prints sentinel + flag-path + abs-path on stderr, then polls the
-- flag until Neovim writes it. `rebase -i` fires the editor many
-- times (todo + each reword). Each call is a fresh shell with a
-- fresh `$$`, so flags don't collide.
local SCRIPT = string.format( local SCRIPT = string.format(
[=[set -eu [=[set -eu
flag="${TMPDIR:-/tmp}/nvim-git-editor-$$.done" flag="${TMPDIR:-/tmp}/nvim-git-editor-$$.done"
@@ -22,7 +17,6 @@ done
SENTINEL SENTINEL
) )
---POSIX shell single-quote escape: foo'bar -> 'foo'\''bar'.
---@param s string ---@param s string
---@return string ---@return string
local function shq(s) local function shq(s)
@@ -31,11 +25,6 @@ end
local GIT_EDITOR = "sh -c " .. shq(SCRIPT) .. " --" local GIT_EDITOR = "sh -c " .. shq(SCRIPT) .. " --"
---Build a stderr callback that strips our sentinel lines, accumulates
---the rest, and dispatches `on_open(abs, done)` for each sentinel seen.
---The `finalize(result)` helper drains any trailing partial line into
---`result.stderr` so the caller can treat `result` like a plain
---`vim.system` result.
---@param on_open fun(file_path: string, done: fun()) ---@param on_open fun(file_path: string, done: fun())
---@return fun(err: string?, data: string?), fun(result: vim.SystemCompleted) ---@return fun(err: string?, data: string?), fun(result: vim.SystemCompleted)
local function build_stderr_handler(on_open) local function build_stderr_handler(on_open)
@@ -97,17 +86,6 @@ local function build_stderr_handler(on_open)
return on_stderr, finalize return on_stderr, finalize
end end
---Run a git command with an editor proxy active. When git invokes the
---editor, `on_open` fires with the absolute file path git wants edited
---plus a `done` callback. The caller opens the file in a buffer and
---invokes `done()` once the user is finished (typically from a
---`BufWipeout` autocmd). For commands that fire the editor more than
---once in a single git invocation (`git rebase -i`, with one call for
---the todo and one per `reword`), `on_open` is invoked once per
---editor handoff.
---
---`on_exit` is called on the main loop with a `vim.SystemCompleted`-
---shaped result. `result.stderr` has our protocol sentinels stripped.
---@param cmd string[] ---@param cmd string[]
---@param opts? { cwd?: string, env?: table<string,string> } ---@param opts? { cwd?: string, env?: table<string,string> }
---@param on_open fun(file_path: string, done: fun()) ---@param on_open fun(file_path: string, done: fun())
+19 -19
View File
@@ -1,11 +1,3 @@
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 = { local HIGHLIGHTS = {
GitDeleted = "Removed", GitDeleted = "Removed",
GitIgnored = "Comment", GitIgnored = "Comment",
@@ -28,10 +20,16 @@ end
---@param path string ---@param path string
---@return string? ---@return string?
function M.head(path) function M.head(path)
return repo.head(path) return require("git.repo").head(path)
end end
function M.setup() function M.setup()
local cmd = require("git.cmd")
local commit = require("git.commit")
local diff = require("git.diff")
local log = require("git.log")
local repo = require("git.repo")
for name, link in pairs(HIGHLIGHTS) do for name, link in pairs(HIGHLIGHTS) do
vim.api.nvim_set_hl(0, name, { link = link, default = true }) vim.api.nvim_set_hl(0, name, { link = link, default = true })
end end
@@ -40,7 +38,7 @@ function M.setup()
pattern = "git://*", pattern = "git://*",
group = group, group = group,
callback = function(args) callback = function(args)
object.read_uri(args.buf) require("git.object").read_uri(args.buf)
end, end,
}) })
vim.api.nvim_create_autocmd("BufReadCmd", { vim.api.nvim_create_autocmd("BufReadCmd", {
@@ -81,21 +79,23 @@ function M.setup()
vim.keymap.set( vim.keymap.set(
"n", "n",
"<leader>gg", "<leader>gg",
sidebar.toggle, require("git.sidebar").toggle,
{ desc = "Toggle git status sidebar" } { desc = "Toggle git status sidebar" }
) )
vim.keymap.set("n", "<leader>gl", log.open, { desc = "Show git log" }) vim.keymap.set("n", "<leader>gl", function()
log.open({ max_count = 1000 })
end, { desc = "Show git log" })
vim.keymap.set("n", "<leader>gd", function() vim.keymap.set("n", "<leader>gd", function()
diff.split({ revspec = "", vertical = true }) diff.split({ vertical = true })
end, { desc = "Diff index vs worktree (vsplit)" }) end, { desc = "Diff index vs worktree (vsplit)" })
vim.keymap.set("n", "<leader>gD", function() vim.keymap.set("n", "<leader>gD", function()
diff.split({ revspec = "HEAD", vertical = true }) diff.split({ rev = "HEAD", vertical = true })
end, { desc = "Diff HEAD vs worktree (vsplit)" }) end, { desc = "Diff HEAD vs worktree (vsplit)" })
vim.keymap.set("n", "<leader>gh", function() vim.keymap.set("n", "<leader>gh", function()
diff.split({ revspec = "", vertical = false }) diff.split({ vertical = false })
end, { desc = "Diff index vs worktree (split)" }) end, { desc = "Diff index vs worktree (split)" })
vim.keymap.set("n", "<leader>gH", function() vim.keymap.set("n", "<leader>gH", function()
diff.split({ revspec = "HEAD", vertical = false }) diff.split({ rev = "HEAD", vertical = false })
end, { desc = "Diff HEAD vs worktree (split)" }) end, { desc = "Diff HEAD vs worktree (split)" })
vim.keymap.set("n", "<leader>gc", function() vim.keymap.set("n", "<leader>gc", function()
commit.commit() commit.commit()
@@ -110,7 +110,7 @@ function M.setup()
local function diff_split_cmd(vertical) local function diff_split_cmd(vertical)
return function(opts) return function(opts)
diff.split({ diff.split({
revspec = opts.args, rev = opts.args ~= "" and opts.args or nil,
vertical = vertical, vertical = vertical,
}) })
end end
@@ -118,12 +118,12 @@ function M.setup()
vim.api.nvim_create_user_command( vim.api.nvim_create_user_command(
"Gdiffsplit", "Gdiffsplit",
diff_split_cmd(true), diff_split_cmd(true),
{ nargs = "?", desc = "Diff against <revspec> (vsplit)" } { nargs = "?", desc = "Diff against <rev> (vsplit)" }
) )
vim.api.nvim_create_user_command( vim.api.nvim_create_user_command(
"Ghdiffsplit", "Ghdiffsplit",
diff_split_cmd(false), diff_split_cmd(false),
{ nargs = "?", desc = "Diff against <revspec> (split)" } { nargs = "?", desc = "Diff against <rev> (split)" }
) )
cmd.setup() cmd.setup()
+5 -12
View File
@@ -4,11 +4,10 @@ local util = require("git.util")
local M = {} local M = {}
local LOG_FORMAT = "%h %ad {%an}%d %s" local LOG_FORMAT = "%h %ad {%an}%d %s"
local DEFAULT_MAX_COUNT = 1000
local URI_PREFIX = "gitlog://" local URI_PREFIX = "gitlog://"
---@param worktree string ---@param worktree string
---@param max_count integer ---@param max_count integer?
---@return string? ---@return string?
local function fetch(worktree, max_count) local function fetch(worktree, max_count)
local cmd = { local cmd = {
@@ -20,7 +19,7 @@ local function fetch(worktree, max_count)
"--date=short", "--date=short",
"--format=format:" .. LOG_FORMAT, "--format=format:" .. LOG_FORMAT,
} }
if max_count > 0 then if max_count then
table.insert(cmd, "--max-count=" .. max_count) table.insert(cmd, "--max-count=" .. max_count)
end end
return util.exec(cmd, { cwd = worktree }) return util.exec(cmd, { cwd = worktree })
@@ -29,8 +28,7 @@ end
---@param buf integer ---@param buf integer
local function populate(buf) local function populate(buf)
local worktree = vim.b[buf].git_worktree local worktree = vim.b[buf].git_worktree
local max_count = vim.b[buf].git_log_max_count or DEFAULT_MAX_COUNT local stdout = fetch(worktree, vim.b[buf].git_log_max_count)
local stdout = fetch(worktree, max_count)
if not stdout then if not stdout then
return return
end end
@@ -64,7 +62,6 @@ local function populate(buf)
vim.bo[buf].modified = false vim.bo[buf].modified = false
end end
---BufReadCmd handler for `gitlog://<worktree>` URIs.
---@param buf integer ---@param buf integer
function M.read_uri(buf) function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf) local name = vim.api.nvim_buf_get_name(buf)
@@ -77,8 +74,6 @@ function M.read_uri(buf)
vim.bo[buf].swapfile = false vim.bo[buf].swapfile = false
vim.bo[buf].bufhidden = "hide" vim.bo[buf].bufhidden = "hide"
vim.bo[buf].buftype = "nofile" vim.bo[buf].buftype = "nofile"
-- Skip the assignment when ft is already set so re-runs don't
-- re-fire `FileType` autocmds (ftplugin reload, treesitter attach).
if vim.bo[buf].filetype ~= "gitlog" then if vim.bo[buf].filetype ~= "gitlog" then
vim.bo[buf].filetype = "gitlog" vim.bo[buf].filetype = "gitlog"
end end
@@ -87,7 +82,7 @@ function M.read_uri(buf)
end end
---@class ow.Git.LogOpts ---@class ow.Git.LogOpts
---@field max_count integer? cap on commits to show. Nil uses the default, <= 0 means "all" ---@field max_count integer?
---@param opts ow.Git.LogOpts? ---@param opts ow.Git.LogOpts?
function M.open(opts) function M.open(opts)
@@ -100,7 +95,7 @@ function M.open(opts)
local buf = vim.fn.bufadd(URI_PREFIX .. worktree) local buf = vim.fn.bufadd(URI_PREFIX .. worktree)
vim.b[buf].git_worktree = worktree vim.b[buf].git_worktree = worktree
vim.b[buf].git_log_max_count = opts.max_count or DEFAULT_MAX_COUNT vim.b[buf].git_log_max_count = opts.max_count
local was_loaded = vim.api.nvim_buf_is_loaded(buf) local was_loaded = vim.api.nvim_buf_is_loaded(buf)
local win = vim.fn.bufwinid(buf) local win = vim.fn.bufwinid(buf)
@@ -110,8 +105,6 @@ function M.open(opts)
vim.api.nvim_set_current_win(win) vim.api.nvim_set_current_win(win)
end end
-- `place_buf` triggers `bufload` -> `read_uri` for a fresh buffer.
-- An already-loaded buffer needs an explicit refresh.
if was_loaded then if was_loaded then
populate(buf) populate(buf)
end end
+77 -126
View File
@@ -1,36 +1,30 @@
local diff = require("git.diff") local Revision = require("git.revision")
local repo = require("git.repo") local repo = require("git.repo")
local util = require("git.util") local util = require("git.util")
local M = {} local M = {}
---@class ow.Git.DiffSection ---@class ow.Git.DiffSection
---@field pre_path string path on the parent side (`a/...`) ---@field path_a string
---@field post_path string path on the current side (`b/...`) ---@field path_b string
---@field pre_blob string? ---@field blob_a string?
---@field post_blob string? ---@field blob_b string?
---@class ow.Git.BufContext ---@class ow.Git.BufContext
---@field worktree string ---@field worktree string
---@field ref string resolved commit SHA of the gitobject buffer ---@field sha string
---@field parent_ref string? resolved parent commit SHA, nil for root commits ---@field parent_sha string?
---@return ow.Git.BufContext? ---@return ow.Git.BufContext?
local function context() local function context()
local worktree = vim.b.git_worktree local worktree = vim.b.git_worktree
local ref = vim.b.git_ref local sha = vim.b.git_sha
if not worktree or not ref then if not worktree or not sha then
return nil return nil
end end
return { worktree = worktree, ref = ref, parent_ref = vim.b.git_parent_ref } return { worktree = worktree, sha = sha, parent_sha = vim.b.git_parent_sha }
end end
---Find the enclosing `diff --git` line and parse the section's pre/post
---paths plus the pre/post blob SHAs from the `index` line.
---
---Uses `vim.fn.search('bcnW')` (backward, accept cursor pos, no move, no
---wrap) so a giant `git show <merge>` buffer doesn't pay an O(cursor_lnum)
---array allocation on every <CR>.
---@return ow.Git.DiffSection? ---@return ow.Git.DiffSection?
local function diff_section() local function diff_section()
local diff_lnum = vim.fn.search("^diff --git ", "bcnW") local diff_lnum = vim.fn.search("^diff --git ", "bcnW")
@@ -42,33 +36,30 @@ local function diff_section()
if not diff_line then if not diff_line then
return nil return nil
end end
local pre_path, post_path = diff_line:match("^diff %-%-git a/(.-) b/(.+)$") local path_a, path_b = diff_line:match("^diff %-%-git a/(.-) b/(.+)$")
if not pre_path or not post_path then if not path_a or not path_b then
return nil return nil
end end
-- Header lines (mode/index/oldfile/newfile/etc) sit between the
-- `diff --git` line and the first `@@` hunk. Cap the read at 20 to
-- bound work even for unusual diff headers.
local header = local header =
vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false) vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false)
local pre_blob, post_blob local blob_a, blob_b
for _, l in ipairs(header) do for _, l in ipairs(header) do
if l:sub(1, 3) == "@@ " or l:match("^diff %-%-git ") then if l:sub(1, 3) == "@@ " or l:match("^diff %-%-git ") then
break break
end end
local pre, post = l:match("^index (%x+)%.%.(%x+)") local pre, post = l:match("^index (%x+)%.%.(%x+)")
if pre then if pre then
pre_blob = pre blob_a = pre
post_blob = post blob_b = post
break break
end end
end end
return { return {
pre_path = pre_path, path_a = path_a,
post_path = post_path, path_b = path_b,
pre_blob = pre_blob, blob_a = blob_a,
post_blob = post_blob, blob_b = blob_b,
} }
end end
@@ -78,9 +69,6 @@ local function is_zero(sha)
return sha == nil or sha:match("^0+$") ~= nil return sha == nil or sha:match("^0+$") ~= nil
end 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 buf integer
---@param worktree string ---@param worktree string
---@param path string ---@param path string
@@ -115,9 +103,9 @@ local function attach_index_writer(buf, worktree, path)
end end
vim.b[buf].git_index_mode = mode vim.b[buf].git_index_mode = mode
end end
-- Use the 3-arg form (mode sha path) instead of the comma form -- Use the 3-arg form (mode sha path) instead of the comma
-- (mode,sha,path), which doesn't survive paths containing a -- form (mode,sha,path), which doesn't survive paths
-- comma. -- containing a comma.
if if
not util.exec({ not util.exec({
"git", "git",
@@ -135,18 +123,15 @@ local function attach_index_writer(buf, worktree, path)
}) })
end end
---Pre-fetched content keyed by bufnr, consumed once by `read_uri`.
---@type table<integer, string> ---@type table<integer, string>
local pending_content = {} local pending_content = {}
---Return a `git://<revspec>` URI buffer. Pass `content` to prime
---`read_uri`'s cache and skip its `cat-file -p` fetch.
---@param worktree string ---@param worktree string
---@param revspec string any revspec git understands (e.g. `HEAD:foo`, `:foo`, `:1:foo`, `<sha>`, `<sha>:foo`) ---@param rev ow.Git.Revision
---@param content string? ---@param content string?
---@return integer ---@return integer
function M.buf_for(worktree, revspec, content) function M.buf_for(worktree, rev, content)
local buf = vim.fn.bufadd(util.uri(revspec)) local buf = vim.fn.bufadd(rev:uri())
vim.b[buf].git_worktree = worktree vim.b[buf].git_worktree = worktree
if content then if content then
pending_content[buf] = content pending_content[buf] = content
@@ -155,17 +140,14 @@ function M.buf_for(worktree, revspec, content)
return buf return buf
end end
---BufReadCmd handler for `git://<revspec>` URIs. Worktree comes from
---`b:git_worktree` if set, else from cwd. Stage-0 index entries
---(`:<path>`) are made writable so `:w` updates the index. Other
---revspecs are read-only.
---@param buf integer ---@param buf integer
function M.read_uri(buf) function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf) local name = vim.api.nvim_buf_get_name(buf)
local revspec = util.parse_uri(name) local rev = Revision.from_uri(name)
if not revspec then if not rev then
return return
end end
local rev_str = rev:format()
local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd()) local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd())
if not worktree then if not worktree then
@@ -182,25 +164,14 @@ function M.read_uri(buf)
pending_content[buf] = nil pending_content[buf] = nil
if stdout == nil then if stdout == nil then
stdout = util.exec( stdout = util.exec(
{ "git", "cat-file", "-p", revspec }, { "git", "cat-file", "-p", rev_str },
{ cwd = worktree } { cwd = worktree }
) )
end end
local parsed = util.parse_revspec(revspec) if stdout and rev.path == nil then
-- Bare-ref objects that dereference to a commit (commits, stashes,
-- annotated tags pointing at a commit, lightweight tags) get their
-- `diff-tree -p` patch appended so the buffer is navigable. The
-- `<CR>` parser walks `diff --git` blocks. `^{commit}` is git's
-- standard "deref to commit" suffix. rev-parse fails for non-commit
-- objects (trees, blobs, tags pointing at non-commits) so they
-- naturally skip the append. `-m --first-parent` collapses merges
-- and stashes into one diff per file (vs `diff --cc` combined
-- diffs, which the parser can't follow).
if stdout and parsed.path == nil then
local commit_sha = local commit_sha =
repo.rev_parse(worktree, revspec .. "^{commit}", true) repo.rev_parse(worktree, rev_str .. "^{commit}", true)
if commit_sha then if commit_sha then
local patch = util.exec({ local patch = util.exec({
"git", "git",
@@ -215,31 +186,25 @@ function M.read_uri(buf)
if patch then if patch then
stdout = (stdout:gsub("\n*$", "\n\n")) .. patch stdout = (stdout:gsub("\n*$", "\n\n")) .. patch
end end
vim.b[buf].git_parent_ref = vim.b[buf].git_parent_sha =
repo.rev_parse(worktree, commit_sha .. "^", true) repo.rev_parse(worktree, commit_sha .. "^", true)
end end
end end
if stdout then if stdout then
-- Reload paths (`:e`, `<C-o>` to an unloaded buf) re-enter
-- with `modifiable = false` from the prior load.
vim.bo[buf].modifiable = true vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout)) vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout))
end end
-- `b:git_ref` anchors `<CR>`-driven navigation in this buffer. local rev_sha = repo.rev_parse(worktree, rev_str, true)
local ref_sha = repo.rev_parse(worktree, revspec, true) if rev_sha then
if ref_sha then vim.b[buf].git_sha = rev_sha
vim.b[buf].git_ref = ref_sha
end end
if parsed.stage == 0 and parsed.path then if rev.stage == 0 and rev.path then
vim.bo[buf].buftype = "acwrite" 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 if not vim.b[buf].git_index_writer then
attach_index_writer(buf, worktree, parsed.path) attach_index_writer(buf, worktree, rev.path)
vim.b[buf].git_index_writer = true vim.b[buf].git_index_writer = true
end end
else else
@@ -248,15 +213,12 @@ function M.read_uri(buf)
end end
vim.bo[buf].modified = false vim.bo[buf].modified = false
-- Filetype from the inner path. We can't lean on `vim.filetype.add` -- Match on the inner path directly. `vim.filetype.add` patterns
-- because Vim normalises `git://` filenames (cwd-prefix + collapses -- don't work because Vim normalises `git://` filenames (cwd-prefix
-- `://` to `:/`) before matching, breaking any pattern keyed on the -- + `://` -> `:/`) before matching, breaking any pattern keyed on
-- raw scheme as well as any built-in pattern that doesn't catch a -- the raw scheme.
-- recognisable extension on the mangled form (.Xresources is the if rev.path then
-- canonical example). Bare-ref content (commit/tag headers + tree local ft = vim.filetype.match({ filename = rev.path, buf = buf })
-- listings) uses the built-in `git` filetype.
if parsed.path then
local ft = vim.filetype.match({ filename = parsed.path, buf = buf })
if ft then if ft then
vim.bo[buf].filetype = ft vim.bo[buf].filetype = ft
end end
@@ -264,34 +226,29 @@ function M.read_uri(buf)
vim.bo[buf].filetype = "git" vim.bo[buf].filetype = "git"
end end
-- BufReadCmd suppresses the normal BufReadPost dispatch, so
-- modeline parsing doesn't run unless we fire it ourselves. The
-- modeline can still override the filetype set above (standard Vim
-- precedence).
vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf })
end end
---Buffer for the file at `<ref>:<path>`. Returns nil for a zero/nil blob.
---@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 sha string
---@return integer? ---@return integer?
local function blob_buf(worktree, blob, path, ref) local function blob_buf(worktree, blob, path, sha)
if is_zero(blob) then if is_zero(blob) then
return nil return nil
end end
return M.buf_for(worktree, ref .. ":" .. path) return M.buf_for(worktree, Revision.new({ base = sha, path = path }))
end end
---@param worktree string ---@param worktree string
---@param blob string? ---@param blob string?
---@param path string ---@param path string
---@param ref string ---@param sha string
local function load_blob(worktree, blob, path, ref) local function load_blob(worktree, blob, path, sha)
local buf = blob_buf(worktree, blob, path, ref) local buf = blob_buf(worktree, blob, path, sha)
if not buf then if not buf then
util.warning("no content for %s at %s", path, ref) util.warning("no content for %s at %s", path, sha)
return return
end end
vim.cmd.normal({ "m'", bang = true }) vim.cmd.normal({ "m'", bang = true })
@@ -301,21 +258,20 @@ end
---@param ctx ow.Git.BufContext ---@param ctx ow.Git.BufContext
---@param section ow.Git.DiffSection ---@param section ow.Git.DiffSection
local function open_section(ctx, section) local function open_section(ctx, section)
if not section.pre_blob or not section.post_blob then if not section.blob_a or not section.blob_b then
util.warning("no index line, cannot determine blob SHAs") util.warning("no index line, cannot determine blob SHAs")
return return
end end
local parent = ctx.parent_ref or "0" local parent = ctx.parent_sha or "0"
local left = local left = blob_buf(ctx.worktree, section.blob_a, section.path_a, parent)
blob_buf(ctx.worktree, section.pre_blob, section.pre_path, parent)
local right = local right =
blob_buf(ctx.worktree, section.post_blob, section.post_path, ctx.ref) blob_buf(ctx.worktree, section.blob_b, section.path_b, ctx.sha)
if left and right then if left and right then
diff.open(left, right, true) require("git.diff").open(left, right, true)
return return
end end
if not left and not right then if not left and not right then
util.warning("no content for %s", section.post_path) util.warning("no content for %s", section.path_b)
return return
end end
local buf = left or right local buf = left or right
@@ -325,36 +281,32 @@ local function open_section(ctx, section)
end end
---@class ow.Git.OpenObjectOpts ---@class ow.Git.OpenObjectOpts
---@field split (false|"above"|"below"|"left"|"right")? forwarded to `util.place_buf`. Default opens a new horizontal split. ---@field split (false|"above"|"below"|"left"|"right")?
---Open any git object. Accepts a bare ref (commit / tree / blob / tag
---sha, branch, tag name, `stash@{N}`, etc.) or `<commit-ref>:<path>`.
---Resolves to a sha so the URI stays stable if the ref later moves.
---@param worktree string ---@param worktree string
---@param ref string ---@param rev string
---@param opts ow.Git.OpenObjectOpts? ---@param opts ow.Git.OpenObjectOpts?
function M.open_object(worktree, ref, opts) function M.open_object(worktree, rev, opts)
local commit_ref, path = ref:match("^(.-):(.+)$") local parsed = Revision.parse(rev)
local revspec if parsed.base then
if commit_ref then local sha = repo.rev_parse(worktree, parsed.base, true)
local sha = repo.rev_parse(worktree, commit_ref, true) or commit_ref if sha then
revspec = sha .. ":" .. path parsed.base = sha
else end
revspec = repo.rev_parse(worktree, ref, true) or ref
end end
local content = util.exec( local content = util.exec(
{ "git", "cat-file", "-p", revspec }, { "git", "cat-file", "-p", parsed:format() },
{ cwd = worktree, silent = true } { cwd = worktree, silent = true }
) )
if not content then if not content then
util.warning("not a git object: %s", ref) util.warning("not a git object: %s", rev)
return return
end end
local buf = M.buf_for(worktree, revspec, content) local buf = M.buf_for(worktree, parsed, content)
util.place_buf(buf, opts and opts.split) util.place_buf(buf, opts and opts.split)
end end
---@return boolean dispatched true if the cursor was on an actionable line ---@return boolean dispatched
function M.open_under_cursor() function M.open_under_cursor()
local ctx = context() local ctx = context()
if not ctx then if not ctx then
@@ -372,14 +324,13 @@ function M.open_under_cursor()
return true return true
end end
-- Blobs navigate by path so the URI carries the entry name (filetype
-- detection wants the extension). Other types navigate by sha.
local entry_type, entry_sha, entry_name = local entry_type, entry_sha, entry_name =
line:match("^%d+ (%w+) (%x+)\t(.+)$") line:match("^%d+ (%w+) (%x+)\t(.+)$")
if entry_sha then if entry_sha then
local nav_ref = entry_type == "blob" and (ctx.ref .. ":" .. entry_name) local nav_rev = entry_type == "blob"
and Revision.new({ base = ctx.sha, path = entry_name }):format()
or entry_sha or entry_sha
M.open_object(ctx.worktree, nav_ref, { split = false }) M.open_object(ctx.worktree, nav_rev, { split = false })
return true return true
end end
@@ -387,26 +338,26 @@ function M.open_under_cursor()
if not section then if not section then
return false return false
end end
local parent = ctx.parent_ref or "0" local parent = ctx.parent_sha or "0"
if line:match("^diff %-%-git ") then if line:match("^diff %-%-git ") then
open_section(ctx, section) open_section(ctx, section)
return true return true
end end
if line:match("^%-%-%- ") then if line:match("^%-%-%- ") then
load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent) load_blob(ctx.worktree, section.blob_a, section.path_a, parent)
return true return true
end end
if line:match("^%+%+%+ ") then if line:match("^%+%+%+ ") then
load_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref) load_blob(ctx.worktree, section.blob_b, section.path_b, ctx.sha)
return true return true
end end
local prefix = line:sub(1, 1) local prefix = line:sub(1, 1)
if prefix == "+" then if prefix == "+" then
load_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref) load_blob(ctx.worktree, section.blob_b, section.path_b, ctx.sha)
return true return true
elseif prefix == "-" then elseif prefix == "-" then
load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent) load_blob(ctx.worktree, section.blob_a, section.path_a, parent)
return true return true
end end
return false return false
+7 -25
View File
@@ -12,7 +12,7 @@ M.UNMERGED = {
UU = true, UU = true,
} }
---@param code string porcelain v1 XY code ---@param code string
---@return string? char ---@return string? char
---@return string? hl_group ---@return string? hl_group
function M.indicator(code) function M.indicator(code)
@@ -84,10 +84,6 @@ function M.resolve(path)
return vim.fs.normalize(gitdir), worktree return vim.fs.normalize(gitdir), worktree
end end
---Resolve the gitdir/worktree from the current buffer's file path,
---falling back to `vim.fn.getcwd()` when the buffer is unnamed or
---carries a synthetic URI (`git://`, `gitlog://`) that isn't a real
---filesystem path. Returns nil for both when not inside a git repo.
---@return string? gitdir ---@return string? gitdir
---@return string? worktree ---@return string? worktree
function M.resolve_cwd() function M.resolve_cwd()
@@ -101,7 +97,7 @@ end
---@class ow.Git.Repo ---@class ow.Git.Repo
---@field gitdir string ---@field gitdir string
---@field worktree string ---@field worktree string
---@field buffers table<integer, true> set of registered buffer numbers ---@field buffers table<integer, true>
---@field watcher? uv.uv_fs_event_t ---@field watcher? uv.uv_fs_event_t
---@field refresh fun(self: ow.Git.Repo) ---@field refresh fun(self: ow.Git.Repo)
---@field refresh_handle ow.Git.Util.DebounceHandle ---@field refresh_handle ow.Git.Util.DebounceHandle
@@ -121,9 +117,8 @@ end
function Repo:stop_watcher() function Repo:stop_watcher()
-- Stop the libuv watcher first so no further fs-events can trigger -- Stop the libuv watcher first so no further fs-events can trigger
-- self:refresh(). Only then tear down the debounce handle. The reverse -- self:refresh(). Tearing down the debounce handle first leaves a
-- order leaves a window where an in-flight watcher callback would call -- window where an in-flight watcher callback hits a closed timer.
-- a closed debounce timer.
if self.watcher then if self.watcher then
self.watcher:stop() self.watcher:stop()
self.watcher:close() self.watcher:close()
@@ -149,11 +144,6 @@ end
---@param repo ow.Git.Repo ---@param repo ow.Git.Repo
local function do_refresh(repo) local function do_refresh(repo)
-- `--branch` adds the `## branch...upstream [ahead/behind]` line that
-- the sidebar parses. The per-buffer indicator only needs the XY +
-- path lines, so it ignores `##` lines below. Running with `--branch`
-- lets the sidebar reuse this single subprocess via the GitRefresh
-- data payload instead of spawning its own.
vim.system( vim.system(
{ {
"git", "git",
@@ -173,10 +163,6 @@ local function do_refresh(repo)
local x = code:sub(1, 1) local x = code:sub(1, 1)
local y = code:sub(2, 2) local y = code:sub(2, 2)
local path_part = line:sub(4) local path_part = line:sub(4)
-- ` -> ` only appears in renames/copies. Without
-- this guard, a literal filename containing the
-- arrow (rare with `core.quotePath=false`) would
-- be mis-parsed.
if x == "R" or x == "C" or y == "R" or y == "C" then if x == "R" or x == "C" or y == "R" or y == "C" then
local arrow = path_part:find(" -> ", 1, true) local arrow = path_part:find(" -> ", 1, true)
if arrow then if arrow then
@@ -325,20 +311,16 @@ function M.head(path)
return nil return nil
end end
---Resolve a git revision to its object SHA. Returns nil if the ref can't
---be parsed (root-commit's `^`, blob's `^`, malformed ref, etc.). When
---`short` is true, the result is abbreviated via `core.abbrev`
---(auto-extended by git to keep the prefix unique in the current repo).
---@param worktree string ---@param worktree string
---@param ref string ---@param rev string
---@param short boolean ---@param short boolean
---@return string? ---@return string?
function M.rev_parse(worktree, ref, short) function M.rev_parse(worktree, rev, short)
local cmd = { "git", "rev-parse", "--verify", "--quiet" } local cmd = { "git", "rev-parse", "--verify", "--quiet" }
if short then if short then
table.insert(cmd, "--short") table.insert(cmd, "--short")
end end
table.insert(cmd, ref) table.insert(cmd, rev)
local stdout = util.exec(cmd, { cwd = worktree, silent = true }) local stdout = util.exec(cmd, { cwd = worktree, silent = true })
local trimmed = stdout and vim.trim(stdout) or "" local trimmed = stdout and vim.trim(stdout) or ""
return trimmed ~= "" and trimmed or nil return trimmed ~= "" and trimmed or nil
+61
View File
@@ -0,0 +1,61 @@
---@class ow.Git.Revision
---@field stage 0|1|2|3?
---@field path string?
---@field base string?
local Revision = {}
Revision.__index = Revision
local URI_PREFIX = "git://"
---@return string
function Revision:format()
if self.stage then
return ":" .. self.stage .. ":" .. self.path
elseif self.path then
return self.base .. ":" .. self.path
end
return self.base or error("Revision:format: empty Revision")
end
---@return string
function Revision:uri()
return URI_PREFIX .. self:format()
end
---@param parts { stage?: integer, base?: string, path?: string }
---@return ow.Git.Revision
function Revision.new(parts)
return setmetatable(parts, Revision)
end
---@param str string
---@return ow.Git.Revision
function Revision.parse(str)
local stage, path = str:match("^:([0123]):(.+)$")
if stage then
return Revision.new({
stage = tonumber(stage) --[[@as (0|1|2|3)?]],
path = path,
})
end
path = str:match("^:([^:]+)$")
if path then
return Revision.new({ stage = 0, path = path })
end
local base, p = str:match("^([^:]+):(.+)$")
if base then
return Revision.new({ base = base, path = p })
end
return Revision.new({ base = str })
end
---@param str string
---@return ow.Git.Revision?
function Revision.from_uri(str)
local raw = str:match("^" .. URI_PREFIX .. "(.+)$")
if raw then
return Revision.parse(raw)
end
end
return Revision
+13 -47
View File
@@ -1,3 +1,4 @@
local Revision = require("git.revision")
local diff = require("git.diff") local diff = require("git.diff")
local object = require("git.object") local object = require("git.object")
local repo = require("git.repo") local repo = require("git.repo")
@@ -19,8 +20,8 @@ local SIDEBAR_WIDTH = 50
---@field section string ---@field section string
---@field path string ---@field path string
---@field orig string? ---@field orig string?
---@field x string porcelain v1 column 1 (always set, may be a literal space) ---@field x string
---@field y string porcelain v1 column 2 (always set, may be a literal space) ---@field y string
---@class ow.Git.CommitEntry ---@class ow.Git.CommitEntry
---@field section string ---@field section string
@@ -34,7 +35,7 @@ local SIDEBAR_WIDTH = 50
---@field worktree string ---@field worktree string
---@field lines table<integer, ow.Git.SidebarEntry> ---@field lines table<integer, ow.Git.SidebarEntry>
---@field sidebar_win integer? ---@field sidebar_win integer?
---@field invocation_win integer? window focused when the sidebar opened. The first diff repurposes it as the right pane ---@field invocation_win integer?
---@field diff_left_win integer? ---@field diff_left_win integer?
---@field diff_right_win integer? ---@field diff_right_win integer?
---@field user_aucmd integer? ---@field user_aucmd integer?
@@ -47,7 +48,6 @@ local state = {}
local group = vim.api.nvim_create_augroup("ow.git.sidebar", { clear = false }) local group = vim.api.nvim_create_augroup("ow.git.sidebar", { clear = false })
local ns = vim.api.nvim_create_namespace("ow.git.sidebar") local ns = vim.api.nvim_create_namespace("ow.git.sidebar")
---Find the sidebar window in the current tabpage by filetype.
---@return integer? win ---@return integer? win
---@return integer? bufnr ---@return integer? bufnr
local function find_sidebar() local function find_sidebar()
@@ -59,8 +59,6 @@ local function find_sidebar()
end end
end end
---Return the sidebar window stashed on `s`, validating that it's still
---live. Falls back to `find_sidebar` if the stashed handle is gone.
---@param s ow.Git.SidebarState ---@param s ow.Git.SidebarState
---@return integer? ---@return integer?
local function sidebar_win_for(s) local function sidebar_win_for(s)
@@ -90,7 +88,7 @@ end
---@param entry ow.Git.SidebarEntry ---@param entry ow.Git.SidebarEntry
---@return string? line ---@return string? line
---@return string? hl_group ---@return string? hl_group
---@return integer? hl_len byte length of the symbol portion at column 2 ---@return integer? hl_len
local function format_entry(entry) local function format_entry(entry)
if entry.sha then if entry.sha then
return string.format(" %s %s", entry.sha, entry.subject or ""), return string.format(" %s %s", entry.sha, entry.subject or ""),
@@ -116,7 +114,7 @@ end
---@field ahead integer ---@field ahead integer
---@field behind integer ---@field behind integer
---@param line string '## branch.line' from porcelain v1 ---@param line string
---@return ow.Git.BranchInfo ---@return ow.Git.BranchInfo
local function parse_branch_line(line) local function parse_branch_line(line)
local info = { ahead = 0, behind = 0 } local info = { ahead = 0, behind = 0 }
@@ -142,9 +140,6 @@ local function parse_branch_line(line)
return info return info
end end
---Parse `git status --porcelain=v1 --branch` output into a (branch, groups)
---pair. `Unpushed` and `Unpulled` start empty here. Ahead/behind commits are
---filled in by a follow-up `git log` once we know the upstream is set.
---@param stdout string ---@param stdout string
---@return ow.Git.BranchInfo, table<string, ow.Git.SidebarEntry[]> ---@return ow.Git.BranchInfo, table<string, ow.Git.SidebarEntry[]>
local function parse_porcelain(stdout) local function parse_porcelain(stdout)
@@ -165,9 +160,6 @@ local function parse_porcelain(stdout)
local y = line:sub(2, 2) local y = line:sub(2, 2)
local rest = line:sub(4) local rest = line:sub(4)
local orig local orig
-- ` -> ` only appears in renames/copies (R/C codes). Without
-- this guard, a literal filename containing the arrow would
-- be mis-parsed.
if x == "R" or x == "C" or y == "R" or y == "C" then if x == "R" or x == "C" or y == "R" or y == "C" then
local arrow = rest:find(" -> ", 1, true) local arrow = rest:find(" -> ", 1, true)
if arrow then if arrow then
@@ -213,9 +205,6 @@ local function parse_porcelain(stdout)
return branch, groups return branch, groups
end end
---Fill in the Unpushed/Unpulled groups from `git log` for any non-zero
---ahead/behind counter. Capped at 200 commits per range so a wildly
---divergent branch can't blow the sidebar's render budget.
---@param worktree string ---@param worktree string
---@param branch ow.Git.BranchInfo ---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.SidebarEntry[]> ---@param groups table<string, ow.Git.SidebarEntry[]>
@@ -233,8 +222,6 @@ local function enrich_with_log(worktree, branch, groups)
{ section = "Unpulled", range = "HEAD..@{upstream}" } { section = "Unpulled", range = "HEAD..@{upstream}" }
) )
end end
-- Submit both subprocesses before waiting so they run concurrently
-- rather than sequentially. Total time = max, not sum.
local pending = {} local pending = {}
for _, f in ipairs(fetches) do for _, f in ipairs(fetches) do
table.insert(pending, { table.insert(pending, {
@@ -271,10 +258,6 @@ local function enrich_with_log(worktree, branch, groups)
end end
end end
---Build the (branch, groups) tuple for the sidebar. When `prefetched_stdout`
---is provided (typical case: dispatched via the `User GitRefresh` autocmd
---that already ran `git status --porcelain=v1 --branch` for the indicator),
---we skip the duplicate subprocess. Otherwise the sidebar fetches its own.
---@param worktree string ---@param worktree string
---@param prefetched_stdout string? ---@param prefetched_stdout string?
---@param callback fun(branch: ow.Git.BranchInfo, groups: table<string, ow.Git.SidebarEntry[]>) ---@param callback fun(branch: ow.Git.BranchInfo, groups: table<string, ow.Git.SidebarEntry[]>)
@@ -372,9 +355,6 @@ local function render(bufnr, branch, groups)
state[bufnr].lines = meta state[bufnr].lines = meta
end end
---Build a stable fingerprint of the parsed branch + groups so refresh can
---short-circuit when the porcelain state is byte-identical to the last
---successful render.
---@param branch ow.Git.BranchInfo ---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.SidebarEntry[]> ---@param groups table<string, ow.Git.SidebarEntry[]>
---@return string ---@return string
@@ -408,7 +388,7 @@ local function fingerprint(branch, groups)
end end
---@param bufnr integer ---@param bufnr integer
---@param prefetched_stdout string? porcelain output from a piggybacked GitRefresh ---@param prefetched_stdout string?
local function refresh(bufnr, prefetched_stdout) local function refresh(bufnr, prefetched_stdout)
local s = state[bufnr] local s = state[bufnr]
if not s then if not s then
@@ -430,9 +410,6 @@ local function refresh(bufnr, prefetched_stdout)
if not vim.api.nvim_buf_is_valid(bufnr) then if not vim.api.nvim_buf_is_valid(bufnr) then
return return
end 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 view_entry recomputes panes.
s.last_shown_key = nil s.last_shown_key = nil
local fp = fingerprint(branch, groups) local fp = fingerprint(branch, groups)
if fp == s.last_render_key then if fp == s.last_render_key then
@@ -482,9 +459,10 @@ end
---@param path string ---@param path string
---@return ow.Git.DiffSide ---@return ow.Git.DiffSide
local function head_pane(worktree, path) local function head_pane(worktree, path)
local rev = Revision.new({ base = "HEAD", path = path })
return { return {
buf = object.buf_for(worktree, "HEAD:" .. path), buf = object.buf_for(worktree, rev),
name = util.uri("HEAD:" .. path), name = rev:uri(),
} }
end end
@@ -501,9 +479,10 @@ end
---@param entry ow.Git.FileEntry ---@param entry ow.Git.FileEntry
---@return ow.Git.DiffSide ---@return ow.Git.DiffSide
local function index_pane(s, entry) local function index_pane(s, entry)
local rev = Revision.new({ stage = 0, path = entry.path })
return { return {
buf = object.buf_for(s.worktree, ":0:" .. entry.path), buf = object.buf_for(s.worktree, rev),
name = util.uri(":0:" .. entry.path), name = rev:uri(),
} }
end end
@@ -515,7 +494,6 @@ local function older_pane(s, entry)
if entry.x == "A" then if entry.x == "A" then
return nil return nil
end end
-- HEAD holds the pre-rename path
return head_pane(s.worktree, entry.orig or entry.path) return head_pane(s.worktree, entry.orig or entry.path)
end end
if entry.section == "Unstaged" then if entry.section == "Unstaged" then
@@ -555,10 +533,6 @@ local function reset_diff_win(win)
end) end)
end end
---Validate the window the user was in when the sidebar opened. The first
---diff repurposes it as the right pane, regardless of whether it holds an
---empty buffer or a real file. Returns nil if the user closed it, moved
---to another tabpage, or it's somehow the sidebar itself.
---@param s ow.Git.SidebarState ---@param s ow.Git.SidebarState
---@return integer? ---@return integer?
local function invocation_win_for(s) local function invocation_win_for(s)
@@ -613,8 +587,6 @@ local function entry_key(entry)
return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "") return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "")
end end
---Split `target_win` to the given side. The new window inherits
---`target_win`'s buffer, which the caller swaps afterwards.
---@param target_win integer ---@param target_win integer
---@param dir "left"|"right" ---@param dir "left"|"right"
---@return integer ---@return integer
@@ -628,8 +600,6 @@ local function vsplit_at(target_win, dir)
return win return win
end end
---Make sure `right_win` exists, repurposing the invocation window or
---splitting the sidebar. Returns the right window.
---@param s ow.Git.SidebarState ---@param s ow.Git.SidebarState
---@param sidebar_win integer ---@param sidebar_win integer
---@param right_win integer? ---@param right_win integer?
@@ -642,7 +612,6 @@ local function ensure_right_win(s, sidebar_win, right_win)
if target then if target then
right_win = target right_win = target
else else
-- Sidebar-only case: split steals from sidebar, restore width.
right_win = vsplit_at(sidebar_win, "right") right_win = vsplit_at(sidebar_win, "right")
vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH) vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH)
end end
@@ -804,9 +773,6 @@ local function action_discard()
local prompt, action local prompt, action
if entry.section == "Untracked" then if entry.section == "Untracked" then
-- Porcelain v1 collapses untracked directories into a single
-- entry with a trailing slash, so plain `os.remove` (which only
-- deletes files / empty dirs) won't do.
local is_dir = entry.path:sub(-1) == "/" local is_dir = entry.path:sub(-1) == "/"
prompt = string.format( prompt = string.format(
"Delete untracked %s %s?", "Delete untracked %s %s?",
+7 -72
View File
@@ -1,54 +1,9 @@
local M = {} local M = {}
local URI_PREFIX = "git://"
---@param revspec string
---@return string
function M.uri(revspec)
return URI_PREFIX .. revspec
end
---Extract the revspec from a `git://<revspec>` buffer name. Returns
---nil if the name doesn't carry the scheme.
---@param name string
---@return string?
function M.parse_uri(name)
return name:match("^" .. URI_PREFIX .. "(.+)$")
end
---@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) --[[@as (0|1|2|3)?]],
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.ScratchOpts ---@class ow.Git.ScratchOpts
---@field name string? ---@field name string?
---@field bufhidden ("hide"|"wipe")? defaults to "wipe" ---@field bufhidden ("hide"|"wipe")?
---Configure a fresh buffer as a read-only scratch and optionally name it.
---@param buf integer ---@param buf integer
---@param opts ow.Git.ScratchOpts ---@param opts ow.Git.ScratchOpts
local function setup_scratch(buf, opts) local function setup_scratch(buf, opts)
@@ -62,8 +17,6 @@ local function setup_scratch(buf, opts)
end end
end end
---Set a buffer's name and re-run filetype detection from it. Wrapped
---in `pcall` because a buffer with that name may already exist (E95).
---@param buf integer ---@param buf integer
---@param name string ---@param name string
function M.set_buf_name(buf, name) function M.set_buf_name(buf, name)
@@ -74,10 +27,6 @@ function M.set_buf_name(buf, name)
end end
end end
---Place a buffer in the current window or a new split per `split`.
---`false` replaces the current buffer, dropping a `'` mark first so
---`''` jumps back. A direction string opens a split on that side. Nil
---falls back to `'splitbelow'` for the direction.
---@param buf integer ---@param buf integer
---@param split (false|"above"|"below"|"left"|"right")? ---@param split (false|"above"|"below"|"left"|"right")?
---@return integer win ---@return integer win
@@ -95,11 +44,8 @@ function M.place_buf(buf, split)
end end
---@class ow.Git.NewScratchOpts : ow.Git.ScratchOpts ---@class ow.Git.NewScratchOpts : ow.Git.ScratchOpts
---@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). ---@field split (false|"above"|"below"|"left"|"right")?
---Create a fresh read-only 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? ---@param opts ow.Git.NewScratchOpts?
---@return integer buf ---@return integer buf
---@return integer win ---@return integer win
@@ -134,9 +80,6 @@ function M.debug(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.DEBUG) vim.notify(fmt:format(...), vim.log.levels.DEBUG)
end end
---Split a string on newlines, dropping the trailing empty element that an
---input ending in `\n` produces. Convenient for slicing subprocess stdout
---into a list of lines without a phantom blank at the end.
---@param content string ---@param content string
---@return string[] ---@return string[]
function M.split_lines(content) function M.split_lines(content)
@@ -164,9 +107,9 @@ function M.debounce(fn, delay)
local fired_gen = 0 local fired_gen = 0
local cb_main = vim.schedule_wrap(function() local cb_main = vim.schedule_wrap(function()
-- Identity check: the libuv fire may have been superseded by a -- Identity check: the libuv fire may have been superseded by
-- re-arm or a cancel between the timer firing and this scheduled -- a re-arm or a cancel between the timer firing and this
-- callback running. -- scheduled callback running.
if fired_gen ~= gen or args == nil then if fired_gen ~= gen or args == nil then
return return
end end
@@ -217,17 +160,9 @@ end
---@class ow.Git.ExecOpts ---@class ow.Git.ExecOpts
---@field cwd string? ---@field cwd string?
---@field stdin string? ---@field stdin string?
---@field silent boolean? suppress the auto-log on non-zero exit ---@field silent boolean?
---@field on_done fun(stdout: string?)? if set, run async and deliver stdout (or nil on failure) here on the main loop instead of returning sync ---@field on_done fun(stdout: string?)?
---Run a system command. Default is sync: returns stdout on success or
---nil on failure (logging stderr unless `opts.silent`). When
---`opts.on_done` is set, runs async via `vim.schedule_wrap` and
---delivers the same stdout-or-nil value to that callback instead.
---
---Async mode returns nil immediately. Callers that need access to the
---raw stderr / exit code in the failure path should opt out of this
---helper and use `vim.system` directly.
---@param cmd string[] ---@param cmd string[]
---@param opts ow.Git.ExecOpts? ---@param opts ow.Git.ExecOpts?
---@return string? ---@return string?