Files
AgentHub/server/__tests__/git-history.test.js
T

351 lines
14 KiB
JavaScript

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