refactor(git): unify around the Repo abstraction

This commit is contained in:
2026-05-02 22:45:44 +02:00
parent 8bd674622e
commit be1d7ace50
14 changed files with 671 additions and 586 deletions
+339 -155
View File
@@ -1,61 +1,205 @@
local status = require("git.status")
local util = require("git.util")
local M = {}
---@param path string
---@return string? gitdir
---@return string? worktree
---@return string? path
function M.resolve(path)
path = vim.fn.resolve(path)
local found = vim.fs.find(".git", { upward = true, path = path })[1]
if not found then
return nil
---@param buf integer?
---@return integer
local function expand_buf(buf)
if not buf or buf == 0 then
return vim.api.nvim_get_current_buf()
end
local worktree = vim.fs.dirname(found)
local stat = vim.uv.fs_stat(found)
if not stat then
return nil
end
if stat.type == "directory" then
return found, worktree, path
end
local f = io.open(found, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
local gitdir = content:match("gitdir:%s*(%S+)")
if not gitdir then
util.warning(".git file at %s has no `gitdir:` line", found)
return nil
end
if not gitdir:match("^/") then
gitdir = vim.fs.joinpath(worktree, gitdir)
end
return vim.fs.normalize(gitdir), worktree, path
return buf
end
---@return string? gitdir
---@return string? worktree
function M.current_repo()
local path = vim.api.nvim_buf_get_name(0)
if path == "" or path:match("^%a+://") then
path = vim.fn.getcwd()
end
local gitdir, worktree, _ = M.resolve(path)
return gitdir, worktree
---@class ow.Git.BufState
---@field repo ow.Git.Repo
---@field sha string?
---@field parent_sha string?
---@field index_writer boolean?
---@field index_mode string?
---@field log_max_count integer?
---@field pending_content string?
---@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?))[]
local Repo = {}
Repo.__index = Repo
---@param r ow.Git.Repo
local function do_refresh(r)
vim.system(
{
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
},
{ cwd = r.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 code = line:sub(1, 2)
local x = code:sub(1, 1)
local y = code:sub(2, 2)
local path_part = line:sub(4)
if x == "R" or x == "C" or y == "R" or y == "C" then
local arrow = path_part:find(" -> ", 1, true)
if arrow then
path_part = path_part:sub(arrow + 4)
end
end
statuses[vim.fs.joinpath(r.worktree, path_part)] =
status.format(code)
end
end
else
util.warning(
"git status failed: %s",
vim.trim(obj.stderr or "")
)
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)
end)
)
end
---@param gitdir string
---@param worktree string
---@return ow.Git.Repo
function Repo.new(gitdir, worktree)
local self = setmetatable({
gitdir = gitdir,
worktree = worktree,
buffers = {},
refresh_listeners = {},
}, Repo)
self.refresh, self.refresh_handle = util.debounce(do_refresh, 50)
self:start_watcher()
return self
end
function Repo:start_watcher()
local watcher, err = vim.uv.new_fs_event()
if not watcher then
util.warning(
"git: failed to create fs_event for %s: %s",
self.gitdir,
err
)
return
end
local ok, err = watcher:start(
self.gitdir,
{ recursive = true },
function(err_, filename)
if
err_
or filename:match("^objects/")
or filename:match("^logs/")
then
return
end
self:refresh()
end
)
if not ok then
util.warning("failed to watch %s: %s", self.gitdir, tostring(err))
watcher:close()
return
end
self.watcher = watcher
end
function Repo:stop_watcher()
if self.watcher then
self.watcher:stop()
self.watcher:close()
self.watcher = nil
end
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
end
---@param buf integer
function Repo:remove_buffer(buf)
self.buffers[expand_buf(buf)] = nil
end
---@param buf integer
---@return ow.Git.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
---@param path string
---@return string?
function M.head(path)
local gitdir = M.resolve(path)
if not gitdir then
return nil
end
local f = io.open(vim.fs.joinpath(gitdir, "HEAD"), "r")
function Repo:head()
local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
if not f then
return nil
end
@@ -75,9 +219,8 @@ function M.head(path)
return nil
end
---@param worktree string
---@return string[]
function M.list_refs(worktree)
function Repo:list_refs()
local out = util.exec({
"git",
"for-each-ref",
@@ -85,7 +228,7 @@ function M.list_refs(worktree)
"refs/heads",
"refs/tags",
"refs/remotes",
}, { cwd = worktree, silent = true })
}, { cwd = self.worktree, silent = true })
if not out then
return {}
end
@@ -94,118 +237,159 @@ function M.list_refs(worktree)
return refs
end
---@param arg_lead string
---@return string[]
function M.complete_rev(arg_lead)
local _, worktree = M.current_repo()
if not worktree then
return {}
end
local stage, stage_path_lead = arg_lead:match("^:([0-3]):(.*)$")
if stage then
local out = util.exec(
{ "git", "ls-files", "--stage" },
{ cwd = worktree, silent = true }
)
if not out then
return {}
end
local matches = {}
for _, line in ipairs(util.split_lines(out)) do
local row_stage, row_path = line:match("^%S+ %S+ (%d)\t(.*)$")
if
row_stage == stage
and row_path
and row_path:sub(1, #stage_path_lead) == stage_path_lead
then
table.insert(matches, ":" .. stage .. ":" .. row_path)
end
end
return matches
end
local colon = arg_lead:find(":", 1, true)
if not colon then
local matches = {}
for _, ref in ipairs(M.list_refs(worktree)) do
if ref:sub(1, #arg_lead) == arg_lead then
table.insert(matches, ref)
end
end
return matches
end
local rev = arg_lead:sub(1, colon - 1)
local path_lead = arg_lead:sub(colon + 1)
local dir, name_lead = path_lead:match("^(.*/)([^/]*)$")
dir = dir or ""
name_lead = name_lead or path_lead
if rev ~= "" then
local cmd = { "git", "ls-tree", rev }
if dir ~= "" then
table.insert(cmd, dir)
end
local out = util.exec(cmd, { cwd = worktree, silent = true })
if not out then
return {}
end
local matches = {}
for _, line in ipairs(util.split_lines(out)) do
local typ, full_path = line:match("^%S+ (%S+) %S+\t(.*)$")
if typ and full_path then
local basename = dir == "" and full_path
or full_path:sub(#dir + 1)
if typ == "tree" then
basename = basename .. "/"
end
if basename:sub(1, #name_lead) == name_lead then
table.insert(matches, rev .. ":" .. dir .. basename)
end
end
end
return matches
end
local cmd = { "git", "ls-files" }
if dir ~= "" then
table.insert(cmd, dir)
end
local out = util.exec(cmd, { cwd = worktree, silent = true })
if not out then
return {}
end
local matches = {}
local seen = {}
for _, full_path in ipairs(util.split_lines(out)) do
local rel = dir == "" and full_path or full_path:sub(#dir + 1)
local slash = rel:find("/", 1, true)
local segment = slash and rel:sub(1, slash) or rel
if
not seen[segment]
and segment:sub(1, #name_lead) == name_lead
then
seen[segment] = true
table.insert(matches, ":" .. dir .. segment)
end
end
return matches
end
---@param worktree string
---@param rev string
---@param short boolean
---@return string?
function M.rev_parse(worktree, rev, short)
function Repo:rev_parse(rev, short)
local cmd = { "git", "rev-parse", "--verify", "--quiet" }
if short then
table.insert(cmd, "--short")
end
table.insert(cmd, rev)
local stdout = util.exec(cmd, { cwd = worktree, silent = true })
local stdout = util.exec(cmd, { cwd = self.worktree, silent = true })
local trimmed = stdout and vim.trim(stdout) or ""
return trimmed ~= "" and trimmed or nil
end
local M = {}
---@type table<string, ow.Git.Repo>
local repo_by_gitdir = {}
---@type table<integer, ow.Git.Repo>
local repo_by_buf = {}
---@param path string
---@return ow.Git.Repo?
function M.resolve(path)
path = vim.fn.resolve(path)
local found = vim.fs.find(".git", { upward = true, path = path })[1]
if not found then
return nil
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
else
local f = io.open(found, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
local rel = content:match("gitdir:%s*(%S+)")
if not rel then
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
end
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?
function M.state(buf)
buf = expand_buf(buf)
local r = repo_by_buf[buf]
return r and r.buffers[buf]
end
---@param buf integer
---@param r ow.Git.Repo
function M.attach(buf, r)
buf = expand_buf(buf)
if repo_by_buf[buf] == r then
return
end
if repo_by_buf[buf] then
repo_by_buf[buf]:remove_buffer(buf)
end
r:add_buffer(buf)
repo_by_buf[buf] = r
end
---@param buf integer
---@return ow.Git.Repo?
function M.register(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]
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
end
---@param buf integer
function M.refresh(buf)
buf = expand_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return
end
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or path:match("^%a+://") then
return
end
local r = M.register(buf)
if not r then
vim.b[buf].git_status = nil
return
end
r:refresh()
end
function M.stop_all()
for _, r in pairs(repo_by_gitdir) do
r:stop_watcher()
end
end
return M