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. 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 == "" then path = vim.fn.getcwd() end return M.resolve(path) end ---@class ow.Git.Repo ---@field gitdir string ---@field worktree string ---@field buffers table 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 local repo_by_gitdir = {} ---@type table 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 ---Verify a revspec resolves to an existing git object. `cat-file -e` is ---git's cheapest existence check, and unlike `rev-parse --verify` it ---also accepts the `:` form that BufReadCmd revspec URIs ---use. ---@param worktree string ---@param revspec string ---@return boolean function M.object_exists(worktree, revspec) return util.exec( { "git", "cat-file", "-e", revspec }, { cwd = worktree, silent = true } ) ~= nil end return M