refactor(git): rework module around clearer Status and Repo split

This commit is contained in:
2026-05-06 00:45:56 +02:00
parent 1b43fa6a1c
commit 80d6d465cf
17 changed files with 821 additions and 775 deletions
+239 -296
View File
@@ -1,7 +1,9 @@
local status = require("git.status")
local util = require("git.util")
---@param buf integer?
local M = {}
---@param buf? integer
---@return integer
local function expand_buf(buf)
if not buf or buf == 0 then
@@ -10,7 +12,7 @@ local function expand_buf(buf)
return buf
end
---@class ow.Git.BufState
---@class ow.Git.Repo.BufState
---@field repo ow.Git.Repo
---@field sha string?
---@field parent_sha string?
@@ -19,110 +21,58 @@ end
---@field log_max_count integer?
---@field pending_content string?
---@class ow.Git.StatusEntry
---@field section "Untracked"|"Unstaged"|"Staged"|"Unmerged"
---@field x string
---@field y string
---@field path string
---@field orig string?
---@alias ow.Git.Repo.Event "refresh"
---@class ow.Git.BranchInfo
---@field head string?
---@field upstream string?
---@field ahead integer
---@field behind integer
---@class ow.Git.PorcelainGroups
---@field Untracked ow.Git.StatusEntry[]
---@field Unstaged ow.Git.StatusEntry[]
---@field Staged ow.Git.StatusEntry[]
---@field Unmerged ow.Git.StatusEntry[]
local global = util.Emitter.new()
---@class ow.Git.Repo
---@field gitdir string
---@field worktree string
---@field buffers table<integer, ow.Git.BufState>
---@field watcher? uv.uv_fs_event_t
---@field refresh fun(self: ow.Git.Repo)
---@field refresh_handle ow.Git.Util.DebounceHandle
---@field private refresh_listeners (fun(r: ow.Git.Repo, porcelain_stdout: string?))[]
---@field buffers table<integer, ow.Git.Repo.BufState>
---@field tabs table<integer, true>
---@field status ow.Git.Status
---@field private _events ow.Git.Util.Emitter<ow.Git.Repo.Event>
---@field private _watcher? uv.uv_fs_event_t
---@field private _schedule_refresh fun(self: ow.Git.Repo)
---@field private _refresh_handle ow.Git.Util.DebounceHandle
local Repo = {}
Repo.__index = Repo
---@param line string
---@return string x, string y, string path, string? orig
local function parse_porcelain_line(line)
local x = line:sub(1, 1)
local y = line:sub(2, 2)
local rest = line:sub(4)
local orig
if x == "R" or x == "C" or y == "R" or y == "C" then
local arrow = rest:find(" -> ", 1, true)
if arrow then
orig = rest:sub(1, arrow - 1)
rest = rest:sub(arrow + 4)
end
end
return x, y, rest, orig
end
local STATUS_CMD = {
"git",
"--no-optional-locks",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
"--ignored",
}
---@param r ow.Git.Repo
local function do_refresh(r)
---@private
function Repo:_fetch_status()
vim.system(
{
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
},
{ cwd = r.worktree, text = true },
STATUS_CMD,
{ cwd = self.worktree, text = true },
vim.schedule_wrap(function(obj)
local statuses = {}
if obj.code == 0 then
for line in (obj.stdout or ""):gmatch("[^\r\n]+") do
if line:sub(1, 2) ~= "##" then
local x, y, path = parse_porcelain_line(line)
statuses[vim.fs.joinpath(r.worktree, path)] =
status.format(x .. y)
end
end
else
if obj.code ~= 0 then
util.warning(
"git status failed: %s",
vim.trim(obj.stderr or "")
)
return
end
local dirty = false
for buf in pairs(r.buffers) do
if not vim.api.nvim_buf_is_valid(buf) then
r.buffers[buf] = nil
else
local name = vim.api.nvim_buf_get_name(buf)
local object = require("git.object")
local log = require("git.log")
if name:sub(1, #object.URI_PREFIX) == object.URI_PREFIX then
object.read_uri(buf)
elseif name:sub(1, #log.URI_PREFIX) == log.URI_PREFIX then
log.read_uri(buf)
else
local s = statuses[vim.fn.resolve(name)]
if vim.b[buf].git_status ~= s then
vim.b[buf].git_status = s
dirty = true
end
end
end
end
if dirty then
vim.cmd.redrawstatus({ bang = true })
end
r:notify_refresh(obj.code == 0 and obj.stdout or nil)
self.status = status.parse(obj.stdout or "")
self._events:emit("refresh", self.status)
global:emit("refresh", self, self.status)
end)
)
end
function Repo:refresh()
self:_schedule_refresh()
end
---@param gitdir string
---@param worktree string
---@return ow.Git.Repo
@@ -131,10 +81,14 @@ function Repo.new(gitdir, worktree)
gitdir = gitdir,
worktree = worktree,
buffers = {},
refresh_listeners = {},
tabs = {},
status = status.parse(""),
_events = util.Emitter.new(),
}, Repo)
self.refresh, self.refresh_handle = util.debounce(do_refresh, 50)
self._schedule_refresh, self._refresh_handle =
util.debounce(Repo._fetch_status, 50)
self:start_watcher()
self:refresh()
return self
end
@@ -148,7 +102,7 @@ function Repo:start_watcher()
)
return
end
local ok, err = watcher:start(
local ok, start_err = watcher:start(
self.gitdir,
{ recursive = true },
function(err_, filename)
@@ -163,67 +117,35 @@ function Repo:start_watcher()
end
)
if not ok then
util.warning("failed to watch %s: %s", self.gitdir, tostring(err))
util.warning("failed to watch %s: %s", self.gitdir, tostring(start_err))
watcher:close()
return
end
self.watcher = watcher
self._watcher = watcher
end
function Repo:stop_watcher()
if self.watcher then
self.watcher:stop()
self.watcher:close()
self.watcher = nil
function Repo:close()
if self._watcher then
self._watcher:stop()
self._watcher:close()
self._watcher = nil
end
self.refresh_handle.close()
self._refresh_handle.close()
end
---@param buf integer
function Repo:add_buffer(buf)
buf = expand_buf(buf)
if not self.buffers[buf] then
self.buffers[buf] = { repo = self }
end
---@param event ow.Git.Repo.Event
---@param fn fun(...)
---@return fun() unsubscribe
function Repo:on(event, fn)
return self._events:on(event, fn)
end
---@param buf integer
function Repo:remove_buffer(buf)
self.buffers[expand_buf(buf)] = nil
end
---@param buf integer
---@return ow.Git.BufState?
---@param buf? integer
---@return ow.Git.Repo.BufState?
function Repo:state(buf)
return self.buffers[expand_buf(buf)]
end
---@return boolean
function Repo:has_buffers()
return next(self.buffers) ~= nil
end
---@param fn fun(r: ow.Git.Repo, porcelain_stdout: string?)
---@return fun() unsubscribe
function Repo:on_refresh(fn)
table.insert(self.refresh_listeners, fn)
return function()
for i, f in ipairs(self.refresh_listeners) do
if f == fn then
table.remove(self.refresh_listeners, i)
return
end
end
end
end
---@param porcelain_stdout string?
function Repo:notify_refresh(porcelain_stdout)
for _, fn in ipairs(self.refresh_listeners) do
fn(self, porcelain_stdout)
end
end
---@return string?
function Repo:head()
local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
@@ -278,27 +200,117 @@ function Repo:rev_parse(rev, short)
return trimmed ~= "" and trimmed or nil
end
local M = {}
---@type table<string, ow.Git.Repo> keyed by worktree
local repos = {}
---@type table<string, ow.Git.Repo>
local repo_by_gitdir = {}
---@param event ow.Git.Repo.Event
---@param fn fun(...)
---@return fun() unsubscribe
function M.on(event, fn)
return global:on(event, fn)
end
---@type table<integer, ow.Git.Repo>
local repo_by_buf = {}
---@param prefix string
---@param fn fun(buf: integer)
---@return fun() unsubscribe
function M.on_uri_refresh(prefix, fn)
return M.on("refresh", function(r)
for buf in pairs(r.buffers) do
if vim.api.nvim_buf_is_valid(buf) then
local name = vim.api.nvim_buf_get_name(buf)
if name:sub(1, #prefix) == prefix then
fn(buf)
end
end
end
end)
end
---@param r ow.Git.Repo
local function release(r)
if repos[r.worktree] ~= r then
return
end
if next(r.buffers) ~= nil or next(r.tabs) ~= nil then
return
end
r:close()
repos[r.worktree] = nil
end
---@param buf integer
---@return ow.Git.Repo?
local function find_by_buf(buf)
for _, r in pairs(repos) do
if r.buffers[buf] then
return r
end
end
return nil
end
---@param path string
---@return ow.Git.Repo?
function M.resolve(path)
path = vim.fn.resolve(path)
local function find_by_path(path)
if path == "" then
return nil
end
if repos[path] then
return repos[path]
end
for wt, r in pairs(repos) do
if path:sub(1, #wt + 1) == wt .. "/" then
return r
end
end
return nil
end
---@param buf integer
---@return string
local function path_for_buf(buf)
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or path:match("^%a+://") then
return vim.fn.getcwd()
end
return vim.fn.resolve(path)
end
---@param arg? integer | string bufnr (default current) or worktree path
---@return ow.Git.Repo?
function M.find(arg)
if type(arg) == "string" then
return find_by_path(arg)
end
local buf = expand_buf(arg)
return find_by_buf(buf) or find_by_path(path_for_buf(buf))
end
---@param arg? integer | string bufnr (default current) or worktree path
---@return ow.Git.Repo?
function M.resolve(arg)
local existing = M.find(arg)
if existing then
return existing
end
local path
if type(arg) == "string" then
path = vim.fn.resolve(arg)
else
path = path_for_buf(expand_buf(arg))
end
local found = vim.fs.find(".git", { upward = true, path = path })[1]
if not found then
return nil
end
local worktree = vim.fs.dirname(found)
if repos[worktree] then
return repos[worktree]
end
local stat = vim.uv.fs_stat(found)
if not stat then
return nil
end
local worktree = vim.fs.dirname(found)
local gitdir
if stat.type == "directory" then
gitdir = found
@@ -314,192 +326,123 @@ function M.resolve(path)
util.warning(".git file at %s has no `gitdir:` line", found)
return nil
end
if rel:match("^/") then
gitdir = rel
else
gitdir = vim.fs.joinpath(worktree, rel)
end
gitdir = vim.fs.normalize(gitdir)
end
local r = repo_by_gitdir[gitdir]
if not r then
r = Repo.new(gitdir, worktree)
repo_by_gitdir[gitdir] = r
gitdir = vim.fs.normalize(
rel:match("^/") and rel or vim.fs.joinpath(worktree, rel)
)
end
local r = Repo.new(gitdir, worktree)
repos[worktree] = r
return r
end
---@param buf integer?
---@return ow.Git.Repo?
function M.find(buf)
buf = expand_buf(buf)
local existing = repo_by_buf[buf]
if existing then
return existing
end
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or path:match("^%a+://") then
path = vim.fn.getcwd()
end
return M.resolve(path)
end
---@param buf integer?
---@return ow.Git.BufState?
---@param buf? integer
---@return ow.Git.Repo.BufState?
function M.state(buf)
buf = expand_buf(buf)
local r = repo_by_buf[buf]
local r = find_by_buf(buf)
return r and r.buffers[buf]
end
---@param buf integer
---@param buf? integer
---@param r ow.Git.Repo
function M.attach(buf, r)
function M.bind(buf, r)
buf = expand_buf(buf)
if repo_by_buf[buf] == r then
local prev = find_by_buf(buf)
if prev == r then
return
end
if repo_by_buf[buf] then
repo_by_buf[buf]:remove_buffer(buf)
if prev then
prev.buffers[buf] = nil
release(prev)
end
r:add_buffer(buf)
repo_by_buf[buf] = r
r.buffers[buf] = { repo = r }
end
---@param buf integer
---@return ow.Git.Repo?
function M.register(buf)
---@param buf? integer
function M.unbind(buf)
buf = expand_buf(buf)
local r = M.find(buf)
if not r then
return nil
end
if repo_by_buf[buf] ~= r then
M.attach(buf, r)
end
return r
end
---@param buf integer
function M.unregister(buf)
buf = expand_buf(buf)
local r = repo_by_buf[buf]
local r = find_by_buf(buf)
if not r then
return
end
repo_by_buf[buf] = nil
r:remove_buffer(buf)
if not r:has_buffers() then
r:stop_watcher()
repo_by_gitdir[r.gitdir] = nil
end
r.buffers[buf] = nil
release(r)
end
---@param buf integer
function M.refresh(buf)
buf = expand_buf(buf)
---@return boolean
local function is_worktree_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return
return false
end
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or path:match("^%a+://") then
return path ~= "" and not path:match("^%a+://")
end
---@param buf? integer
function M.track(buf)
buf = expand_buf(buf)
if not is_worktree_buf(buf) then
return
end
local r = M.register(buf)
if not r then
vim.b[buf].git_status = nil
local r = M.resolve(buf)
if r and not r.buffers[buf] then
M.bind(buf, r)
end
end
---@param buf? integer
function M.refresh(buf)
local r = find_by_buf(expand_buf(buf))
if r then
r:refresh()
end
end
function M.refresh_all()
for _, r in pairs(repos) do
r:refresh()
end
end
function M.update_cwd_repo()
local tab = vim.api.nvim_get_current_tabpage()
local new = M.resolve(vim.fn.getcwd())
local old
for _, r in pairs(repos) do
if r.tabs[tab] then
old = r
break
end
end
if new == old then
return
end
r:refresh()
if old then
old.tabs[tab] = nil
release(old)
end
if new then
new.tabs[tab] = true
new:refresh()
end
end
---@param tab integer
function M.release_tab(tab)
for _, r in pairs(repos) do
if r.tabs[tab] then
r.tabs[tab] = nil
release(r)
return
end
end
end
function M.stop_all()
for _, r in pairs(repo_by_gitdir) do
r:stop_watcher()
for _, r in pairs(repos) do
r:close()
end
end
---@param line string
---@return ow.Git.BranchInfo
function M.parse_branch_line(line)
local info = { ahead = 0, behind = 0 }
local content = line:sub(4)
local arrow = content:find("...", 1, true)
if not arrow then
info.head = content
return info
end
info.head = content:sub(1, arrow - 1)
local rest = content:sub(arrow + 3)
local bracket = rest:find(" %[")
if not bracket then
info.upstream = rest
return info
end
info.upstream = rest:sub(1, bracket - 1)
local inside = rest:match("%[([^%]]+)%]")
if inside then
info.ahead = (tonumber(inside:match("ahead (%d+)")) or 0) --[[@as integer]]
info.behind = (tonumber(inside:match("behind (%d+)")) or 0) --[[@as integer]]
end
return info
end
---@param stdout string
---@return ow.Git.BranchInfo, ow.Git.PorcelainGroups
function M.parse_porcelain(stdout)
local branch = { ahead = 0, behind = 0 }
---@type ow.Git.PorcelainGroups
local groups = {
Untracked = {},
Unstaged = {},
Staged = {},
Unmerged = {},
}
for line in stdout:gmatch("[^\r\n]+") do
if line:sub(1, 2) == "##" then
branch = M.parse_branch_line(line)
else
local x, y, path, orig = parse_porcelain_line(line)
if x == "?" and y == "?" then
table.insert(groups.Untracked, {
section = "Untracked",
x = x,
y = y,
path = path,
orig = orig,
})
elseif status.UNMERGED[x .. y] then
table.insert(groups.Unmerged, {
section = "Unmerged",
x = x,
y = y,
path = path,
orig = orig,
})
else
if x ~= " " then
table.insert(groups.Staged, {
section = "Staged",
x = x,
y = y,
path = path,
orig = orig,
})
end
if y ~= " " then
table.insert(groups.Unstaged, {
section = "Unstaged",
x = x,
y = y,
path = path,
orig = orig,
})
end
end
end
end
return branch, groups
end
return M