449 lines
10 KiB
Lua
449 lines
10 KiB
Lua
local status = require("git.status")
|
|
local util = require("git.util")
|
|
|
|
local M = {}
|
|
|
|
---@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
|
|
return buf
|
|
end
|
|
|
|
---@class ow.Git.Repo.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?
|
|
|
|
---@alias ow.Git.Repo.Event "refresh"
|
|
|
|
local global = util.Emitter.new()
|
|
|
|
---@class ow.Git.Repo
|
|
---@field gitdir string
|
|
---@field worktree 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
|
|
|
|
local STATUS_CMD = {
|
|
"git",
|
|
"--no-optional-locks",
|
|
"-c",
|
|
"core.quotePath=false",
|
|
"status",
|
|
"--porcelain=v1",
|
|
"--branch",
|
|
"--ignored",
|
|
}
|
|
|
|
---@private
|
|
function Repo:_fetch_status()
|
|
vim.system(
|
|
STATUS_CMD,
|
|
{ cwd = self.worktree, text = true },
|
|
vim.schedule_wrap(function(obj)
|
|
if obj.code ~= 0 then
|
|
util.error(
|
|
"git status failed: %s",
|
|
vim.trim(obj.stderr or "")
|
|
)
|
|
return
|
|
end
|
|
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
|
|
function Repo.new(gitdir, worktree)
|
|
local self = setmetatable({
|
|
gitdir = gitdir,
|
|
worktree = worktree,
|
|
buffers = {},
|
|
tabs = {},
|
|
status = status.parse(""),
|
|
_events = util.Emitter.new(),
|
|
}, Repo)
|
|
self._schedule_refresh, self._refresh_handle =
|
|
util.debounce(Repo._fetch_status, 50)
|
|
self:start_watcher()
|
|
self:refresh()
|
|
return self
|
|
end
|
|
|
|
function Repo:start_watcher()
|
|
local watcher, err = vim.uv.new_fs_event()
|
|
if not watcher then
|
|
util.error(
|
|
"git: failed to create fs_event for %s: %s",
|
|
self.gitdir,
|
|
err
|
|
)
|
|
return
|
|
end
|
|
local ok, start_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.error("failed to watch %s: %s", self.gitdir, tostring(start_err))
|
|
watcher:close()
|
|
return
|
|
end
|
|
self._watcher = watcher
|
|
end
|
|
|
|
function Repo:close()
|
|
if self._watcher then
|
|
self._watcher:stop()
|
|
self._watcher:close()
|
|
self._watcher = nil
|
|
end
|
|
self._refresh_handle.close()
|
|
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
|
|
---@return ow.Git.Repo.BufState?
|
|
function Repo:state(buf)
|
|
return self.buffers[expand_buf(buf)]
|
|
end
|
|
|
|
---@return string?
|
|
function Repo:head()
|
|
local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
|
|
if not f then
|
|
return nil
|
|
end
|
|
local first = f:read("*l")
|
|
f:close()
|
|
if not first then
|
|
return nil
|
|
end
|
|
local branch = first:match("^ref:%s*refs/heads/(%S+)")
|
|
if branch then
|
|
return branch
|
|
end
|
|
local sha = first:match("^(%x+)")
|
|
if sha then
|
|
return sha:sub(1, 7)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
---@return string[]
|
|
function Repo:list_refs()
|
|
local out = util.exec({
|
|
"git",
|
|
"for-each-ref",
|
|
"--format=%(refname:short)",
|
|
"refs/heads",
|
|
"refs/tags",
|
|
"refs/remotes",
|
|
}, { cwd = self.worktree, silent = true })
|
|
if not out then
|
|
return {}
|
|
end
|
|
local refs = util.split_lines(out)
|
|
table.insert(refs, 1, "HEAD")
|
|
return refs
|
|
end
|
|
|
|
---@param rev string
|
|
---@param short boolean
|
|
---@return string?
|
|
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 = self.worktree, silent = true })
|
|
local trimmed = stdout and vim.trim(stdout) or ""
|
|
return trimmed ~= "" and trimmed or nil
|
|
end
|
|
|
|
---@type table<string, ow.Git.Repo> keyed by worktree
|
|
local repos = {}
|
|
|
|
---@param event ow.Git.Repo.Event
|
|
---@param fn fun(...)
|
|
---@return fun() unsubscribe
|
|
function M.on(event, fn)
|
|
return global:on(event, fn)
|
|
end
|
|
|
|
---@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?
|
|
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 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.error(".git file at %s has no `gitdir:` line", found)
|
|
return nil
|
|
end
|
|
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.BufState?
|
|
function M.state(buf)
|
|
buf = expand_buf(buf)
|
|
local r = find_by_buf(buf)
|
|
return r and r.buffers[buf]
|
|
end
|
|
|
|
---@param buf? integer
|
|
---@param r ow.Git.Repo
|
|
function M.bind(buf, r)
|
|
buf = expand_buf(buf)
|
|
local prev = find_by_buf(buf)
|
|
if prev == r then
|
|
return
|
|
end
|
|
if prev then
|
|
prev.buffers[buf] = nil
|
|
release(prev)
|
|
end
|
|
r.buffers[buf] = { repo = r }
|
|
end
|
|
|
|
---@param buf? integer
|
|
function M.unbind(buf)
|
|
buf = expand_buf(buf)
|
|
local r = find_by_buf(buf)
|
|
if not r then
|
|
return
|
|
end
|
|
r.buffers[buf] = nil
|
|
release(r)
|
|
end
|
|
|
|
---@param buf integer
|
|
---@return boolean
|
|
local function is_worktree_buf(buf)
|
|
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
|
|
return false
|
|
end
|
|
local path = vim.api.nvim_buf_get_name(buf)
|
|
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.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
|
|
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(repos) do
|
|
r:close()
|
|
end
|
|
end
|
|
|
|
return M
|