Files
nvim/lua/git/repo.lua
T

447 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 immutable boolean?
---@field index_writer boolean?
---@field index_mode string?
---@field log_max_count integer?
---@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",
"--untracked-files=all",
}
---@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, r: ow.Git.Repo)
---@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, r)
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