836 lines
29 KiB
JavaScript
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,
|
|
};
|