Files
AgentHub/server/lib/git-history.js
T

836 lines
29 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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 `<safe name>^{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/<r>/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=<sha>` 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 <sha> <subject>"), [] 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,
};