/** * @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ĩ */ 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"); }); });