396 lines
10 KiB
Lua
396 lines
10 KiB
Lua
local status = require("git.status")
|
|
local util = require("git.util")
|
|
|
|
---@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.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
|
|
|
|
---@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
|
|
|
|
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
|