Files
nvim/lua/git/repo.lua
T

348 lines
9.5 KiB
Lua

local util = require("git.util")
local M = {}
M.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
function M.indicator(code)
if code == "" then
return nil
end
if code == "??" then
return "?", "GitUntracked"
end
if code == "!!" then
return "!", "GitIgnored"
end
if M.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 = M.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
function M.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
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
end
---Resolve the gitdir/worktree from the current buffer's file path,
---falling back to `vim.fn.getcwd()` when the buffer is unnamed or
---carries a synthetic URI (`git://`, `gitlog://`) that isn't a real
---filesystem path. Returns nil for both when not inside a git repo.
---@return string? gitdir
---@return string? worktree
function M.resolve_cwd()
local path = vim.api.nvim_buf_get_name(0)
if path == "" or path:match("^%a+://") then
path = vim.fn.getcwd()
end
return M.resolve(path)
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.Git.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()
-- Stop the libuv watcher first so no further fs-events can trigger
-- self:refresh(). Only then tear down the debounce handle. The reverse
-- order leaves a window where an in-flight watcher callback would call
-- a closed debounce timer.
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)
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)
-- `--branch` adds the `## branch...upstream [ahead/behind]` line that
-- the sidebar parses. The per-buffer indicator only needs the XY +
-- path lines, so it ignores `##` lines below. Running with `--branch`
-- lets the sidebar reuse this single subprocess via the GitRefresh
-- data payload instead of spawning its own.
vim.system(
{
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
},
{ cwd = repo.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)
-- ` -> ` only appears in renames/copies. Without
-- this guard, a literal filename containing the
-- arrow (rare with `core.quotePath=false`) would
-- be mis-parsed.
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(repo.worktree, path_part)] =
format(code)
end
end
else
util.warning(
"git status failed: %s",
vim.trim(obj.stderr or "")
)
end
local dirty = false
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
dirty = true
end
end
end
if dirty then
vim.cmd.redrawstatus({ bang = true })
end
vim.api.nvim_exec_autocmds("User", {
pattern = "GitRefresh",
data = {
gitdir = repo.gitdir,
worktree = repo.worktree,
porcelain_stdout = 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 = {},
}, 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 = M.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
function M.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
function M.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
function M.stop_all()
for _, repo in pairs(repo_by_gitdir) do
repo:stop_watcher()
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")
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 parsed (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?
function M.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 stdout = util.exec(cmd, { cwd = worktree, silent = true })
local trimmed = stdout and vim.trim(stdout) or ""
return trimmed ~= "" and trimmed or nil
end
return M