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
+13 -47
View File
@@ -1,3 +1,4 @@
local Revision = require("git.revision")
local diff = require("git.diff")
local object = require("git.object")
local repo = require("git.repo")
@@ -19,8 +20,8 @@ local SIDEBAR_WIDTH = 50
---@field section string
---@field path string
---@field orig string?
---@field x string porcelain v1 column 1 (always set, may be a literal space)
---@field y string porcelain v1 column 2 (always set, may be a literal space)
---@field x string
---@field y string
---@class ow.Git.CommitEntry
---@field section string
@@ -34,7 +35,7 @@ local SIDEBAR_WIDTH = 50
---@field worktree string
---@field lines table<integer, ow.Git.SidebarEntry>
---@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_right_win integer?
---@field user_aucmd integer?
@@ -47,7 +48,6 @@ local state = {}
local group = vim.api.nvim_create_augroup("ow.git.sidebar", { clear = false })
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? bufnr
local function find_sidebar()
@@ -59,8 +59,6 @@ local function find_sidebar()
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
---@return integer?
local function sidebar_win_for(s)
@@ -90,7 +88,7 @@ end
---@param entry ow.Git.SidebarEntry
---@return string? line
---@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)
if entry.sha then
return string.format(" %s %s", entry.sha, entry.subject or ""),
@@ -116,7 +114,7 @@ end
---@field ahead integer
---@field behind integer
---@param line string '## branch.line' from porcelain v1
---@param line string
---@return ow.Git.BranchInfo
local function parse_branch_line(line)
local info = { ahead = 0, behind = 0 }
@@ -142,9 +140,6 @@ local function parse_branch_line(line)
return info
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
---@return ow.Git.BranchInfo, table<string, ow.Git.SidebarEntry[]>
local function parse_porcelain(stdout)
@@ -165,9 +160,6 @@ local function parse_porcelain(stdout)
local y = line:sub(2, 2)
local rest = line:sub(4)
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
local arrow = rest:find(" -> ", 1, true)
if arrow then
@@ -213,9 +205,6 @@ local function parse_porcelain(stdout)
return branch, groups
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 branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.SidebarEntry[]>
@@ -233,8 +222,6 @@ local function enrich_with_log(worktree, branch, groups)
{ section = "Unpulled", range = "HEAD..@{upstream}" }
)
end
-- Submit both subprocesses before waiting so they run concurrently
-- rather than sequentially. Total time = max, not sum.
local pending = {}
for _, f in ipairs(fetches) do
table.insert(pending, {
@@ -271,10 +258,6 @@ local function enrich_with_log(worktree, branch, groups)
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 prefetched_stdout string?
---@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
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 groups table<string, ow.Git.SidebarEntry[]>
---@return string
@@ -408,7 +388,7 @@ local function fingerprint(branch, groups)
end
---@param bufnr integer
---@param prefetched_stdout string? porcelain output from a piggybacked GitRefresh
---@param prefetched_stdout string?
local function refresh(bufnr, prefetched_stdout)
local s = state[bufnr]
if not s then
@@ -430,9 +410,6 @@ local function refresh(bufnr, prefetched_stdout)
if not vim.api.nvim_buf_is_valid(bufnr) then
return
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
local fp = fingerprint(branch, groups)
if fp == s.last_render_key then
@@ -482,9 +459,10 @@ end
---@param path string
---@return ow.Git.DiffSide
local function head_pane(worktree, path)
local rev = Revision.new({ base = "HEAD", path = path })
return {
buf = object.buf_for(worktree, "HEAD:" .. path),
name = util.uri("HEAD:" .. path),
buf = object.buf_for(worktree, rev),
name = rev:uri(),
}
end
@@ -501,9 +479,10 @@ end
---@param entry ow.Git.FileEntry
---@return ow.Git.DiffSide
local function index_pane(s, entry)
local rev = Revision.new({ stage = 0, path = entry.path })
return {
buf = object.buf_for(s.worktree, ":0:" .. entry.path),
name = util.uri(":0:" .. entry.path),
buf = object.buf_for(s.worktree, rev),
name = rev:uri(),
}
end
@@ -515,7 +494,6 @@ local function older_pane(s, entry)
if entry.x == "A" then
return nil
end
-- HEAD holds the pre-rename path
return head_pane(s.worktree, entry.orig or entry.path)
end
if entry.section == "Unstaged" then
@@ -555,10 +533,6 @@ local function reset_diff_win(win)
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
---@return integer?
local function invocation_win_for(s)
@@ -613,8 +587,6 @@ local function entry_key(entry)
return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "")
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 dir "left"|"right"
---@return integer
@@ -628,8 +600,6 @@ local function vsplit_at(target_win, dir)
return win
end
---Make sure `right_win` exists, repurposing the invocation window or
---splitting the sidebar. Returns the right window.
---@param s ow.Git.SidebarState
---@param sidebar_win integer
---@param right_win integer?
@@ -642,7 +612,6 @@ local function ensure_right_win(s, sidebar_win, right_win)
if target then
right_win = target
else
-- Sidebar-only case: split steals from sidebar, restore width.
right_win = vsplit_at(sidebar_win, "right")
vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH)
end
@@ -804,9 +773,6 @@ local function action_discard()
local prompt, action
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) == "/"
prompt = string.format(
"Delete untracked %s %s?",