local status = require("git.core.status") local util = require("git.core.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 initialized boolean? ---@field immutable boolean? ---@field index_writer boolean? ---@field index_mode string? ---@alias ow.Git.Repo.Event ---| "change" local global = util.Emitter.new() ---@type table keyed by worktree local repos = {} ---@param r ow.Git.Repo local function release_if_unused(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 ---@class ow.Git.Repo.Change ---@field paths table ---@field branch_changed boolean ---@class ow.Git.Repo.RefreshOpts ---@field invalidate boolean? ---@class ow.Git.Repo.SubmoduleEntry ---@field worktree string ---@field unsub fun()? ---@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 _watchers table ---@field private _schedule_refresh fun(self: ow.Git.Repo) ---@field private _refresh_handle ow.Git.Util.DebounceHandle ---@field private _cache table ---@field private _fetch_epoch integer ---@field private _pending_invalidate boolean ---@field package _submodules table local Repo = {} Repo.__index = Repo local STATUS_ARGS = { "--no-optional-locks", "-c", "core.quotePath=false", "status", "--porcelain=v2", "--branch", "--ignored", "--untracked-files=all", "-z", } local PSEUDO_REFS = { "HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", } ---@type table local INVALIDATION_RULES = { head = function(relpath) return relpath == "HEAD" or vim.startswith(relpath, "refs/heads/") or relpath == "packed-refs" end, refs = function(relpath) return vim.startswith(relpath, "refs/heads/") or vim.startswith(relpath, "refs/tags/") or vim.startswith(relpath, "refs/remotes/") or relpath == "packed-refs" end, pseudo_refs = function(relpath) return vim.tbl_contains(PSEUDO_REFS, relpath) end, stash_refs = function(relpath) return relpath == "refs/stash" or relpath == "logs/refs/stash" end, config = function(relpath) return relpath == "config" end, } ---@param relpath string ---@return boolean local function affects_resolve(relpath) return vim.startswith(relpath, "refs/") or relpath == "packed-refs" or relpath == "HEAD" or relpath == "FETCH_HEAD" end ---@private ---@param prefix string function Repo:_clear_cache_prefix(prefix) for key in pairs(self._cache) do if vim.startswith(key, prefix) then self._cache[key] = nil end end end ---@private ---@param relpath string function Repo:_invalidate(relpath) for key, affects in pairs(INVALIDATION_RULES) do if self._cache[key] ~= nil and affects(relpath) then self._cache[key] = nil end end if affects_resolve(relpath) then self:_clear_cache_prefix("resolve:") self:_clear_cache_prefix("head_blob:") end if relpath == "index" then self:_clear_cache_prefix("index:") end end ---@param path string ---@return table>? local function read_git_config(path) local f = io.open(path, "r") if not f then return nil end local content = f:read("*a") f:close() local out = {} local section for line in content:gmatch("[^\n]+") do local trimmed = line:match("^%s*(.-)%s*$") if trimmed ~= "" and not trimmed:match("^[#;]") then local s = trimmed:match("^%[(.-)%]$") if s then section = s out[section] = out[section] or {} elseif section then local key, value = trimmed:match("^(%S+)%s*=%s*(.-)$") if key then out[section][key] = value end end end end return out end ---@param gitdir string ---@return string[] local function find_submodules(gitdir) local handle = vim.uv.fs_scandir(vim.fs.joinpath(gitdir, "modules")) if not handle then return {} end local out = {} while true do local name, typ = vim.uv.fs_scandir_next(handle) if not name then break end if typ == "directory" then table.insert(out, name) end end return out end ---@private function Repo:_fetch_status() if self._pending_invalidate then self._cache = {} self._pending_invalidate = false end local prior_entries = self.status.entries local prior_branch = self.status.branch self._fetch_epoch = self._fetch_epoch + 1 local epoch = self._fetch_epoch util.git(STATUS_ARGS, { cwd = self.worktree, on_exit = function(result) if epoch ~= self._fetch_epoch then return end if result.code ~= 0 then util.error( "git status failed: %s", vim.trim(result.stderr or "") ) return end self.status = status.parse(result.stdout or "") local change = { paths = status.diff_entries( prior_entries, self.status.entries ), branch_changed = not vim.deep_equal( prior_branch, self.status.branch ), } if next(change.paths) == nil and not change.branch_changed then return end self._events:emit("change", change, self.status) global:emit("change", self, change, self.status) end, }) end ---@param opts ow.Git.Repo.RefreshOpts? function Repo:refresh(opts) if opts and opts.invalidate then self._pending_invalidate = true end 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 = {}, _fetch_epoch = 0, _pending_invalidate = false, _submodules = {}, }, Repo) self._schedule_refresh, self._refresh_handle = util.debounce(Repo._fetch_status, 50) self:start_watcher() self:refresh() if vim.g.git_submodule_recursion ~= false then self:_start_modules_watcher() for _, name in ipairs(find_submodules(gitdir)) do self:_register_submodule(name) end end 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 ---@param path string ---@param on_event fun(filename: string?) ---@return uv.uv_fs_event_t? local function start_fs_event(path, on_event) local watcher = vim.uv.new_fs_event() if not watcher then return nil end local ok = watcher:start(path, {}, function(err, filename) if err then return end on_event(filename) end) if not ok then watcher:close() return nil end return watcher end ---@private ---@param name string function Repo:_unregister_submodule(name) local entry = self._submodules[name] if not entry then return end self._submodules[name] = nil if entry.unsub then entry.unsub() end local child = repos[entry.worktree] if child then release_if_unused(child) end end ---@private ---@param name string function Repo:_register_submodule(name) local sub_gitdir = vim.fs.joinpath(self.gitdir, "modules", name) local cfg = read_git_config(vim.fs.joinpath(sub_gitdir, "config")) local raw = cfg and cfg.core and cfg.core.worktree if not raw then return end local wt = raw:match("^/") and raw or vim.fs.joinpath(sub_gitdir, raw) wt = vim.fs.normalize(wt) local existing = self._submodules[name] if existing and existing.worktree == wt then return end if existing then self:_unregister_submodule(name) end local child = repos[wt] or M.resolve(wt) if not child then return end self._submodules[name] = { worktree = wt, unsub = child:on("change", function() self:refresh() end), } end ---@private function Repo:_start_modules_watcher() local dir = vim.fs.joinpath(self.gitdir, "modules") if self._watchers[dir] then return end if not vim.uv.fs_stat(dir) then return end self._watchers[dir] = start_fs_event(dir, function(filename) if not filename then return end if vim.uv.fs_stat(vim.fs.joinpath(dir, filename)) then self:_register_submodule(filename) else self:_unregister_submodule(filename) end end) end ---@private function Repo:_stop_modules_watcher() local dir = vim.fs.joinpath(self.gitdir, "modules") local w = self._watchers[dir] if w then w:stop() w:close() self._watchers[dir] = nil end for _, name in ipairs(vim.tbl_keys(self._submodules)) do self:_unregister_submodule(name) end end ---@private ---@param relpath string function Repo:_handle_fs_event(relpath) if vim.startswith(relpath, "objects") then return end self:_invalidate(relpath) if relpath == "modules" and vim.g.git_submodule_recursion ~= false then if vim.uv.fs_stat(vim.fs.joinpath(self.gitdir, "modules")) then self:_start_modules_watcher() for _, name in ipairs(find_submodules(self.gitdir)) do self:_register_submodule(name) end else self:_stop_modules_watcher() end end if vim.startswith(relpath, "logs") then return end self:refresh() end ---@private ---@param relpath string gitdir-relative path of the directory to watch function Repo:_watch_tree(relpath) local path = vim.fs.joinpath(self.gitdir, relpath) if self._watchers[path] then return end local stat = vim.uv.fs_stat(path) if not stat or stat.type ~= "directory" then return end local watcher = start_fs_event(path, function(filename) if not vim.uv.fs_stat(path) then local w = self._watchers[path] --[[@as uv.uv_fs_event_t?]] if w then w:stop() w:close() self._watchers[path] = nil end return end if filename then local child = vim.fs.joinpath(relpath, filename) self:_handle_fs_event(child) vim.schedule(function() self:_watch_tree(child) end) else self:refresh({ invalidate = true }) end end) if not watcher then return end self._watchers[path] = watcher local handle = vim.uv.fs_scandir(path) if not handle then return end while true do local name, typ = vim.uv.fs_scandir_next(handle) if not name then break end if typ == "directory" then self:_watch_tree(vim.fs.joinpath(relpath, name)) end end end function Repo:start_watcher() self._watchers = {} local top = start_fs_event(self.gitdir, function(filename) if not filename then self:refresh({ invalidate = true }) return end self:_handle_fs_event(filename) end) if not top then util.error("git: failed to watch %s", self.gitdir) return end self._watchers[self.gitdir] = top self:_watch_tree("refs") end function Repo:close() for _, watcher in pairs(self._watchers) do watcher:stop() watcher:close() end self._watchers = {} self:_stop_modules_watcher() self._refresh_handle.close() self._events:clear() end ---@overload fun(event: "change", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun() 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() return self:get_cached("head", function(self) 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) end ---@return string[] function Repo:list_refs() return self:get_cached("refs", function(self) local out = util.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 ---@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.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 args = { "rev-parse", "--verify", "--quiet" } if short then table.insert(args, "--short") end table.insert(args, rev) local stdout = util.git(args, { cwd = self.worktree, silent = true }) local trimmed = stdout and vim.trim(stdout) or "" return trimmed ~= "" and trimmed or nil end ---@param rel string worktree-relative path ---@return string? function Repo:index_sha(rel) local sha = self:get_cached("index:" .. rel, function(self) return self:rev_parse(":" .. rel, false) or false end) return sha or nil end ---@param rel string worktree-relative path ---@return string? function Repo:head_sha(rel) local sha = self:get_cached("head_blob:" .. rel, function(self) return self:rev_parse("HEAD:" .. rel, false) or false end) return sha or nil end ---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing" ---@param abbrev string ---@return string? full_sha ---@return ow.Git.Repo.ResolveStatus function Repo:resolve_sha(abbrev) local result = self:get_cached("resolve:" .. abbrev, function(self) local out = util.git( { "rev-parse", "--disambiguate=" .. abbrev }, { cwd = self.worktree, silent = true } ) local trimmed = out and vim.trim(out) or "" if trimmed == "" then return { nil, "missing" } end local lines = util.split_lines(trimmed) if #lines == 1 then return { lines[1], "ok" } end return { nil, "ambiguous" } end) return result[1], result[2] end ---@private ---@return table> function Repo:_config() return self:get_cached("config", function(self) return read_git_config(vim.fs.joinpath(self.gitdir, "config")) or {} end) end ---@private ---@return boolean function Repo:_ignorecase() local cfg = self:_config() return cfg.core and cfg.core.ignorecase == "true" or false end ---@param rel string ---@return ow.Git.Status.Entry? function Repo:status_entry_for(rel) local direct = self.status.entries[rel] if direct or not self:_ignorecase() then return direct end local lower = rel:lower() for path, entry in pairs(self.status.entries) do if path:lower() == lower then return entry end end return nil end ---@type table local no_repo_dirs = {} ---@overload fun(event: "change", fn: fun(r: ow.Git.Repo, change: ow.Git.Repo.Change, status: ow.Git.Status)): fun() 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_change(prefix, fn) return M.on("change", function(r) for buf in pairs(r.buffers) do if vim.api.nvim_buf_is_loaded(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 ---@return table function M.all() return repos 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 local best for wt in pairs(repos) do if path:sub(1, #wt + 1) == wt .. "/" then if not best or #wt > #best then best = wt end end end return best and repos[best] or 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 util.is_uri(path) 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) if type(arg) ~= "string" then local existing = find_by_buf(expand_buf(arg)) if existing then return existing end end local path if type(arg) == "string" then path = vim.fn.resolve(arg) else path = path_for_buf(expand_buf(arg)) end local dir = vim.fs.dirname(path) if no_repo_dirs[dir] then return nil end local found = vim.fs.find(".git", { upward = true, path = path })[1] if not found then no_repo_dirs[dir] = true 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 for d in pairs(no_repo_dirs) do if d == worktree or vim.startswith(d, worktree .. "/") then no_repo_dirs[d] = nil end end 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_if_unused(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_if_unused(r) end ---@param buf integer ---@return boolean function M.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 util.is_uri(path) end ---@param buf? integer function M.track(buf) buf = expand_buf(buf) if not M.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() no_repo_dirs = {} 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_if_unused(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_if_unused(r) return end end end function M.stop_all() for _, r in pairs(repos) do r:close() end end return M