/** * @file The Changes panel's history READS (issue: history & search, like the * VS Code / GitLens / JetBrains git log): the commit graph page by page with * its ref badges and search, one commit's details and its patch, a file's * history across renames (`--follow`), blame at any revision, a read-only * tree at any revision, a comparison of two refs, the plan a multi-commit * cherry-pick/revert would run, and the state of one that is paused. * * Same rules as git-diff.js, whose guards these reuse: argv only through * worktree.js's `git()` (literal pathspecs), every caller path through * `lanePath`, every revision through `SAFE_REV` and then resolved to a full * commit sha before git sees it as a revision, a secret file's CONTENT never * returned at any revision (`isSecret`/`isSecretOnDisk`), the lane's cwd the * boundary (`--relative`, and a tree listing that is cwd-limited by git * itself), and every output capped. Heavy walks carry a timeout so a huge * repo answers with an error, not a request held open. * @author Nguyễn Ngọc Trí Vĩ */ const path = require("node:path"); const worktree = require("./worktree"); const { lanePath, isSecretOnDisk, parseUnifiedDiff, elideLongLines, parseNumstat, MAX_FILE_VIEW_BYTES, } = require("./git-diff"); const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts }); /** A walk of the history (log, blame, contains) may take this long. */ const READ_TIMEOUT_MS = 20_000; /** Commits one history page may hold, and the default page. */ const MAX_PAGE = 500; const DEFAULT_PAGE = 100; /** Furthest a page may start: past this, narrow the search instead. */ const MAX_SKIP = 1_000_000; /** Longest search text / author accepted. */ const MAX_QUERY = 200; /** Commits each side of a comparison lists. */ const MAX_COMPARE_COMMITS = 300; /** Files a comparison lists. */ const MAX_COMPARE_FILES = 1000; /** Bytes of one file's patch in a comparison. */ const MAX_PATCH_BYTES = 200_000; /** Bytes of a commit's patch (Copy patch). */ const MAX_FORMAT_PATCH = 2 * 1024 * 1024; /** Commits one cherry-pick / revert may name. */ const MAX_PICK = 50; /** Entries one tree listing returns. */ const MAX_TREE = 5000; /** Branches / tags "contained in" lists, each. */ const MAX_CONTAINS = 50; /** A revision the request may name: a sha or a ref name that cannot read as * an option or a range (git-diff.js's own rule). */ const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}(?:[~^][0-9]{0,4}){0,8}$/; /** A commit id: abbreviated or full (64 = a SHA-256 repository). */ const SHA_RE = /^[0-9a-f]{4,64}$/i; function bad(code, message) { const err = new Error(message); err.code = code; return err; } /** A query value as a trimmed string, capped, or "" — EBADQUERY past the cap. */ function text(value, name) { if (value == null) return ""; if (typeof value !== "string") throw bad("EBADQUERY", `${name} must be text`); const v = value.trim(); if (v.length > MAX_QUERY || v.includes("\0")) throw bad("EBADQUERY", `${name} is too long`); return v; } function int(value, def, min, max) { const n = Math.floor(Number(value)); if (!Number.isFinite(n)) return def; return Math.min(Math.max(n, min), max); } /** * The full sha of the commit `rev` names, or EBADREV. `rev` passes SAFE_REV * first, so git only ever sees `^{commit}`, never an option or a * range, and every later call uses the sha it answered. */ async function resolveCommit(dir, rev) { if (typeof rev !== "string" || !SAFE_REV.test(rev) || rev.includes("..")) { throw bad("EBADREV", "not a valid revision"); } const sha = await git(dir, ["rev-parse", "-q", "--verify", `${rev}^{commit}`]).then( (r) => r.stdout.trim(), () => "" ); if (!sha) throw bad("EBADREV", `no commit ${rev}`); return sha; } /** * Every ref that points at a commit, as the graph's badges: `{kind, name}` * with kind `head` (a detached HEAD), `local`, `remote` or `tag`, and * `current` on the branch HEAD is on. One `for-each-ref` per page — a * thousand tags is still one call. `refs/remotes//HEAD` is an alias, not * a branch, and is left out. */ async function refBadges(dir) { const [refs, head, current] = await Promise.all([ git(dir, [ "for-each-ref", "--format=%(objectname)%00%(*objectname)%00%(refname)", "refs/heads", "refs/remotes", "refs/tags", ]).then( (r) => r.stdout, () => "" ), git(dir, ["rev-parse", "-q", "--verify", "HEAD"]).then( (r) => r.stdout.trim(), () => "" ), git(dir, ["symbolic-ref", "-q", "--short", "HEAD"]).then( (r) => r.stdout.trim(), () => "" ), ]); const map = new Map(); const add = (sha, badge) => { if (!map.has(sha)) map.set(sha, []); map.get(sha).push(badge); }; for (const line of refs.split("\n")) { if (!line) continue; const [obj, peeled, ref] = line.split("\0"); const sha = peeled || obj; if (ref.startsWith("refs/heads/")) { const name = ref.slice(11); add(sha, { kind: "local", name, ...(name === current ? { current: true } : {}) }); } else if (ref.startsWith("refs/remotes/")) { if (ref.endsWith("/HEAD")) continue; add(sha, { kind: "remote", name: ref.slice(13) }); } else if (ref.startsWith("refs/tags/")) { add(sha, { kind: "tag", name: ref.slice(10) }); } } if (head && !current) add(head, { kind: "head", name: "HEAD" }); return { map, head, current }; } const LOG_FORMAT = ["%H", "%h", "%s", "%an", "%ae", "%aI", "%D", "%P"].join("%x1f"); function parseLog(stdout, badges) { const commits = []; for (const record of stdout.split("\x1e")) { const line = record.replace(/^\n/, ""); if (!line.trim()) continue; const [sha, short, subject, author, email, date, refs, parents] = line.split("\x1f"); commits.push({ sha, short, subject, author, email, date, refs: (refs || "") .split(", ") .map((r) => r.trim()) .filter(Boolean), parents: (parents || "").trim().split(" ").filter(Boolean), badges: badges?.get(sha) ?? [], }); } return commits; } /** * One page of history, for the graph: newest first in `--date-order` (no * parent before its children, which the graph's lanes need), each commit with * its parents and ref badges. * * @param {object} q * @param {number} [q.limit=100] Page size, ≤ 500. * @param {number} [q.skip=0] Commits to skip (the page's start). * @param {boolean|string} [q.all] Every branch, remote branch and tag (VS * Code's "all branches"), the stash excluded. * @param {string} [q.ref] Another branch's history (a short name, looked up). * @param {string} [q.q] Message text, case-insensitive and literal; a hex * string also finds the commit with that SHA prefix. * @param {string} [q.author] Author name or e-mail, literal. * @param {string} [q.path] Only commits touching this path (lane-relative). * @returns {Promise<{commits, hasMore, filtered}>} */ async function listHistory(dir, q = {}) { const limit = int(q.limit, DEFAULT_PAGE, 1, MAX_PAGE); const skip = int(q.skip, 0, 0, MAX_SKIP); const grep = text(q.q, "q"); const author = text(q.author, "author"); const file = q.path || q.file ? lanePath(String(q.path || q.file)) : null; const all = q.all === true || q.all === "1" || q.all === "true"; const args = [ "log", `--max-count=${limit + 1}`, `--skip=${skip}`, "--date-order", `--format=${LOG_FORMAT}%x1e`, ]; if (grep || author) args.push("--regexp-ignore-case", "--fixed-strings"); if (grep) args.push(`--grep=${grep}`); if (author) args.push(`--author=${author}`); if (all) { args.push("--exclude=refs/stash", "--all"); } else if (q.ref) { const ref = text(q.ref, "ref"); const full = await worktree.branchRef(dir, ref); if (!full) throw bad("ENOBRANCH", `no branch ${ref}`); args.push(full); } // `--parents` rewrites each parent to the path's own previous commit, so a // file's history still draws one joined-up line. if (file) args.push("--parents", "--", file); const [{ stdout }, badges] = await Promise.all([ git(dir, args, { timeout: READ_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 }), refBadges(dir), ]); let commits = parseLog(stdout, badges.map); const hasMore = commits.length > limit; if (hasMore) commits = commits.slice(0, limit); // A SHA typed into the search: that commit first, whatever its message. if (grep && SHA_RE.test(grep) && skip === 0) { const sha = await resolveCommit(dir, grep).catch(() => null); if (sha && !commits.some((c) => c.sha === sha)) { const one = await git(dir, ["log", "-1", `--format=${LOG_FORMAT}%x1e`, sha]); commits = [...parseLog(one.stdout, badges.map), ...commits]; } } return { commits, hasMore, filtered: !!(grep || author) }; } /** * One commit in full, for the details pane: the whole message, author and * committer, parents, its own ref badges, and the branches and tags that * contain it (each capped at MAX_CONTAINS). Its files come from * `GET /git/changes?range=commit`, which already reads them. */ async function commitDetails(dir, rev) { const sha = await resolveCommit(dir, rev); const fmt = ["%H", "%h", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%P", "%B"].join("%x1f"); const [{ stdout }, badges, contains] = await Promise.all([ git(dir, ["show", "-s", `--format=${fmt}`, sha], { maxBuffer: 4 * 1024 * 1024 }), refBadges(dir), git( dir, [ "for-each-ref", "--contains", sha, "--format=%(refname)", "refs/heads", "refs/remotes", "refs/tags", ], { timeout: READ_TIMEOUT_MS } ).then( (r) => r.stdout, () => "" ), ]); const [full, short, author, email, date, committer, committerEmail, committedAt, parents, body] = stdout.split("\x1f"); const branches = []; const tags = []; for (const ref of contains.split("\n")) { if (ref.startsWith("refs/heads/")) branches.push(ref.slice(11)); else if (ref.startsWith("refs/remotes/") && !ref.endsWith("/HEAD")) branches.push(ref.slice(13)); else if (ref.startsWith("refs/tags/")) tags.push(ref.slice(10)); } const message = (body || "").replace(/\n+$/, ""); return { sha: full, short, subject: message.split("\n")[0], message: message.length > 64 * 1024 ? message.slice(0, 64 * 1024) : message, author, email, date, committer, committerEmail, committedAt, parents: (parents || "").trim().split(" ").filter(Boolean), badges: badges.map.get(full) ?? [], isHead: full === badges.head, branches: branches.slice(0, MAX_CONTAINS), tags: tags.slice(0, MAX_CONTAINS), containsTruncated: branches.length > MAX_CONTAINS || tags.length > MAX_CONTAINS, }; } /** * A commit as an e-mail patch (`git format-patch -1 --stdout`), for Copy * patch: cwd-relative, and only the lane's own files — a secret file's diff * is left out and named in `omitted`, never sent. A merge commit has no one * patch (EMERGECOMMIT). */ async function commitPatch(dir, rev) { const sha = await resolveCommit(dir, rev); const parents = (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" "); if (parents.length > 2) throw bad("EMERGECOMMIT", "a merge commit has no single patch"); const files = ( await git(dir, [ "diff-tree", "--no-commit-id", "--root", "-r", "--name-only", "--relative", "-z", sha, ]) ).stdout .split("\0") .filter(Boolean); const keep = []; const omitted = []; for (const f of files) ((await isSecretOnDisk(dir, f)) ? omitted : keep).push(f); if (!keep.length) return { sha, patch: "", omitted, truncated: false }; let out; let truncated = false; try { out = ( await git(dir, ["format-patch", "-1", "--stdout", "--relative", sha, "--", ...keep], { maxBuffer: MAX_FORMAT_PATCH + 64 * 1024, timeout: READ_TIMEOUT_MS, }) ).stdout; } catch (err) { if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err; out = String(err.git.stdout); } if (out.length > MAX_FORMAT_PATCH) { out = out.slice(0, MAX_FORMAT_PATCH); truncated = true; } return { sha, patch: out, omitted, truncated }; } /** * One file's history across renames (`git log --follow`), newest first: each * commit with the path the file had THERE and its status (A/M/R/D…), so a * version's diff is read under its own name. Paths are lane-relative. */ async function fileHistory(dir, requested, q = {}) { const file = lanePath(requested); if (await isSecretOnDisk(dir, file)) { throw bad("ESECRET", "that file holds secrets and is not readable here"); } const limit = int(q.limit, DEFAULT_PAGE, 1, MAX_PAGE); const skip = int(q.skip, 0, 0, MAX_SKIP); const fmt = ["%H", "%h", "%s", "%an", "%aI", "%P"].join("%x1f"); const { stdout } = await git( dir, [ "log", "--follow", "--relative", "-z", "--name-status", `--max-count=${limit + 1}`, `--skip=${skip}`, `--format=%x1e${fmt}`, "--", file, ], { timeout: READ_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 } ); const commits = []; for (const record of stdout.split("\x1e")) { if (!record.trim()) continue; const [head, ...rest] = record.split("\0"); const [sha, short, subject, author, date, parents] = head.split("\x1f"); const tokens = rest.map((t) => t.replace(/^\n/, "")).filter((t) => t !== ""); const status = tokens[0] || "M"; const letter = status[0]; const rename = letter === "R" || letter === "C"; commits.push({ sha, short, subject, author, date, parents: (parents || "").trim().split(" ").filter(Boolean), status: letter, path: rename ? tokens[2] || file : tokens[1] || file, ...(rename ? { from: tokens[1] } : {}), }); } const hasMore = commits.length > limit; return { path: file, commits: hasMore ? commits.slice(0, limit) : commits, hasMore }; } /** * Blame at a revision (`rev` = `work` for the file on disk, else a commit): * each line's text and the commit that last changed it, plus that commit's * author, date, summary and `previous` (where "blame before this change" * goes). Guards of a whole-file read: lexical path, secret refused, 1 MB cap. */ async function blameAt(dir, requested, rev = "work") { const file = lanePath(requested); if (await isSecretOnDisk(dir, file)) { throw bad("ESECRET", "that file holds secrets and is not readable here"); } const args = ["blame", "--porcelain"]; let at = null; if (rev && rev !== "work") { at = await resolveCommit(dir, rev); const size = await git(dir, ["cat-file", "-s", `${at}:./${file}`]).then( (r) => Number(r.stdout.trim()), () => -1 ); if (size < 0) throw bad("ENOFILE", "no such file at that revision"); if (size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file too large to annotate"); args.push(at); } else { const fs = require("node:fs"); const st = await fs.promises.stat(path.join(dir, file)).catch(() => null); if (!st?.isFile()) throw bad("ENOFILE", "no such file"); if (st.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file too large to annotate"); } const out = ( await git(dir, [...args, "--", file], { maxBuffer: 64 * 1024 * 1024, timeout: READ_TIMEOUT_MS, }) ).stdout; const commits = {}; const lines = []; let sha = null; for (const line of out.split("\n")) { const head = /^([0-9a-f]{40,64}) \d+ \d+/.exec(line); if (head) { sha = head[1]; if (!/^0+$/.test(sha)) commits[sha] ??= { short: sha.slice(0, 7) }; continue; } if (line.startsWith("\t")) { lines.push({ sha: /^0+$/.test(sha) ? null : sha, text: line.slice(1) }); continue; } const c = commits[sha]; if (!c) continue; const sp = line.indexOf(" "); const key = sp < 0 ? line : line.slice(0, sp); const val = sp < 0 ? "" : line.slice(sp + 1); if (key === "author") c.author = val; else if (key === "author-time") c.date = new Date(Number(val) * 1000).toISOString(); else if (key === "summary") c.summary = val; else if (key === "previous") c.previous = val.split(" ")[0]; } return { path: file, rev: at ?? "work", commits, lines }; } /** * One folder of the tree at a commit (`git ls-tree`), for Browse files at a * revision: cwd-limited by git itself, a secret file left out of the list * (its content is refused by `/git/file` anyway), folders first. A file is * then read with `GET /git/file?rev=` under that read's own guards. */ async function treeAt(dir, rev, sub = "") { const sha = await resolveCommit(dir, rev); const folder = sub ? lanePath(sub) : ""; const args = ["ls-tree", "-z", "-l", sha]; if (folder) args.push("--", `${folder}/`); const { stdout } = await git(dir, args, { maxBuffer: 16 * 1024 * 1024 }); const entries = []; for (const row of stdout.split("\0")) { if (!row) continue; const tab = row.indexOf("\t"); const [, type, , size] = row.slice(0, tab).trim().split(/\s+/); const p = row.slice(tab + 1); if (await isSecretOnDisk(dir, p)) continue; entries.push({ path: p, name: p.slice(p.lastIndexOf("/") + 1), dir: type === "tree", submodule: type === "commit", size: type === "blob" ? Number(size) : null, }); } entries.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name)); const truncated = entries.length > MAX_TREE; return { rev: sha, path: folder, entries: truncated ? entries.slice(0, MAX_TREE) : entries, truncated, }; } /** `--name-status -z` + numstat into the Changes panel's file rows. */ function filesFrom(nameStatus, stats) { const files = []; const tokens = nameStatus.split("\0"); for (let i = 0; i < tokens.length; i++) { const status = tokens[i].replace(/^\n/, ""); if (!/^[A-Z]/.test(status)) continue; const letter = status[0]; const rename = letter === "R" || letter === "C"; const from = rename ? tokens[++i] : undefined; const file = tokens[++i]; if (!file) continue; const stat = stats.get(file); files.push({ path: file, ...(from ? { from } : {}), status: `${letter} `, untracked: false, staged: false, unstaged: false, conflicted: false, added: stat?.added ?? 0, deleted: stat?.deleted ?? 0, binary: !!stat?.binary, }); } return files; } /** * Compare two refs (branches, tags or commits): the commits only in `a` and * only in `b` (`--left-right a...b`), and the files that differ — `three` * (default) is `a...b`, what `b` adds since the two diverged; `two` is `a b`, * the trees as they stand. `path` returns that one file with its hunks. */ async function compareRefs(dir, { a, b, mode = "three", path: file = null, from = null } = {}) { const left = await resolveCommit(dir, a); const right = await resolveCommit(dir, b); const range = mode === "two" ? [left, right] : [`${left}...${right}`]; if (file) { const p = lanePath(file); // A rename's old name too, so its diff reads as a change, not an add. const old = from ? lanePath(from) : null; if ((await isSecretOnDisk(dir, p)) || (old && (await isSecretOnDisk(dir, old)))) { return { a: left, b: right, mode, file: { path: p, secret: true, hunks: [] } }; } let patch = ""; try { patch = ( await git( dir, [ "diff", "-U3", "-M", "--no-color", "--no-ext-diff", "--relative", ...range, "--", ...(old && old !== p ? [old, p] : [p]), ], { maxBuffer: 16 * 1024 * 1024, timeout: READ_TIMEOUT_MS } ) ).stdout; } catch (err) { if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err; patch = String(err.git.stdout); } patch = elideLongLines(patch); let truncated = false; if (patch.length > MAX_PATCH_BYTES) { const nl = patch.lastIndexOf("\n", MAX_PATCH_BYTES); patch = patch.slice(0, nl > MAX_PATCH_BYTES / 2 ? nl + 1 : MAX_PATCH_BYTES); truncated = true; } return { a: left, b: right, mode, file: { path: p, status: "M ", untracked: false, staged: false, unstaged: false, conflicted: false, added: 0, deleted: 0, binary: /^Binary files /m.test(patch), hunks: parseUnifiedDiff(patch), ...(truncated ? { truncated } : {}), }, }; } const fmt = ["%m", "%H", "%h", "%s", "%an", "%aI", "%P"].join("%x1f"); const opts = { timeout: READ_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 }; const [log, ns, num] = await Promise.all([ git( dir, [ "log", "--left-right", `--max-count=${MAX_COMPARE_COMMITS * 4}`, `--format=${fmt}%x1e`, `${left}...${right}`, ], opts ), git(dir, ["diff", "--name-status", "-z", "-M", "--relative", ...range], opts), git(dir, ["diff", "--numstat", "-z", "-M", "--relative", ...range], opts), ]); const onlyA = []; const onlyB = []; for (const record of log.stdout.split("\x1e")) { const line = record.replace(/^\n/, ""); if (!line.trim()) continue; const [side, sha, short, subject, author, date, parents] = line.split("\x1f"); const c = { sha, short, subject, author, date, parents: (parents || "").split(" ").filter(Boolean), }; (side === "<" ? onlyA : onlyB).push(c); } let files = filesFrom(ns.stdout, parseNumstat(num.stdout)); const filesTruncated = files.length > MAX_COMPARE_FILES; if (filesTruncated) files = files.slice(0, MAX_COMPARE_FILES); return { a: left, b: right, mode: mode === "two" ? "two" : "three", onlyA: onlyA.slice(0, MAX_COMPARE_COMMITS), onlyB: onlyB.slice(0, MAX_COMPARE_COMMITS), commitsTruncated: onlyA.length > MAX_COMPARE_COMMITS || onlyB.length > MAX_COMPARE_COMMITS, files, filesTruncated, }; } /** * The commits of a cherry-pick or revert request as git will apply them: * each resolved to a full sha (EBADSHA), none a merge commit (EMERGECOMMIT), * no duplicates, at most MAX_PICK; a cherry-pick oldest → newest, a revert * newest → oldest (undoing the later change first is what keeps the earlier * one's context intact). Order is ancestry first, then date (`--topo-order`). */ async function pickPlan(dir, op, shas) { if (op !== "cherry-pick" && op !== "revert") throw bad("EBADACTION", "op must be cherry-pick or revert"); if (!Array.isArray(shas) || !shas.length || shas.length > MAX_PICK) { throw bad("EBADSHA", `name 1 to ${MAX_PICK} commits`); } const full = []; for (const s of shas) { if (typeof s !== "string" || !SHA_RE.test(s)) throw bad("EBADSHA", "sha must be a commit id"); const sha = await git(dir, ["rev-parse", "-q", "--verify", `${s}^{commit}`]).then( (r) => r.stdout.trim(), () => "" ); if (!sha) throw bad("EBADSHA", `no commit ${s}`); if (!full.includes(sha)) full.push(sha); } // Ancestry first (a parent is never applied after its child, whatever the // clocks said), then date: `--topo-order` over the commits between them // and their common ancestor — a bounded walk, not the whole history. let ordered = full; if (full.length > 1) { const base = await git(dir, ["merge-base", "--octopus", ...full]).then( (r) => r.stdout.trim(), () => "" ); const walk = await git( dir, ["rev-list", "--topo-order", ...full, ...(base ? [`^${base}`] : [])], { timeout: READ_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 } ); const want = new Set(full); ordered = walk.stdout.split("\n").filter((s) => want.has(s)); // The common ancestor is excluded from the walk; it is the oldest. if (want.has(base)) ordered.push(base); } const fmt = ["%H", "%h", "%s", "%an", "%aI", "%P"].join("%x1f"); const { stdout } = await git(dir, [ "log", "--no-walk=unsorted", `--format=${fmt}%x1e`, ...ordered, ]); const commits = []; for (const record of stdout.split("\x1e")) { const line = record.replace(/^\n/, ""); if (!line.trim()) continue; const [sha, short, subject, author, date, parents] = line.split("\x1f"); const p = (parents || "").trim().split(" ").filter(Boolean); if (p.length > 1) throw bad("EMERGECOMMIT", `${short} is a merge commit; pick it in a terminal`); commits.push({ sha, short, subject, author, date }); } // git printed newest first; a cherry-pick replays history forwards. if (op === "cherry-pick") commits.reverse(); return commits; } /** The plan plus the facts the confirmation shows: branch, clean, repo root. */ async function pickPreview(dir, op, shas) { const commits = await pickPlan(dir, op, shas); const [branch, prefix, status] = await Promise.all([ git(dir, ["branch", "--show-current"]).then( (r) => r.stdout.trim(), () => "" ), git(dir, ["rev-parse", "--show-prefix"]).then( (r) => r.stdout.trim(), () => "" ), worktree.statusCounts(dir), ]); const operation = await worktree.operationInProgress(dir); return { op, commits, branch: branch || null, clean: status.dirty + status.untracked === 0, repoRoot: !prefix, busy: !!operation || (await sequencerTodo(dir)).length > 0, }; } /** `.git/sequencer/todo`'s lines ("pick "), [] when none. */ async function sequencerTodo(dir) { try { const rel = (await git(dir, ["rev-parse", "--git-path", "sequencer/todo"])).stdout.trim(); const body = await require("./run-as").readFileSafeAsync(path.resolve(dir, rel), "utf8", { max: 256 * 1024, }); return body .split("\n") .map((l) => /^(pick|revert|p|r)\s+([0-9a-f]{4,64})\s?(.*)$/.exec(l.trim())) .filter(Boolean) .map((m) => ({ verb: m[1].startsWith("p") ? "pick" : "revert", sha: m[2], subject: m[3] })); } catch { return []; } } /** The pre-operation HEAD git saved for a multi-commit pick/revert, or null. */ async function sequencerHead(dir) { try { const rel = (await git(dir, ["rev-parse", "--git-path", "sequencer/head"])).stdout.trim(); const v = ( await require("./run-as").readFileSafeAsync(path.resolve(dir, rel), "utf8", { max: 1024 }) ).trim(); return /^[0-9a-f]{40,64}$/.test(v) ? v : null; } catch { return null; } } /** * A cherry-pick or revert that is open (one commit, or a sequence paused on a * conflict): which, the commit it stopped at, the ones still to come, how many * are already applied, the HEAD it started from and the conflicted paths — * what the panel's "paused at commit X (2 of 5)" banner draws. */ async function sequenceState(dir) { const operation = await worktree.operationInProgress(dir); const todo = await sequencerTodo(dir); // A merge or a rebase has its own banner: never reported as a paused pick. if ( (operation && operation !== "cherry-pick" && operation !== "revert") || (!operation && !todo.length) ) { return { active: false }; } const op = operation || (todo[0].verb === "revert" ? "revert" : "cherry-pick"); const headName = op === "revert" ? "REVERT_HEAD" : "CHERRY_PICK_HEAD"; const current = await git(dir, ["rev-parse", "-q", "--verify", headName]).then( (r) => r.stdout.trim(), () => "" ); const startedFrom = await sequencerHead(dir); const done = startedFrom ? await git(dir, ["rev-list", "--count", `${startedFrom}..HEAD`]).then( (r) => Number(r.stdout.trim()) || 0, () => 0 ) : 0; const describe = async (sha) => { const r = await git(dir, ["log", "-1", "--format=%H%x1f%h%x1f%s", sha]).catch(() => null); if (!r) return null; const [full, short, subject] = r.stdout.trim().split("\x1f"); return { sha: full, short, subject }; }; const unmerged = ( await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({ stdout: "" })) ).stdout .split("\0") .filter(Boolean); // The todo's first line is the commit it stopped at; the rest are to come. const rest = current && todo.length && current.startsWith(todo[0].sha) ? todo.slice(1) : todo; return { active: true, operation: op, current: current ? await describe(current) : null, remaining: rest .slice(0, MAX_PICK * 2) .map((t) => ({ sha: t.sha, short: t.sha.slice(0, 7), subject: t.subject })), done, startedFrom, conflicts: unmerged, }; } module.exports = { listHistory, commitDetails, commitPatch, fileHistory, blameAt, treeAt, compareRefs, pickPlan, pickPreview, sequenceState, sequencerTodo, sequencerHead, resolveCommit, MAX_PICK, MAX_PAGE, };