refactor(git): rework module around clearer Status and Repo split
This commit is contained in:
+239
-296
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user