local status = require("git.status") local util = require("git.util") ---@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.BufState ---@field repo ow.Git.Repo ---@field sha string? ---@field parent_sha string? ---@field index_writer boolean? ---@field index_mode string? ---@field log_max_count integer? ---@field pending_content string? ---@class ow.Git.Repo ---@field gitdir string ---@field worktree string ---@field buffers table ---@field watcher? uv.uv_fs_event_t ---@field refresh fun(self: ow.Git.Repo) ---@field refresh_handle ow.Git.Util.DebounceHandle ---@field private refresh_listeners (fun(r: ow.Git.Repo, porcelain_stdout: string?))[] local Repo = {} Repo.__index = Repo ---@param r ow.Git.Repo local function do_refresh(r) vim.system( { "git", "-c", "core.quotePath=false", "status", "--porcelain=v1", "--branch", }, { cwd = r.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) 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(r.worktree, path_part)] = status.format(code) end end else util.warning( "git status failed: %s", vim.trim(obj.stderr or "") ) end local dirty = false for buf in pairs(r.buffers) do if not vim.api.nvim_buf_is_valid(buf) then r.buffers[buf] = nil else local name = vim.api.nvim_buf_get_name(buf) local object = require("git.object") local log = require("git.log") if name:sub(1, #object.URI_PREFIX) == object.URI_PREFIX then object.read_uri(buf) elseif name:sub(1, #log.URI_PREFIX) == log.URI_PREFIX then log.read_uri(buf) else local s = statuses[vim.fn.resolve(name)] if vim.b[buf].git_status ~= s then vim.b[buf].git_status = s dirty = true end end end end if dirty then vim.cmd.redrawstatus({ bang = true }) end r:notify_refresh(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 = {}, refresh_listeners = {}, }, Repo) self.refresh, self.refresh_handle = util.debounce(do_refresh, 50) self:start_watcher() return self end function Repo:start_watcher() local watcher, err = vim.uv.new_fs_event() if not watcher then util.warning( "git: failed to create fs_event for %s: %s", self.gitdir, err ) return end local ok, 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.warning("failed to watch %s: %s", self.gitdir, tostring(err)) watcher:close() return end self.watcher = watcher end function Repo:stop_watcher() 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) buf = expand_buf(buf) if not self.buffers[buf] then self.buffers[buf] = { repo = self } end end ---@param buf integer function Repo:remove_buffer(buf) self.buffers[expand_buf(buf)] = nil end ---@param buf integer ---@return ow.Git.BufState? function Repo:state(buf) return self.buffers[expand_buf(buf)] end ---@return boolean function Repo:has_buffers() return next(self.buffers) ~= nil end ---@param fn fun(r: ow.Git.Repo, porcelain_stdout: string?) ---@return fun() unsubscribe function Repo:on_refresh(fn) table.insert(self.refresh_listeners, fn) return function() for i, f in ipairs(self.refresh_listeners) do if f == fn then table.remove(self.refresh_listeners, i) return end end end end ---@param porcelain_stdout string? function Repo:notify_refresh(porcelain_stdout) for _, fn in ipairs(self.refresh_listeners) do fn(self, porcelain_stdout) end 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 local M = {} ---@type table local repo_by_gitdir = {} ---@type table local repo_by_buf = {} ---@param path string ---@return ow.Git.Repo? function M.resolve(path) path = vim.fn.resolve(path) local found = vim.fs.find(".git", { upward = true, path = path })[1] if not found then return nil end local stat = vim.uv.fs_stat(found) if not stat then return nil end local worktree = vim.fs.dirname(found) 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.warning(".git file at %s has no `gitdir:` line", found) return nil end if rel:match("^/") then gitdir = rel else gitdir = vim.fs.joinpath(worktree, rel) end gitdir = vim.fs.normalize(gitdir) end local r = repo_by_gitdir[gitdir] if not r then r = Repo.new(gitdir, worktree) repo_by_gitdir[gitdir] = r end return r end ---@param buf integer? ---@return ow.Git.Repo? function M.find(buf) buf = expand_buf(buf) local existing = repo_by_buf[buf] if existing then return existing end local path = vim.api.nvim_buf_get_name(buf) if path == "" or path:match("^%a+://") then path = vim.fn.getcwd() end return M.resolve(path) end ---@param buf integer? ---@return ow.Git.BufState? function M.state(buf) buf = expand_buf(buf) local r = repo_by_buf[buf] return r and r.buffers[buf] end ---@param buf integer ---@param r ow.Git.Repo function M.attach(buf, r) buf = expand_buf(buf) if repo_by_buf[buf] == r then return end if repo_by_buf[buf] then repo_by_buf[buf]:remove_buffer(buf) end r:add_buffer(buf) repo_by_buf[buf] = r end ---@param buf integer ---@return ow.Git.Repo? function M.register(buf) buf = expand_buf(buf) local r = M.find(buf) if not r then return nil end if repo_by_buf[buf] ~= r then M.attach(buf, r) end return r end ---@param buf integer function M.unregister(buf) buf = expand_buf(buf) local r = repo_by_buf[buf] if not r then return end repo_by_buf[buf] = nil r:remove_buffer(buf) if not r:has_buffers() then r:stop_watcher() repo_by_gitdir[r.gitdir] = nil end end ---@param buf integer function M.refresh(buf) buf = expand_buf(buf) if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then return end local path = vim.api.nvim_buf_get_name(buf) if path == "" or path:match("^%a+://") then return end local r = M.register(buf) if not r then vim.b[buf].git_status = nil return end r:refresh() end function M.stop_all() for _, r in pairs(repo_by_gitdir) do r:stop_watcher() end end return M