311 lines
7.6 KiB
Lua
311 lines
7.6 KiB
Lua
local util = require("util")
|
|
|
|
local UNMERGED = {
|
|
DD = true,
|
|
AU = true,
|
|
UD = true,
|
|
UA = true,
|
|
DU = true,
|
|
AA = true,
|
|
UU = true,
|
|
}
|
|
|
|
---@param code string porcelain v1 XY code
|
|
---@return string? char
|
|
---@return string? hl_group
|
|
local function indicator(code)
|
|
if code == "" then
|
|
return nil
|
|
end
|
|
if code == "??" then
|
|
return "?", "GitUntracked"
|
|
end
|
|
if code == "!!" then
|
|
return "!", "GitIgnored"
|
|
end
|
|
if UNMERGED[code] then
|
|
return "U", "GitUnmerged"
|
|
end
|
|
local x, y = code:sub(1, 1), code:sub(2, 2)
|
|
if x == "R" or y == "R" then
|
|
return "R", "GitRenamed"
|
|
end
|
|
if y == " " and x ~= " " then
|
|
return x, "GitStaged"
|
|
end
|
|
if y == "D" then
|
|
return "D", "GitDeleted"
|
|
end
|
|
return "M", "GitUnstaged"
|
|
end
|
|
|
|
---@param code string
|
|
---@return string?
|
|
local function format(code)
|
|
local char, hl = indicator(code)
|
|
if not char then
|
|
return nil
|
|
end
|
|
return string.format("%%#%s#%s%%*", hl, char)
|
|
end
|
|
|
|
---@param path string
|
|
---@return string? gitdir
|
|
---@return string? worktree
|
|
local function resolve(path)
|
|
local found = vim.fs.find(".git", { upward = true, path = path })[1]
|
|
if not found then
|
|
return nil
|
|
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
|
|
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
|
|
return nil
|
|
end
|
|
if not gitdir:match("^/") then
|
|
gitdir = vim.fs.joinpath(worktree, gitdir)
|
|
end
|
|
return vim.fs.normalize(gitdir), worktree
|
|
end
|
|
|
|
---@class ow.Git.Repo
|
|
---@field gitdir string
|
|
---@field worktree string
|
|
---@field buffers table<integer, true> set of registered buffer numbers
|
|
---@field watcher? uv.uv_fs_event_t
|
|
---@field refresh fun(self: ow.Git.Repo)
|
|
---@field refresh_handle ow.Util.DebounceHandle
|
|
local Repo = {}
|
|
Repo.__index = Repo
|
|
|
|
function Repo:start_watcher()
|
|
local watcher = assert(vim.uv.new_fs_event())
|
|
assert(watcher:start(self.gitdir, {}, function(err, filename)
|
|
if err or (filename ~= "index" and filename ~= "HEAD") then
|
|
return
|
|
end
|
|
self:refresh()
|
|
end))
|
|
self.watcher = watcher
|
|
end
|
|
|
|
function Repo:stop_watcher()
|
|
self.refresh_handle.close()
|
|
if self.watcher then
|
|
self.watcher:stop()
|
|
self.watcher:close()
|
|
self.watcher = nil
|
|
end
|
|
end
|
|
|
|
---@param buf integer
|
|
function Repo:add_buffer(buf)
|
|
self.buffers[buf] = true
|
|
end
|
|
|
|
---@param buf integer
|
|
function Repo:remove_buffer(buf)
|
|
self.buffers[buf] = nil
|
|
end
|
|
|
|
---@return boolean
|
|
function Repo:has_buffers()
|
|
return next(self.buffers) ~= nil
|
|
end
|
|
|
|
---@param repo ow.Git.Repo
|
|
local function do_refresh(repo)
|
|
vim.system({
|
|
"git",
|
|
"-c",
|
|
"core.quotePath=false",
|
|
"status",
|
|
"--porcelain=v1",
|
|
}, { cwd = repo.worktree, text = true }, function(obj)
|
|
vim.schedule(function()
|
|
local statuses = {}
|
|
if obj.code == 0 then
|
|
for line in (obj.stdout or ""):gmatch("[^\r\n]+") do
|
|
local code = line:sub(1, 2)
|
|
local path_part = line:sub(4)
|
|
local arrow = path_part:find(" -> ", 1, true)
|
|
if arrow then
|
|
path_part = path_part:sub(arrow + 4)
|
|
end
|
|
statuses[vim.fs.joinpath(repo.worktree, path_part)] =
|
|
format(code)
|
|
end
|
|
end
|
|
for buf in pairs(repo.buffers) do
|
|
if not vim.api.nvim_buf_is_valid(buf) then
|
|
repo.buffers[buf] = nil
|
|
else
|
|
local status = statuses[vim.api.nvim_buf_get_name(buf)]
|
|
if vim.b[buf].git_status ~= status then
|
|
vim.b[buf].git_status = status
|
|
vim.cmd.redrawstatus({ bang = true })
|
|
end
|
|
end
|
|
end
|
|
vim.api.nvim_exec_autocmds("User", {
|
|
pattern = "GitRefresh",
|
|
data = { gitdir = repo.gitdir, worktree = repo.worktree },
|
|
})
|
|
end)
|
|
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 = {},
|
|
}, Repo)
|
|
self.refresh, self.refresh_handle = util.debounce(do_refresh, 50)
|
|
self:start_watcher()
|
|
return self
|
|
end
|
|
|
|
---@type table<string, ow.Git.Repo>
|
|
local repo_by_gitdir = {}
|
|
|
|
---@type table<integer, ow.Git.Repo>
|
|
local repo_by_buf = {}
|
|
|
|
---@param buf integer
|
|
---@return ow.Git.Repo?
|
|
local function register(buf)
|
|
local existing = repo_by_buf[buf]
|
|
if existing then
|
|
return existing
|
|
end
|
|
local path = vim.api.nvim_buf_get_name(buf)
|
|
if path == "" then
|
|
return nil
|
|
end
|
|
local gitdir, worktree = resolve(path)
|
|
if not gitdir or not worktree then
|
|
return nil
|
|
end
|
|
local repo = repo_by_gitdir[gitdir]
|
|
if not repo then
|
|
repo = Repo.new(gitdir, worktree)
|
|
repo_by_gitdir[gitdir] = repo
|
|
end
|
|
repo:add_buffer(buf)
|
|
repo_by_buf[buf] = repo
|
|
return repo
|
|
end
|
|
|
|
---@param buf integer
|
|
local function unregister(buf)
|
|
local repo = repo_by_buf[buf]
|
|
if not repo then
|
|
return
|
|
end
|
|
repo_by_buf[buf] = nil
|
|
repo:remove_buffer(buf)
|
|
if not repo:has_buffers() then
|
|
repo:stop_watcher()
|
|
repo_by_gitdir[repo.gitdir] = nil
|
|
end
|
|
end
|
|
|
|
---@param buf integer
|
|
local function refresh_buf(buf)
|
|
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
|
|
return
|
|
end
|
|
local repo = register(buf)
|
|
if not repo then
|
|
vim.b[buf].git_status = nil
|
|
return
|
|
end
|
|
repo:refresh()
|
|
end
|
|
|
|
local function stop_all()
|
|
for _, repo in pairs(repo_by_gitdir) do
|
|
repo:stop_watcher()
|
|
end
|
|
end
|
|
|
|
---@param path string
|
|
---@return string?
|
|
local function head(path)
|
|
local gitdir = resolve(path)
|
|
if not gitdir then
|
|
return nil
|
|
end
|
|
local f = io.open(vim.fs.joinpath(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
|
|
|
|
---Resolve a git revision to its object SHA. Returns nil if the ref can't be
|
|
---resolved (root-commit's `^`, blob's `^`, malformed ref, etc.). When `short`
|
|
---is true, the result is abbreviated via `core.abbrev` (auto-extended by git
|
|
---to keep the prefix unique in the current repo).
|
|
---@param worktree string
|
|
---@param ref string
|
|
---@param short? boolean
|
|
---@return string?
|
|
local function rev_parse(worktree, ref, short)
|
|
local cmd = { "git", "rev-parse", "--verify", "--quiet" }
|
|
if short then
|
|
table.insert(cmd, "--short")
|
|
end
|
|
table.insert(cmd, ref)
|
|
local result = vim.system(cmd, { cwd = worktree, text = true }):wait()
|
|
if result.code ~= 0 then
|
|
return nil
|
|
end
|
|
local sha = vim.trim(result.stdout or "")
|
|
if sha == "" then
|
|
return nil
|
|
end
|
|
return sha
|
|
end
|
|
|
|
return {
|
|
UNMERGED = UNMERGED,
|
|
head = head,
|
|
indicator = indicator,
|
|
refresh_buf = refresh_buf,
|
|
resolve = resolve,
|
|
rev_parse = rev_parse,
|
|
stop_all = stop_all,
|
|
unregister = unregister,
|
|
}
|