feat(git): history graph with search, commit details, file history, blame/browse at a revision, compare refs, and multi-commit cherry-pick/revert with preview, pause banner and undo
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* @file The history reads and the multi-commit cherry-pick/revert against a
|
||||
* REAL repo: log paging, parents, ref badges, all-branches and search (text,
|
||||
* author, path, SHA prefix); commit details and patch (a secret left out);
|
||||
* file history across a rename; blame at a revision; tree at a revision (a
|
||||
* secret hidden, its content refused); comparing two refs both ways; and
|
||||
* `cherry-pick`/`revert` with `shas` — order, success, a conflict then
|
||||
* continue / skip / abort, and undo-pick (refused once stale or pushed).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const history = require("../lib/git-history");
|
||||
const { readFileAt } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ghist-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
const REMOTE = path.join(ROOT, "remote.git");
|
||||
|
||||
const env = () => {
|
||||
const e = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
delete e.GIT_DIR;
|
||||
delete e.GIT_WORK_TREE;
|
||||
delete e.GIT_INDEX_FILE;
|
||||
return e;
|
||||
};
|
||||
const g = (...args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", env: env() });
|
||||
const write = (file, body) => {
|
||||
fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(REPO, file), body);
|
||||
};
|
||||
const sha = (ref) => g("rev-parse", ref).trim();
|
||||
const subjects = (n) => g("log", `-${n}`, "--format=%s").trim().split("\n");
|
||||
|
||||
// main: init, main-1 ; feat (from init): f1 (adds b), f2 (a.txt conflicts), f3 (adds c)
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.rmSync(REMOTE, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "Tester");
|
||||
write("a.txt", "base\n");
|
||||
write("docs/old.md", "one\ntwo\nthree\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
g("tag", "v1");
|
||||
g("switch", "-qc", "feat");
|
||||
write("b.txt", "b\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "f1 adds b");
|
||||
write("a.txt", "theirs\n");
|
||||
g("commit", "-qam", "f2 edits a");
|
||||
write("c.txt", "c\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "f3 adds c");
|
||||
g("switch", "-q", "main");
|
||||
write("a.txt", "ours\n");
|
||||
g("commit", "-qam", "main-1");
|
||||
});
|
||||
|
||||
describe("listHistory", () => {
|
||||
it("pages with parents, badges and hasMore", async () => {
|
||||
const p1 = await history.listHistory(REPO, { limit: 1 });
|
||||
assert.equal(p1.commits.length, 1);
|
||||
assert.equal(p1.hasMore, true);
|
||||
assert.equal(p1.commits[0].subject, "main-1");
|
||||
assert.deepEqual(p1.commits[0].parents, [sha("main~1")]);
|
||||
assert.deepEqual(p1.commits[0].badges, [{ kind: "local", name: "main", current: true }]);
|
||||
const p2 = await history.listHistory(REPO, { limit: 1, skip: 1 });
|
||||
assert.equal(p2.commits[0].subject, "init");
|
||||
assert.deepEqual(p2.commits[0].badges, [{ kind: "tag", name: "v1" }]);
|
||||
assert.equal(p2.hasMore, false);
|
||||
});
|
||||
|
||||
it("reads every branch with all=1", async () => {
|
||||
const { commits } = await history.listHistory(REPO, { all: "1" });
|
||||
assert.equal(commits.length, 5);
|
||||
const f3 = commits.find((c) => c.subject === "f3 adds c");
|
||||
assert.deepEqual(f3.badges, [{ kind: "local", name: "feat" }]);
|
||||
});
|
||||
|
||||
it("searches by message, author, path and SHA prefix, all literal", async () => {
|
||||
const byText = await history.listHistory(REPO, { all: "1", q: "ADDS" });
|
||||
assert.deepEqual(byText.commits.map((c) => c.subject).sort(), ["f1 adds b", "f3 adds c"]);
|
||||
assert.equal(byText.filtered, true);
|
||||
assert.equal((await history.listHistory(REPO, { all: "1", q: "a.d" })).commits.length, 0);
|
||||
assert.equal((await history.listHistory(REPO, { author: "nobody" })).commits.length, 0);
|
||||
assert.equal((await history.listHistory(REPO, { author: "test" })).commits.length, 2);
|
||||
const byPath = await history.listHistory(REPO, { all: "1", path: "c.txt" });
|
||||
assert.deepEqual(
|
||||
byPath.commits.map((c) => c.subject),
|
||||
["f3 adds c"]
|
||||
);
|
||||
const f2 = sha("feat~1");
|
||||
const bySha = await history.listHistory(REPO, { q: f2.slice(0, 8) });
|
||||
assert.equal(bySha.commits[0].sha, f2);
|
||||
await assert.rejects(history.listHistory(REPO, { path: "../x" }), { code: "EBADPATH" });
|
||||
await assert.rejects(history.listHistory(REPO, { q: "x".repeat(300) }), { code: "EBADQUERY" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit details, patch, file history, blame, tree, compare", () => {
|
||||
it("details name containing branches and tags", async () => {
|
||||
const d = await history.commitDetails(REPO, sha("main~1"));
|
||||
assert.equal(d.subject, "init");
|
||||
assert.equal(d.author, "Tester");
|
||||
assert.ok(d.branches.includes("main") && d.branches.includes("feat"));
|
||||
assert.deepEqual(d.tags, ["v1"]);
|
||||
await assert.rejects(history.commitDetails(REPO, "--all"), { code: "EBADREV" });
|
||||
await assert.rejects(history.commitDetails(REPO, "a..b"), { code: "EBADREV" });
|
||||
});
|
||||
|
||||
it("copies a patch without a secret file's diff", async () => {
|
||||
write(".claude/settings.local.json", '{"key":"sk-secret"}\n');
|
||||
write("ok.txt", "fine\n");
|
||||
g("add", "-A", "-f");
|
||||
g("commit", "-qm", "with secret");
|
||||
const p = await history.commitPatch(REPO, "HEAD");
|
||||
assert.match(p.patch, /Subject: \[PATCH\] with secret/);
|
||||
assert.match(p.patch, /ok\.txt/);
|
||||
assert.doesNotMatch(p.patch, /sk-secret/);
|
||||
assert.deepEqual(p.omitted, [".claude/settings.local.json"]);
|
||||
});
|
||||
|
||||
it("follows a file across a rename", async () => {
|
||||
g("mv", "docs/old.md", "docs/new.md");
|
||||
g("commit", "-qm", "rename");
|
||||
write("docs/new.md", "one\nTWO\nthree\n");
|
||||
g("commit", "-qam", "edit new");
|
||||
const h = await history.fileHistory(REPO, "docs/new.md");
|
||||
assert.deepEqual(
|
||||
h.commits.map((c) => [c.subject, c.status, c.path]),
|
||||
[
|
||||
["edit new", "M", "docs/new.md"],
|
||||
["rename", "R", "docs/new.md"],
|
||||
["init", "A", "docs/old.md"],
|
||||
]
|
||||
);
|
||||
assert.equal(h.commits[1].from, "docs/old.md");
|
||||
});
|
||||
|
||||
it("blames at a revision with each line's text", async () => {
|
||||
write("docs/old.md", "one\nTWO\nthree\n");
|
||||
g("commit", "-qam", "edit");
|
||||
const now = await history.blameAt(REPO, "docs/old.md", "HEAD");
|
||||
assert.deepEqual(
|
||||
now.lines.map((l) => [l.text, now.commits[l.sha].summary]),
|
||||
[
|
||||
["one", "init"],
|
||||
["TWO", "edit"],
|
||||
["three", "init"],
|
||||
]
|
||||
);
|
||||
assert.equal(now.commits[sha("HEAD")].previous, sha("HEAD~1"));
|
||||
const before = await history.blameAt(REPO, "docs/old.md", "HEAD~1");
|
||||
assert.equal(before.lines[1].text, "two");
|
||||
await assert.rejects(history.blameAt(REPO, "nope.md", "HEAD"), { code: "ENOFILE" });
|
||||
});
|
||||
|
||||
it("lists a tree at a revision, a secret hidden and refused", async () => {
|
||||
write(".claude/settings.local.json", '{"key":"sk"}\n');
|
||||
g("add", "-A", "-f");
|
||||
g("commit", "-qm", "secret");
|
||||
const root = await history.treeAt(REPO, "HEAD");
|
||||
assert.deepEqual(
|
||||
root.entries.map((e) => [e.name, e.dir]),
|
||||
[
|
||||
[".claude", true],
|
||||
["docs", true],
|
||||
["a.txt", false],
|
||||
]
|
||||
);
|
||||
const claude = await history.treeAt(REPO, "HEAD", ".claude");
|
||||
assert.deepEqual(claude.entries, []);
|
||||
await assert.rejects(readFileAt(REPO, ".claude/settings.local.json", sha("HEAD")), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
const docs = await history.treeAt(REPO, "v1", "docs");
|
||||
assert.deepEqual(
|
||||
docs.entries.map((e) => e.path),
|
||||
["docs/old.md"]
|
||||
);
|
||||
});
|
||||
|
||||
it("compares two refs, three-dot and two-dot", async () => {
|
||||
const c = await history.compareRefs(REPO, { a: "main", b: "feat" });
|
||||
assert.deepEqual(
|
||||
c.onlyA.map((x) => x.subject),
|
||||
["main-1"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
c.onlyB.map((x) => x.subject),
|
||||
["f3 adds c", "f2 edits a", "f1 adds b"]
|
||||
);
|
||||
// Three-dot: what feat adds since the fork — a.txt, b.txt, c.txt.
|
||||
assert.deepEqual(c.files.map((f) => f.path).sort(), ["a.txt", "b.txt", "c.txt"]);
|
||||
const one = await history.compareRefs(REPO, { a: "main", b: "feat", path: "a.txt" });
|
||||
assert.ok(one.file.hunks[0].lines.some((l) => l.type === "add" && l.text === "theirs"));
|
||||
assert.ok(one.file.hunks[0].lines.some((l) => l.type === "del" && l.text === "base"));
|
||||
const two = await history.compareRefs(REPO, {
|
||||
a: "main",
|
||||
b: "feat",
|
||||
mode: "two",
|
||||
path: "a.txt",
|
||||
});
|
||||
assert.ok(two.file.hunks[0].lines.some((l) => l.type === "del" && l.text === "ours"));
|
||||
await assert.rejects(history.compareRefs(REPO, { a: "main", b: "-x" }), { code: "EBADREV" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-commit cherry-pick / revert", () => {
|
||||
it("previews the plan oldest first and refuses a merge commit", async () => {
|
||||
const plan = await history.pickPreview(REPO, "cherry-pick", [sha("feat"), sha("feat~2")]);
|
||||
assert.deepEqual(
|
||||
plan.commits.map((c) => c.subject),
|
||||
["f1 adds b", "f3 adds c"]
|
||||
);
|
||||
assert.equal(plan.branch, "main");
|
||||
assert.equal(plan.clean, true);
|
||||
const rev = await history.pickPlan(REPO, "revert", [sha("feat~2"), sha("feat")]);
|
||||
assert.deepEqual(
|
||||
rev.map((c) => c.subject),
|
||||
["f3 adds c", "f1 adds b"]
|
||||
);
|
||||
g("merge", "-q", "--no-edit", "--no-ff", "-X", "ours", "feat");
|
||||
await assert.rejects(history.pickPlan(REPO, "cherry-pick", [sha("HEAD")]), {
|
||||
code: "EMERGECOMMIT",
|
||||
});
|
||||
await assert.rejects(history.pickPlan(REPO, "cherry-pick", ["zz"]), { code: "EBADSHA" });
|
||||
});
|
||||
|
||||
it("picks several commits in order and undoes them", async () => {
|
||||
const from = sha("HEAD");
|
||||
const r = await runGitAction(REPO, "cherry-pick", { shas: [sha("feat"), sha("feat~2")] });
|
||||
assert.equal(r.paused, false);
|
||||
assert.equal(r.from, from);
|
||||
assert.deepEqual(subjects(2), ["f3 adds c", "f1 adds b"]);
|
||||
assert.deepEqual(
|
||||
r.commits.map((c) => c.subject),
|
||||
["f1 adds b", "f3 adds c"]
|
||||
);
|
||||
await assert.rejects(runGitAction(REPO, "undo-pick", { from, to: r.to }), {
|
||||
code: "ECONFIRM",
|
||||
});
|
||||
const u = await runGitAction(REPO, "undo-pick", { from, to: r.to, confirm: true });
|
||||
assert.equal(u.undone.length, 2);
|
||||
assert.equal(sha("HEAD"), from);
|
||||
// Soft: the picked changes stay staged.
|
||||
assert.match(g("status", "--porcelain"), /A {2}b\.txt/);
|
||||
});
|
||||
|
||||
it("refuses undo once HEAD moved or the commits are pushed", async () => {
|
||||
const from = sha("HEAD");
|
||||
const r = await runGitAction(REPO, "cherry-pick", { shas: [sha("feat~2")] });
|
||||
write("z.txt", "z\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "later");
|
||||
await assert.rejects(runGitAction(REPO, "undo-pick", { from, to: r.to, confirm: true }), {
|
||||
code: "ESTALE",
|
||||
});
|
||||
g("reset", "-q", "--hard", r.to);
|
||||
execFileSync("git", ["init", "-q", "--bare", REMOTE], { env: env() });
|
||||
g("remote", "add", "origin", REMOTE);
|
||||
g("push", "-q", "origin", "main");
|
||||
await assert.rejects(runGitAction(REPO, "undo-pick", { from, to: r.to, confirm: true }), {
|
||||
code: "EPUSHED",
|
||||
});
|
||||
});
|
||||
|
||||
it("stops on a conflict, then continue finishes the rest", async () => {
|
||||
const r = await runGitAction(REPO, "cherry-pick", {
|
||||
shas: [sha("feat~2"), sha("feat~1"), sha("feat")],
|
||||
});
|
||||
assert.equal(r.paused, true);
|
||||
assert.deepEqual(r.conflicts, ["a.txt"]);
|
||||
const st = await history.sequenceState(REPO);
|
||||
assert.equal(st.active, true);
|
||||
assert.equal(st.operation, "cherry-pick");
|
||||
assert.equal(st.current.subject, "f2 edits a");
|
||||
assert.equal(st.done, 1);
|
||||
assert.deepEqual(
|
||||
st.remaining.map((x) => x.subject),
|
||||
["f3 adds c"]
|
||||
);
|
||||
await assert.rejects(runGitAction(REPO, "pick-continue", {}), { code: "ECONFLICTS" });
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
|
||||
const c = await runGitAction(REPO, "pick-continue", {});
|
||||
assert.equal(c.paused, false);
|
||||
assert.deepEqual(subjects(3), ["f3 adds c", "f2 edits a", "f1 adds b"]);
|
||||
assert.equal((await history.sequenceState(REPO)).active, false);
|
||||
});
|
||||
|
||||
it("skips the conflicted commit, or aborts back to the start", async () => {
|
||||
const start = sha("HEAD");
|
||||
await runGitAction(REPO, "cherry-pick", { shas: [sha("feat~2"), sha("feat~1"), sha("feat")] });
|
||||
await assert.rejects(runGitAction(REPO, "pick-skip", {}), { code: "ECONFIRM" });
|
||||
const s = await runGitAction(REPO, "pick-skip", { confirm: true });
|
||||
assert.equal(s.paused, false);
|
||||
assert.deepEqual(subjects(2), ["f3 adds c", "f1 adds b"]);
|
||||
|
||||
g("reset", "-q", "--hard", start);
|
||||
await runGitAction(REPO, "cherry-pick", { shas: [sha("feat~2"), sha("feat~1")] });
|
||||
await assert.rejects(runGitAction(REPO, "pick-abort", {}), { code: "ECONFIRM" });
|
||||
await runGitAction(REPO, "pick-abort", { confirm: true });
|
||||
assert.equal(sha("HEAD"), start);
|
||||
assert.equal((await history.sequenceState(REPO)).active, false);
|
||||
await assert.rejects(runGitAction(REPO, "pick-continue", {}), { code: "ENOMERGE" });
|
||||
});
|
||||
|
||||
it("reverts several commits newest first, and refuses a dirty tree", async () => {
|
||||
g("switch", "-q", "feat");
|
||||
await runGitAction(REPO, "revert", { shas: [sha("feat~2"), sha("feat")] });
|
||||
assert.deepEqual(subjects(2), ['Revert "f1 adds b"', 'Revert "f3 adds c"']);
|
||||
write("dirty.txt", "x\n");
|
||||
await assert.rejects(runGitAction(REPO, "revert", { shas: [sha("HEAD")] }), {
|
||||
code: "EDIRTY",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the single-sha body on its own path", async () => {
|
||||
const r = await runGitAction(REPO, "cherry-pick", { sha: sha("feat~2") });
|
||||
assert.deepEqual(r.conflicts, []);
|
||||
assert.equal(r.from, undefined);
|
||||
assert.equal(subjects(1)[0], "f1 adds b");
|
||||
});
|
||||
|
||||
it("refuses every pick action while a rebase is open, and never reports it as a pick", async () => {
|
||||
// main-1 and feat's f2 both edit a.txt: the rebase stops on it.
|
||||
assert.throws(() => g("rebase", "feat"));
|
||||
for (const [action, body] of [
|
||||
["cherry-pick", { shas: [sha("feat~2")] }],
|
||||
["pick-continue", {}],
|
||||
["pick-skip", { confirm: true }],
|
||||
["pick-abort", { confirm: true }],
|
||||
["undo-pick", { from: sha("feat~1"), to: sha("feat"), confirm: true }],
|
||||
]) {
|
||||
await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" }, action);
|
||||
}
|
||||
assert.equal((await history.sequenceState(REPO)).active, false);
|
||||
g("rebase", "--abort");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,835 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
@@ -30,6 +30,11 @@
|
||||
* same clean-tree/repo-root guards, same conflict flow, and `merge-commit`/
|
||||
* `merge-abort` finish or abort whichever of the three git has open.
|
||||
*
|
||||
* Several commits at once (`shas`), a paused pick's `pick-continue` /
|
||||
* `pick-skip` / `pick-abort`, and `undo-pick` (a soft reset back over what
|
||||
* a pick just made) live in git-pick.js, dispatched at the top of
|
||||
* `runGitAction` under the same guards.
|
||||
*
|
||||
* `undo-commit` (`reset --soft HEAD~1`) and `amend` rewrite the last commit,
|
||||
* so both refuse once any remote-tracking branch holds it (EPUSHED): an
|
||||
* unpushed commit is the only one whose rewrite costs nobody else anything.
|
||||
@@ -116,6 +121,11 @@ const ACTIONS = new Set([
|
||||
"shelve",
|
||||
"unshelve",
|
||||
"shelf-drop",
|
||||
// git-pick.js: a paused cherry-pick/revert's banner, and taking a pick back.
|
||||
"pick-continue",
|
||||
"pick-skip",
|
||||
"pick-abort",
|
||||
"undo-pick",
|
||||
]);
|
||||
|
||||
// Branch & tag tools (rename, upstream, tag create/delete/push): git-refs.js.
|
||||
@@ -384,6 +394,15 @@ async function runGitAction(dir, action, body = {}, opts = {}) {
|
||||
if (gitRefs.handles(action, body)) {
|
||||
return gitRefs.refsAction(dir, action, body, { networkGit, PROTECTED });
|
||||
}
|
||||
// Several commits (`shas`), and the paused sequence's own buttons: git-pick.js,
|
||||
// which keeps every guard below. A single `sha` stays on the path further down.
|
||||
const pick = require("./git-pick");
|
||||
if (
|
||||
pick.PICK_ACTIONS.has(action) ||
|
||||
((action === "cherry-pick" || action === "revert") && Array.isArray(body.shas))
|
||||
) {
|
||||
return pick.runPickAction(dir, action, body);
|
||||
}
|
||||
if (BLOCKED_WHILE_MERGING.has(action)) {
|
||||
const open = await operationInProgress(dir);
|
||||
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @file The multi-commit cherry-pick / revert WRITES, and the buttons of the
|
||||
* banner a paused one shows (like GitKraken, GitLens and JetBrains):
|
||||
*
|
||||
* - `cherry-pick` / `revert` with `shas: [...]` — 1 to 50 non-merge
|
||||
* commits, applied in git's own sequencer (`git cherry-pick A B C`) in
|
||||
* the order `pickPlan` fixes (a pick oldest → newest, a revert newest →
|
||||
* oldest). The single-`sha` body stays git-ops.js's, unchanged.
|
||||
* - `pick-continue` — `git <op> --continue` once no file is unmerged.
|
||||
* - `pick-skip` — `git <op> --skip`, `confirm: true` (it drops the
|
||||
* resolutions made for the commit it stopped at).
|
||||
* - `pick-abort` — `git <op> --abort`, `confirm: true`: back to where the
|
||||
* operation started. Unlike `merge-abort` it also ends a sequence whose
|
||||
* *_HEAD is gone (a commit made in between) but whose todo is not.
|
||||
* - `undo-pick` — `reset --soft <from>` after a pick/revert this panel ran,
|
||||
* `confirm: true`: only while HEAD is still the `to` it produced
|
||||
* (ESTALE), `from` is its ancestor, nothing in between is a merge or on
|
||||
* any remote-tracking branch (EPUSHED), from the repo root.
|
||||
*
|
||||
* The guards are git-ops.js's: argv only, a sha validated and resolved before
|
||||
* git sees it as a revision, a clean tree and the repo-root lane to start one
|
||||
* (an abort can then never take uncommitted work with it), nothing while a
|
||||
* rebase or another operation is open (EMERGING), and the route's lane lock.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const worktree = require("./worktree");
|
||||
const history = require("./git-history");
|
||||
|
||||
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
|
||||
|
||||
/** The actions this module owns (git-ops.js dispatches them here). */
|
||||
const PICK_ACTIONS = new Set(["pick-continue", "pick-skip", "pick-abort", "undo-pick"]);
|
||||
|
||||
/** A commit id: abbreviated or full. */
|
||||
const SHA_RE = /^[0-9a-f]{4,64}$/i;
|
||||
/** Most commits one `undo-pick` takes back. */
|
||||
const MAX_UNDO = 50;
|
||||
/** `--continue` takes the message git prepared; never an editor. */
|
||||
const NO_EDITOR = { env: { GIT_EDITOR: "true" } };
|
||||
|
||||
function bad(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
function out(result) {
|
||||
return [result?.stdout, result?.stderr].filter(Boolean).join("").trim();
|
||||
}
|
||||
|
||||
async function rebaseInProgress(dir) {
|
||||
for (const name of ["rebase-merge", "rebase-apply"]) {
|
||||
const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim();
|
||||
if (fs.existsSync(path.resolve(dir, rel))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function unmergedPaths(dir) {
|
||||
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function head(dir) {
|
||||
return (await git(dir, ["rev-parse", "HEAD"])).stdout.trim();
|
||||
}
|
||||
|
||||
async function assertRepoRoot(dir, what) {
|
||||
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
|
||||
throw bad(
|
||||
"ESUBDIR",
|
||||
`${what} changes the whole repository; run it from a workspace at the repo root`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The cherry-pick or revert open here (by *_HEAD, else by the sequencer). */
|
||||
async function openPick(dir) {
|
||||
const op = await worktree.operationInProgress(dir);
|
||||
if (op === "cherry-pick" || op === "revert") return op;
|
||||
// A merge or a rebase (the rewrite tools' own) is never a pick here.
|
||||
if (op) return null;
|
||||
const todo = await history.sequencerTodo(dir);
|
||||
if (!todo.length) return null;
|
||||
return todo[0].verb === "revert" ? "revert" : "cherry-pick";
|
||||
}
|
||||
|
||||
/** The commits `from..to` made, oldest first. */
|
||||
async function madeBetween(dir, from, to) {
|
||||
if (!from || from === to) return [];
|
||||
const { stdout } = await git(dir, [
|
||||
"log",
|
||||
"--reverse",
|
||||
`--max-count=${MAX_UNDO * 2}`,
|
||||
"--format=%H%x1f%h%x1f%s%x1e",
|
||||
`${from}..${to}`,
|
||||
]);
|
||||
return stdout
|
||||
.split("\x1e")
|
||||
.map((r) => r.replace(/^\n/, ""))
|
||||
.filter((r) => r.trim())
|
||||
.map((r) => {
|
||||
const [sha, short, subject] = r.split("\x1f");
|
||||
return { sha, short, subject };
|
||||
});
|
||||
}
|
||||
|
||||
/** What every answer carries: where it started, where HEAD is, what it made. */
|
||||
async function result(dir, action, gitResult, from, extra = {}) {
|
||||
const to = await head(dir).catch(() => null);
|
||||
const state = await history.sequenceState(dir);
|
||||
return {
|
||||
action,
|
||||
ok: true,
|
||||
output: out(gitResult),
|
||||
from: state.active && state.startedFrom ? state.startedFrom : from,
|
||||
to,
|
||||
commits: await madeBetween(
|
||||
dir,
|
||||
state.active && state.startedFrom ? state.startedFrom : from,
|
||||
to
|
||||
),
|
||||
conflicts: state.active ? state.conflicts : [],
|
||||
paused: !!state.active,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** `cherry-pick` / `revert` with `shas`: one sequence, in pickPlan's order. */
|
||||
async function startSequence(dir, action, body) {
|
||||
if (await rebaseInProgress(dir)) {
|
||||
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
|
||||
}
|
||||
const open = (await worktree.operationInProgress(dir)) || (await openPick(dir));
|
||||
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
|
||||
const commits = await history.pickPlan(dir, action, body.shas);
|
||||
await assertRepoRoot(dir, `a ${action}`);
|
||||
const { dirty, untracked } = await worktree.statusCounts(dir);
|
||||
if (dirty + untracked > 0) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
const from = await head(dir);
|
||||
const shas = commits.map((c) => c.sha);
|
||||
const args = action === "revert" ? ["revert", "--no-edit", ...shas] : ["cherry-pick", ...shas];
|
||||
try {
|
||||
const r = await git(dir, args, NO_EDITOR);
|
||||
return result(dir, action, r, from, { applied: commits });
|
||||
} catch (e) {
|
||||
const state = await history.sequenceState(dir);
|
||||
if (state.active) {
|
||||
// Stopped on a conflict, or on a commit whose changes are already here
|
||||
// (no conflict, nothing to commit): paused, finished from the banner.
|
||||
const empty = !state.conflicts.length;
|
||||
return result(dir, action, e.git, from, { applied: commits, ...(empty ? { empty } : {}) });
|
||||
}
|
||||
// Nothing paused: git refused outright (no identity, a failing signer).
|
||||
// The tree was clean before, so the commits' own paths go back to HEAD —
|
||||
// nothing else is touched, and no reset runs (git-ops.js's single-sha
|
||||
// path does the same).
|
||||
const touched = new Set();
|
||||
for (const sha of shas) {
|
||||
const r = await git(dir, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", sha]);
|
||||
for (const p of r.stdout.split("\0")) if (p) touched.add(p);
|
||||
}
|
||||
if (touched.size) {
|
||||
await git(dir, [
|
||||
"restore",
|
||||
"--source=HEAD",
|
||||
"--staged",
|
||||
"--worktree",
|
||||
"--",
|
||||
...touched,
|
||||
]).catch(() => {});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** pick-continue / pick-skip / pick-abort. */
|
||||
async function sequenceAction(dir, action, body) {
|
||||
if (await rebaseInProgress(dir)) {
|
||||
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
|
||||
}
|
||||
if ((action === "pick-abort" || action === "pick-skip") && body.confirm !== true) {
|
||||
throw bad("ECONFIRM", `${action} requires confirm: true`);
|
||||
}
|
||||
const op = await openPick(dir);
|
||||
if (!op) throw bad("ENOMERGE", "no cherry-pick or revert in progress");
|
||||
const from = await history.sequencerHead(dir);
|
||||
if (action === "pick-abort") {
|
||||
const r = await git(dir, [op, "--abort"]);
|
||||
return { action, ok: true, output: out(r), to: await head(dir), conflicts: [], paused: false };
|
||||
}
|
||||
if (action === "pick-continue") {
|
||||
const left = await unmergedPaths(dir);
|
||||
if (left.length) {
|
||||
throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const r = await git(dir, [op, action === "pick-skip" ? "--skip" : "--continue"], NO_EDITOR);
|
||||
return result(dir, action, r, from);
|
||||
} catch (e) {
|
||||
const state = await history.sequenceState(dir);
|
||||
if (state.active && state.conflicts.length) return result(dir, action, e.git, from);
|
||||
if (state.active && action === "pick-continue") {
|
||||
// Resolved to exactly what the branch already had: nothing to commit.
|
||||
const staged = await git(dir, ["diff", "--cached", "--quiet"]).then(
|
||||
() => false,
|
||||
() => true
|
||||
);
|
||||
if (!staged) throw bad("EEMPTY", "this commit's changes are already here: skip it instead");
|
||||
}
|
||||
if (state.active) return result(dir, action, e.git, from, { empty: true });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** undo-pick: `reset --soft <from>`, only over what the pick itself made. */
|
||||
async function undoPick(dir, body) {
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "undo-pick requires confirm: true");
|
||||
if (await rebaseInProgress(dir)) {
|
||||
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
|
||||
}
|
||||
const open = (await worktree.operationInProgress(dir)) || (await openPick(dir));
|
||||
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
|
||||
for (const k of ["from", "to"]) {
|
||||
if (typeof body[k] !== "string" || !SHA_RE.test(body[k])) {
|
||||
throw bad("EBADSHA", `${k} must be a commit id`);
|
||||
}
|
||||
}
|
||||
const resolve = (s) =>
|
||||
git(dir, ["rev-parse", "-q", "--verify", `${s}^{commit}`]).then(
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
const from = await resolve(body.from);
|
||||
const to = await resolve(body.to);
|
||||
if (!from || !to) throw bad("EBADSHA", "no such commit");
|
||||
if (to !== (await head(dir))) {
|
||||
throw bad("ESTALE", "the branch moved since the pick: refresh and retry");
|
||||
}
|
||||
await assertRepoRoot(dir, "undoing a pick");
|
||||
const ancestor = await git(dir, ["merge-base", "--is-ancestor", from, to]).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (!ancestor || from === to) throw bad("EBADSHA", "from is not before to on this branch");
|
||||
const made = await madeBetween(dir, from, to);
|
||||
if (!made.length || made.length > MAX_UNDO) {
|
||||
throw bad("EBADSHA", `undo takes back 1 to ${MAX_UNDO} commits`);
|
||||
}
|
||||
if ((await git(dir, ["rev-list", "--merges", `${from}..${to}`])).stdout.trim()) {
|
||||
throw bad("EMERGECOMMIT", "a merge commit is in that range");
|
||||
}
|
||||
// Every later commit contains the first one, so a remote holding any of
|
||||
// them holds the first.
|
||||
const on = (
|
||||
await git(dir, [
|
||||
"for-each-ref",
|
||||
"--contains",
|
||||
made[0].sha,
|
||||
"--format=%(refname:short)",
|
||||
"refs/remotes",
|
||||
])
|
||||
).stdout.trim();
|
||||
if (on) {
|
||||
throw bad("EPUSHED", `those commits are already on ${on.split("\n")[0]}; revert them instead`);
|
||||
}
|
||||
// --soft: only the branch moves; the changes stay staged, the working tree
|
||||
// (an agent's live edits included) is never touched.
|
||||
const r = await git(dir, ["reset", "--soft", from]);
|
||||
return { action: "undo-pick", ok: true, output: out(r), to: from, undone: made };
|
||||
}
|
||||
|
||||
/** Entry point from git-ops.js's `runGitAction`. */
|
||||
async function runPickAction(dir, action, body = {}) {
|
||||
if (action === "undo-pick") return undoPick(dir, body);
|
||||
if (PICK_ACTIONS.has(action)) return sequenceAction(dir, action, body);
|
||||
return startSequence(dir, action, body);
|
||||
}
|
||||
|
||||
module.exports = { runPickAction, PICK_ACTIONS };
|
||||
@@ -14,6 +14,8 @@ const ccConfig = require("./openapi-extra/cc-config");
|
||||
const codexConfig = require("./openapi-extra/codex-config");
|
||||
const run = require("./openapi-extra/run");
|
||||
const lanes = require("./openapi-extra/lanes");
|
||||
// The history reads; its /git/log replaces the shorter entry in lanes.js.
|
||||
const gitHistory = require("./openapi-extra/git-history");
|
||||
const push = require("./openapi-extra/push");
|
||||
const aiTools = require("./openapi-extra/ai-tools");
|
||||
const misc = require("./openapi-extra/misc");
|
||||
@@ -59,6 +61,7 @@ module.exports = combine(
|
||||
codexConfig,
|
||||
run,
|
||||
lanes,
|
||||
gitHistory,
|
||||
push,
|
||||
aiTools,
|
||||
misc,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for the Changes panel's history reads
|
||||
* (server/routes/lane-git-history.js over server/lib/git-history.js): the
|
||||
* paged, searchable commit graph (`/git/log`, which replaces the shorter
|
||||
* entry in openapi-extra/lanes.js), commit details, a commit's patch, a
|
||||
* file's history across renames, blame at a revision, a tree at a revision,
|
||||
* comparing two refs, a cherry-pick/revert plan and a paused one's state.
|
||||
* Exports `{ tags, schemas, paths }` for server/openapi-extra.js. Every
|
||||
* /api/lanes path is also served under /api/workspaces.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
||||
const q = (name, description, schema = { type: "string" }, required = false) => ({
|
||||
name,
|
||||
in: "query",
|
||||
...(required ? { required } : {}),
|
||||
description,
|
||||
schema,
|
||||
});
|
||||
/** The repo selectors every /git read takes. */
|
||||
const target = [
|
||||
q("session", "A session's own worktree (`claude --worktree`) instead of the workspace folder."),
|
||||
q("source", "An extra folder of this workspace (`lane_sources` id)."),
|
||||
q("repo", "A repo inside the workspace folder, by its relative path."),
|
||||
];
|
||||
const READ =
|
||||
" Read-only and unguarded like every /git read; the repo is resolved from the lane row (or ?session / ?source / ?repo), never from a path in the request. A folder that is not a repo answers 200 {available:false}.";
|
||||
const errors = (bad, forbidden = false) => ({
|
||||
400: { description: bad },
|
||||
...(forbidden
|
||||
? {
|
||||
403: {
|
||||
description:
|
||||
"ESECRET (`.claude/settings.local.json`, at any rev and depth) or EFOREIGN (S3: the folder resolves into another account's files).",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
404: { description: "Lane not found, or ENOSOURCE / ENOREPO / ENOWORKTREE." },
|
||||
});
|
||||
|
||||
const paths = {
|
||||
"/api/lanes/{id}/git/log": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The lane's commit graph, paged and searchable",
|
||||
description:
|
||||
"Newest first in --date-order (no parent before its children), each commit with its parents and ref badges ({kind: local|remote|tag|head, name, current?}) for drawing the graph. limit (default 30, max 500) and skip page it; hasMore says a next page exists. all=1 reads every branch, remote branch and tag (the stash excluded); ref= another existing branch (else 400 ENOBRANCH). Search: q= the message (case-insensitive, literal — never a regex) and, when it is hex, the commit with that SHA prefix first; author= name or e-mail (literal); path= (alias file=) only commits touching that lane-relative path (no `..`, else 400 EBADPATH). Text over 200 characters is 400 EBADQUERY. A walk is killed after 20 s." +
|
||||
READ,
|
||||
operationId: "listLaneCommits",
|
||||
parameters: [
|
||||
idParam,
|
||||
q("limit", "Page size.", { type: "integer", default: 30, maximum: 500 }),
|
||||
q("skip", "Commits to skip (the page's start).", { type: "integer", default: 0 }),
|
||||
q("all", "1 = every branch, remote branch and tag.", { type: "string", enum: ["1"] }),
|
||||
q("ref", "Another branch's history."),
|
||||
q("q", "Message text, or a SHA prefix."),
|
||||
q("author", "Author name or e-mail."),
|
||||
q("path", "Only commits touching this path."),
|
||||
q("file", "Alias of path."),
|
||||
...target,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, commits:[{sha, short, subject, author, email, date, refs, parents, badges}], hasMore, filtered}.",
|
||||
},
|
||||
...errors("EBADPATH, ENOBRANCH or EBADQUERY."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/commit": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One commit in full (details pane)",
|
||||
description:
|
||||
"The whole message, author and committer with dates, parents, the commit's own ref badges, isHead, and the branches and tags that contain it (50 each, containsTruncated past that). Its files and diffs are GET /git/changes?range=commit&sha=." +
|
||||
READ,
|
||||
operationId: "readLaneCommit",
|
||||
parameters: [idParam, q("sha", "A commit (sha or ref name).", undefined, true), ...target],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, sha, short, subject, message, author, email, date, committer, committerEmail, committedAt, parents, badges, isHead, branches, tags, containsTruncated}.",
|
||||
},
|
||||
...errors("EBADREV."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/patch": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "A commit as a patch (Copy patch)",
|
||||
description:
|
||||
"`git format-patch -1 --stdout --relative` over the commit's files inside the lane. A secret file's diff is never included: it is named in omitted instead. Capped at 2 MB (truncated). A merge commit has no single patch (400 EMERGECOMMIT)." +
|
||||
READ,
|
||||
operationId: "readLaneCommitPatch",
|
||||
parameters: [idParam, q("sha", "A commit.", undefined, true), ...target],
|
||||
responses: {
|
||||
200: { description: "{available, sha, patch, omitted:[paths], truncated}." },
|
||||
...errors("EBADREV or EMERGECOMMIT."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/file-history": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One file's history across renames",
|
||||
description:
|
||||
"`git log --follow` for one lane-relative path, newest first: each commit with the path the file had there, its status letter (A/M/R/D…) and, for a rename, from. Paged like /git/log (limit ≤ 500, skip, hasMore). A secret file is 403 ESECRET." +
|
||||
READ,
|
||||
operationId: "listLaneFileHistory",
|
||||
parameters: [
|
||||
idParam,
|
||||
q("path", "The file.", undefined, true),
|
||||
q("limit", "Page size.", { type: "integer", default: 100, maximum: 500 }),
|
||||
q("skip", "Commits to skip.", { type: "integer", default: 0 }),
|
||||
...target,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, path, commits:[{sha, short, subject, author, date, parents, status, path, from?}], hasMore}.",
|
||||
},
|
||||
...errors("EBADPATH.", true),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/blame-at": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Blame at a revision, with each line's text",
|
||||
description:
|
||||
"`git blame --porcelain` of the file at rev (a commit) or rev=work (the file on disk). Each line carries its text and the sha that last changed it (null = not committed yet); commits maps a sha to {short, author, date, summary, previous} — previous is where 'blame before this change' goes. Same guards as /git/file: lexical lane path, 403 ESECRET, 1 MB cap (400 ETOOBIG)." +
|
||||
READ,
|
||||
operationId: "blameLaneFileAt",
|
||||
parameters: [
|
||||
idParam,
|
||||
q("path", "The file.", undefined, true),
|
||||
q("rev", "A commit, or work.", { type: "string", default: "work" }),
|
||||
...target,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, path, rev, commits: {<sha>: {short, author, date, summary, previous?}}, lines: [{sha|null, text}]}.",
|
||||
},
|
||||
...errors("EBADPATH, EBADREV, ENOFILE or ETOOBIG.", true),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/tree-at": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One folder of the tree at a commit (Browse files)",
|
||||
description:
|
||||
"`git ls-tree` of path (a lane-relative folder; empty = the lane's own folder) at rev, folders first. A secret file is left out of the list; a file's content is then GET /git/file?rev=<sha> (or /git/raw for an image) under that read's own guards. Capped at 5000 entries (truncated)." +
|
||||
READ,
|
||||
operationId: "listLaneTreeAt",
|
||||
parameters: [
|
||||
idParam,
|
||||
q("rev", "A commit.", undefined, true),
|
||||
q("path", "A folder inside the lane."),
|
||||
...target,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, rev, path, entries:[{path, name, dir, submodule, size}], truncated}.",
|
||||
},
|
||||
...errors("EBADPATH or EBADREV."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/compare": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Compare two refs",
|
||||
description:
|
||||
"a and b are branches, tags or commits (each resolved to a commit, else 400 EBADREV). Without path: onlyA / onlyB — the commits only in a and only in b (`--left-right a...b`, 300 each, commitsTruncated) — and files, the files that differ (1000, filesTruncated): mode=three (default) is `a...b`, what b adds since the two diverged; mode=two is `a b`, the two trees as they stand. With path (and a rename's from): that one file with its hunks (200 KB cap); a secret file comes back as {secret:true, hunks:[]}." +
|
||||
READ,
|
||||
operationId: "compareLaneRefs",
|
||||
parameters: [
|
||||
idParam,
|
||||
q("a", "Left ref.", undefined, true),
|
||||
q("b", "Right ref.", undefined, true),
|
||||
q("mode", "three (a...b) or two (a b).", { type: "string", enum: ["three", "two"] }),
|
||||
q("path", "One file's diff."),
|
||||
q("from", "That file's old name, for a rename."),
|
||||
...target,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, a, b, mode, onlyA, onlyB, commitsTruncated, files, filesTruncated} — or {available, a, b, mode, file} with path.",
|
||||
},
|
||||
...errors("EBADREV or EBADPATH."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/pick-plan": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "What a cherry-pick / revert of several commits would do",
|
||||
description:
|
||||
"The confirmation dialog's preview, computed by the same code the action runs: shas (comma-separated, 1–50) resolved and ordered — a cherry-pick oldest → newest, a revert newest → oldest (ancestry first, then date) — plus the current branch, whether the tree is clean, whether the lane is the repo root, and busy (an operation is already open). A merge commit is 400 EMERGECOMMIT, an unknown one 400 EBADSHA." +
|
||||
READ,
|
||||
operationId: "planLanePick",
|
||||
parameters: [
|
||||
idParam,
|
||||
q(
|
||||
"op",
|
||||
"cherry-pick or revert.",
|
||||
{ type: "string", enum: ["cherry-pick", "revert"] },
|
||||
true
|
||||
),
|
||||
q("shas", "Comma-separated commit ids.", undefined, true),
|
||||
...target,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, op, commits:[{sha, short, subject, author, date}], branch, clean, repoRoot, busy}.",
|
||||
},
|
||||
...errors("EBADACTION, EBADSHA or EMERGECOMMIT."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/sequence": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The cherry-pick / revert that is paused, if any",
|
||||
description:
|
||||
"For the 'paused at commit X (2 of 5)' banner: operation, current (the commit it stopped at), remaining (still to apply), done (applied since it started), startedFrom (the HEAD it started at) and conflicts (unmerged paths). {active:false} when none is open (a merge is not reported here)." +
|
||||
READ,
|
||||
operationId: "readLanePickSequence",
|
||||
parameters: [idParam, ...target],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, active, operation, current, remaining, done, startedFrom, conflicts}.",
|
||||
},
|
||||
...errors("—"),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags: [], schemas: {}, paths };
|
||||
@@ -972,6 +972,10 @@ const paths = {
|
||||
"remote-rename",
|
||||
"remote-remove",
|
||||
"conflict-save",
|
||||
"pick-continue",
|
||||
"pick-skip",
|
||||
"pick-abort",
|
||||
"undo-pick",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1067,7 +1071,23 @@ const paths = {
|
||||
confirm: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"discard, discard-hunk, merge-abort, undo-commit and shelf-drop; the call is refused without it. Also the answer to 409 EAGENTRUNNING (shelve, unshelve, a switch moving uncommitted changes while an agent of the lane is working).",
|
||||
"discard, discard-hunk, merge-abort, undo-commit, shelf-drop, pick-skip, pick-abort and undo-pick; the call is refused without it (400 ECONFIRM). Also the answer to 409 EAGENTRUNNING (shelve, unshelve, a switch moving uncommitted changes while an agent of the lane is working).",
|
||||
},
|
||||
shas: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"cherry-pick, revert (server/lib/git-pick.js): 1-50 non-merge commits (400 EBADSHA / EMERGECOMMIT) applied as ONE git sequence in the order GET /git/pick-plan shows — a pick oldest first, a revert newest first. Clean tree, repo-root lane, nothing already open (EDIRTY / ESUBDIR / 409 EMERGING). Answers {from, to, commits, conflicts, paused, applied}; paused:true with conflicts[] stops at a conflict (or, with empty:true, at a commit already on the branch) — finish with pick-continue (409 ECONFLICTS while anything is unmerged, 409 EEMPTY when the resolution changed nothing), pick-skip {confirm:true} (drops that commit and goes on) or pick-abort {confirm:true} (`git <op> --abort`, back to from; also ends a sequence whose *_HEAD is gone). The single sha body is unchanged.",
|
||||
},
|
||||
from: {
|
||||
type: "string",
|
||||
description:
|
||||
"undo-pick: the HEAD before the pick/revert (its from). Must be an ancestor of to, 1-50 commits back, none of them a merge (EMERGECOMMIT).",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
description:
|
||||
"undo-pick: the HEAD the pick produced; 409 ESTALE once HEAD moved on, 409 EPUSHED once any remote-tracking branch holds one of the commits. Runs `reset --soft <from>` (the changes stay staged).",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1086,7 +1106,7 @@ const paths = {
|
||||
404: { description: "Lane not found." },
|
||||
409: {
|
||||
description:
|
||||
"EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS.",
|
||||
"EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS, EEMPTY, ESTALE, EPUSHED.",
|
||||
},
|
||||
500: { description: "Unexpected server-side failure." },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file The Changes panel's history reads, mounted on the lanes router
|
||||
* (`/api/lanes/:id/git/...` and its `/api/workspaces` alias): commit details,
|
||||
* a commit's patch, a file's history across renames, blame at a revision, a
|
||||
* tree at a revision, a two-ref comparison, a cherry-pick/revert plan and a
|
||||
* paused one's state. All of it is server/lib/git-history.js.
|
||||
*
|
||||
* Reads, so unguarded like every other `/git/*` read — and each resolves its
|
||||
* repository exactly like them, through the lanes router's `gitTargetOr404`
|
||||
* (the lane row, `?session=`, `?source=`, `?repo=`), never a path from the
|
||||
* request. Error mapping matches `/git/file`: a secret or a foreign folder is
|
||||
* 403, a bad request 400, anything else (not a repo, no commits) a 200
|
||||
* `{available: false}` — a state, not a fault.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const history = require("../lib/git-history");
|
||||
|
||||
const BAD = new Set([
|
||||
"EBADPATH",
|
||||
"EBADREV",
|
||||
"EBADQUERY",
|
||||
"EBADSHA",
|
||||
"EBADACTION",
|
||||
"ENOFILE",
|
||||
"ETOOBIG",
|
||||
"ENOBRANCH",
|
||||
"EMERGECOMMIT",
|
||||
]);
|
||||
|
||||
const str = (v) => (typeof v === "string" ? v : "");
|
||||
|
||||
/**
|
||||
* @param {import("express").Router} router The lanes router.
|
||||
* @param {Function} gitTargetOr404 Its repo resolver: `{lane, cwd}` or null
|
||||
* (the 404 already sent).
|
||||
*/
|
||||
function registerGitHistory(router, gitTargetOr404) {
|
||||
const read = (sub, fn) =>
|
||||
router.get(`/:id/git/${sub}`, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json({ available: true, ...(await fn(t.cwd, req.query)) });
|
||||
} catch (err) {
|
||||
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
|
||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (BAD.has(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json({ available: false });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /git/commit?sha= — one commit's message, people, parents, refs. */
|
||||
read("commit", (cwd, q) => history.commitDetails(cwd, str(q.sha)));
|
||||
/** GET /git/patch?sha= — the commit as `format-patch` text (Copy patch). */
|
||||
read("patch", (cwd, q) => history.commitPatch(cwd, str(q.sha)));
|
||||
/** GET /git/file-history?path=&skip=&limit= — `log --follow`. */
|
||||
read("file-history", (cwd, q) => history.fileHistory(cwd, str(q.path), q));
|
||||
/** GET /git/blame-at?path=&rev= — blame at a commit (or `work`). */
|
||||
read("blame-at", (cwd, q) => history.blameAt(cwd, str(q.path), str(q.rev) || "work"));
|
||||
/** GET /git/tree-at?rev=&path= — one folder of the tree at a commit. */
|
||||
read("tree-at", (cwd, q) => history.treeAt(cwd, str(q.rev), str(q.path)));
|
||||
/** GET /git/compare?a=&b=&mode=three|two[&path=&from=] — two refs. */
|
||||
read("compare", (cwd, q) =>
|
||||
history.compareRefs(cwd, {
|
||||
a: str(q.a),
|
||||
b: str(q.b),
|
||||
mode: q.mode === "two" ? "two" : "three",
|
||||
path: str(q.path) || null,
|
||||
from: str(q.from) || null,
|
||||
})
|
||||
);
|
||||
/** GET /git/pick-plan?op=cherry-pick|revert&shas=a,b — what it would run. */
|
||||
read("pick-plan", (cwd, q) =>
|
||||
history.pickPreview(
|
||||
cwd,
|
||||
str(q.op),
|
||||
str(q.shas)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
/** GET /git/sequence — the cherry-pick/revert that is paused, if any. */
|
||||
read("sequence", (cwd) => history.sequenceState(cwd));
|
||||
}
|
||||
|
||||
module.exports = { registerGitHistory };
|
||||
+15
-11
@@ -56,7 +56,6 @@ const {
|
||||
} = require("../lib/worktree");
|
||||
const {
|
||||
collectChanges,
|
||||
listCommits,
|
||||
defaultBranch,
|
||||
listPaths,
|
||||
listTree,
|
||||
@@ -67,6 +66,8 @@ const {
|
||||
} = require("../lib/git-diff");
|
||||
const { runGitAction, listShelves, PROTECTED: PROTECTED_BRANCHES } = require("../lib/git-ops");
|
||||
const gitRefs = require("../lib/git-refs");
|
||||
const { listHistory } = require("../lib/git-history");
|
||||
const { registerGitHistory } = require("./lane-git-history");
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
|
||||
const { syncMcp } = require("../lib/lane-mcp");
|
||||
@@ -1661,23 +1662,22 @@ router.get("/:id/git/changes", async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The lane's recent commits, for the history list. `?file=` narrows it to the
|
||||
* commits touching one path — the "history of this file" the single-file view
|
||||
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
|
||||
* The lane's commits, for the history graph (git-history.js `listHistory`):
|
||||
* `?limit=` (≤ 500, default 30) and `?skip=` page it, `?all=1` reads every
|
||||
* branch, remote branch and tag, `?ref=` another branch, and `?q=` (message,
|
||||
* or a SHA prefix), `?author=` and `?path=` (alias `?file=`) search it. Each
|
||||
* commit carries its parents and ref badges; `hasMore` says a next page
|
||||
* exists. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
|
||||
*/
|
||||
router.get("/:id/git/log", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
const { lane, cwd } = t;
|
||||
try {
|
||||
const commits = await listCommits(cwd, {
|
||||
limit: req.query.limit,
|
||||
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
|
||||
ref: typeof req.query.ref === "string" && req.query.ref ? req.query.ref : null,
|
||||
});
|
||||
res.json({ available: true, commits });
|
||||
const page = await listHistory(cwd, { ...req.query, limit: req.query.limit ?? 30 });
|
||||
res.json({ available: true, ...page });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADPATH" || err.code === "ENOBRANCH") {
|
||||
if (["EBADPATH", "ENOBRANCH", "EBADQUERY"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json({ available: false, commits: [] });
|
||||
@@ -1737,6 +1737,10 @@ router.get("/:id/git/blame", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// The history reads (commit details, patch, file history, blame at a rev,
|
||||
// tree at a rev, compare, pick plan, a paused pick): lane-git-history.js.
|
||||
registerGitHistory(router, gitTargetOr404);
|
||||
|
||||
/**
|
||||
* One image's bytes (`?path=&rev=work|HEAD|<sha>`) for the viewer's `<img>`:
|
||||
* `readImageAt`'s guards (the file read's, plus an image-extension allowlist
|
||||
|
||||
Reference in New Issue
Block a user