local notify = require("jjannotate.util").notify ---@param epoch integer ---@return string local function relative_time(epoch) local seconds = os.time() - epoch if seconds < 1 then return "just now" end local units = { { 31536000, "year" }, { 2592000, "month" }, { 604800, "week" }, { 86400, "day" }, { 3600, "hour" }, { 60, "minute" }, { 1, "second" }, } for _, unit in ipairs(units) do local n = math.floor(seconds / unit[1]) if n >= 1 then return string.format("%d %s%s ago", n, unit[2], n == 1 and "" or "s") end end return "just now" end ---@param raw string ---@return jjannotate.AnnotateResult local function parse(raw) local lines = {} ---@type jjannotate.Line[] local longest_timestamp = 0 local last_change_id ---@type string | nil local row_index = {} ---@type jjannotate.Line[] local change_id_index = {} ---@type table local change_id, author, timestamp ---@type string?, string?, string? local row = 0 for _, line in ipairs(vim.split(raw, "\n")) do local sha = line:match("^(%x+) %d+ %d+") if sha then change_id = sha:sub(1, 8) author, timestamp = nil, nil elseif line:match("^author ") then author = line:sub(8) elseif line:match("^author%-time ") then local epoch = tonumber(line:match("^author%-time (%d+)")) timestamp = epoch and relative_time(epoch) or "" elseif line:sub(1, 1) == "\t" then local parsed = { range = { start = row, _end = row }, change_id = change_id or "", author = author or "", timestamp = timestamp or "", } if parsed.change_id == last_change_id then lines[#lines].range._end = row row_index[row] = lines[#lines] else table.insert(lines, parsed) longest_timestamp = math.max(longest_timestamp, #parsed.timestamp) row_index[row] = parsed local cid = parsed.change_id if not change_id_index[cid] then change_id_index[cid] = {} end table.insert(change_id_index[cid], parsed) end last_change_id = parsed.change_id row = row + 1 end end return { lines = lines, longest_timestamp = longest_timestamp, row_index = row_index, change_id_index = change_id_index, } end ---@param repo string repo root ---@param path string path within `repo` ---@param revision string ---@param cb fun(result: jjannotate.AnnotateResult) local function annotate(repo, path, revision, cb) if path == "" then notify("Can't annotate a scratch buffer", vim.log.levels.WARN) cb(nil) return end vim.system({ "git", "blame", "--line-porcelain", revision, "--", path, }, { cwd = repo, text = true }, function(out) if out.code ~= 0 then notify( string.format( "Failed to `git blame %s` (%d): %s", path, out.code, out.stderr or "no stderr" ), vim.log.levels.ERROR ) cb(nil) return end if not out.stdout or out.stdout == "" then notify("No output from `git blame`", vim.log.levels.ERROR) cb(nil) return end cb(parse(out.stdout)) end) end ---@param repo string repo root ---@param revision string local function show(repo, revision) vim.cmd.tabnew() vim.fn.jobstart({ "git", "--no-pager", "show", revision }, { term = true, cwd = repo }) local buf = vim.api.nvim_get_current_buf() vim.bo[buf].buflisted = false vim.api.nvim_buf_set_name(buf, string.format("jjannotate://show/%s", revision)) vim.keymap.set("n", "q", "bwipe!", { buffer = buf, desc = "Close" }) vim.keymap.set("t", "q", ":bwipe!", { buffer = buf, desc = "Close" }) end ---@param repo string repo root ---@param path string path within `repo` ---@param revision string ---@param cb fun(contents: string?) local function file_contents(repo, path, revision, cb) vim.system({ "git", "--no-pager", "show", string.format("%s:%s", revision, path), }, { cwd = repo, text = true }, function(out) if out.code ~= 0 then notify( string.format( "Failed to get file contents for %s@%s (%d): %s", path, revision, out.code, out.stderr or "no stderr" ), vim.log.levels.ERROR ) cb(nil) return end cb(out.stdout) end) end ---@param out vim.SystemCompleted ---@return string? local function parse_resolve_revision(out) local id = out.stdout and vim.trim(out.stdout) or "" if out.code ~= 0 or id == "" then notify( string.format("Failed to resolve revision (%d): %s", out.code, out.stderr or "no stderr"), vim.log.levels.ERROR ) return end return id end --- Resolve a revision to a single commit hash. If `cb` is passed, it is called --- with the result. Otherwise, this blocks until the result is returned. ---@overload fun(repo: string, revision: string?): string? ---@overload fun(repo: string, revision: string?, cb: fun(commit: string?)) ---@param repo string repo root ---@param revision string? defaults to `HEAD` ---@param cb fun(commit: string?)? ---@return string? local function resolve_revision(repo, revision, cb) revision = revision or "HEAD" local obj = vim.system({ "git", "rev-parse", "--verify", revision .. "^{commit}", }, { cwd = repo, text = true }, function(out) if cb then cb(parse_resolve_revision(out)) end end) if not cb then local out = obj:wait() return parse_resolve_revision(out) end end ---@type jjannotate.Backend return { name = "git", marker = ".git", annotate = annotate, show = show, file_contents = file_contents, resolve_revision = resolve_revision, }