351 lines
14 KiB
JavaScript
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");
|
|
});
|
|
});
|