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 ---@field tabs table ---@field status ow.Git.Status ---@field private _events ow.Git.Util.Emitter ---@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 ---@field private _cache table 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) self._cache = {} 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(), _cache = {}, }, Repo) self._schedule_refresh, self._refresh_handle = util.debounce(Repo._fetch_status, 50) self:start_watcher() self:refresh() return self end ---@generic T ---@param key string ---@param compute fun(self: ow.Git.Repo): T ---@return T function Repo:get_cached(key, compute) local hit = self._cache[key] if hit ~= nil then return hit end local value = compute(self) self._cache[key] = value return value 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() return self:get_cached("refs", function(self) 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 return util.split_lines(out) end) end local PSEUDO_REFS = { "HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", } ---@return string[] function Repo:list_pseudo_refs() return self:get_cached("pseudo_refs", function(self) local refs = {} for _, name in ipairs(PSEUDO_REFS) do if name == "HEAD" or vim.uv.fs_stat(self.gitdir .. "/" .. name) then table.insert(refs, name) end end return refs end) end ---@return string[] function Repo:list_stash_refs() return self:get_cached("stash_refs", function(self) if not vim.uv.fs_stat(self.gitdir .. "/refs/stash") then return {} end local refs = { "stash" } local out = util.exec( { "git", "stash", "list", "--pretty=format:%gd" }, { cwd = self.worktree, silent = true } ) if out then for _, entry in ipairs(util.split_lines(out)) do table.insert(refs, entry) end end return refs end) 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 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