refactor(git): unify around the Repo abstraction
This commit is contained in:
+339
-155
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user