local util = require("util") local 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 local function indicator(code) if code == "" then return nil end if code == "??" then return "?", "GitUntracked" end if code == "!!" then return "!", "GitIgnored" end if 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 if y == "M" or y == "T" then return "M", "GitUnstaged" end return "M", "GitUnstaged" end ---@param code string ---@return string? local function format(code) local char, hl = 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 local function 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 return nil end if not gitdir:match("^/") then gitdir = vim.fs.joinpath(worktree, gitdir) end return vim.fs.normalize(gitdir), worktree end ---@class ow.Git.Repo ---@field gitdir string ---@field worktree string ---@field buffers integer[] ---@field watcher? uv.uv_fs_event_t ---@field refresh fun(self: ow.Git.Repo) ---@field refresh_handle ow.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() self.refresh_handle.close() if self.watcher then self.watcher:stop() self.watcher:close() self.watcher = nil end end ---@param buf integer function Repo:add_buffer(buf) table.insert(self.buffers, buf) end ---@param buf integer function Repo:remove_buffer(buf) for i, b in ipairs(self.buffers) do if b == buf then table.remove(self.buffers, i) break end end end ---@param repo ow.Git.Repo local function do_refresh(repo) vim.system({ "git", "-c", "core.quotePath=false", "status", "--porcelain=v1", }, { cwd = repo.worktree, text = true }, function(obj) vim.schedule(function() local statuses = {} if obj.code == 0 then for line in (obj.stdout or ""):gmatch("[^\r\n]+") do local code = line:sub(1, 2) local path_part = line:sub(4) local arrow = path_part:find(" -> ", 1, true) if arrow then path_part = path_part:sub(arrow + 4) end statuses[vim.fs.joinpath(repo.worktree, path_part)] = format(code) end end for _, buf in ipairs(repo.buffers) do if vim.api.nvim_buf_is_valid(buf) then local status = statuses[vim.api.nvim_buf_get_name(buf)] if vim.b[buf].git_status ~= status then vim.b[buf].git_status = status vim.cmd.redrawstatus({ bang = true }) end end end vim.api.nvim_exec_autocmds("User", { pattern = "GitRefresh", data = { gitdir = repo.gitdir, worktree = repo.worktree }, }) end) 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 = 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 local function unregister(buf) local repo = repo_by_buf[buf] if not repo then return end repo_by_buf[buf] = nil repo:remove_buffer(buf) if #repo.buffers == 0 then repo:stop_watcher() repo_by_gitdir[repo.gitdir] = nil end end ---@param buf integer local function 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 local function stop_all() for _, repo in pairs(repo_by_gitdir) do repo:stop_watcher() end end ---@param path string ---@return string? local function head(path) local gitdir = 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 ---resolved (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? local function 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 result = vim.system(cmd, { cwd = worktree, text = true }):wait() if result.code ~= 0 then return nil end local sha = vim.trim(result.stdout or "") if sha == "" then return nil end return sha end return { UNMERGED = UNMERGED, head = head, indicator = indicator, refresh_buf = refresh_buf, resolve = resolve, rev_parse = rev_parse, stop_all = stop_all, unregister = unregister, }