/** * @file The history actions against a REAL repo: cherry-pick and revert * (clean tree, repo-root lane, a commit that exists and is not a merge, a * conflict reported and finished/aborted through merge-commit/merge-abort, * an empty pick undone rather than left open), undo-commit (confirm, never a * pushed commit, never the root or a merge commit, changes kept staged) and * amend (never a pushed commit), plus a log read of another branch and the * "vs base" range falling back to the repo's default branch. * @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 { gitFacts } = require("../lib/worktree"); const { listCommits, collectChanges } = require("../lib/git-diff"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-history-")); const REPO = path.join(ROOT, "repo"); const REMOTE = path.join(ROOT, "remote.git"); const g = (...args) => { const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env }); }; const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body); const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8"); const sha = (ref) => g("rev-parse", ref).trim(); const subject = (ref = "HEAD") => g("log", "-1", "--format=%s", ref).trim(); // main: init -> main ; feat (from init): feat-a (edits a.txt), feat-b (adds b.txt) beforeEach(() => { fs.rmSync(REPO, { recursive: true, force: true }); fs.rmSync(REMOTE, { recursive: true, force: true }); fs.mkdirSync(path.join(REPO, "sub"), { recursive: true }); g("init", "-q", "-b", "main"); g("config", "user.email", "t@h"); g("config", "user.name", "t"); write("a.txt", "base\n"); write("sub/s.txt", "s\n"); g("add", "-A"); g("commit", "-qm", "init"); g("switch", "-qc", "feat"); write("a.txt", "theirs\n"); g("commit", "-qam", "feat-a"); write("b.txt", "only on feat\n"); g("add", "-A"); g("commit", "-qm", "feat-b"); g("switch", "-q", "main"); write("a.txt", "ours\n"); g("commit", "-qam", "main"); }); describe("cherry-pick", () => { it("applies one commit from another branch", async () => { const r = await runGitAction(REPO, "cherry-pick", { sha: sha("feat") }); assert.deepEqual(r.conflicts, []); assert.equal(read("b.txt"), "only on feat\n"); assert.equal(subject(), "feat-b"); }); it("reports a conflict, then finishes it with merge-commit keeping the author", async () => { g("switch", "-qc", "other", "main~1"); write("a.txt", "other\n"); g("-c", "user.name=Other", "-c", "user.email=o@h", "commit", "-qam", "by-other"); g("switch", "-q", "main"); const r = await runGitAction(REPO, "cherry-pick", { sha: sha("other") }); assert.deepEqual(r.conflicts, ["a.txt"]); const facts = await gitFacts(REPO); assert.equal(facts.merging, true); assert.equal(facts.operation, "cherry-pick"); await assert.rejects(runGitAction(REPO, "revert", { sha: sha("HEAD") }), { code: "EMERGING" }); await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" }); await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" }); await runGitAction(REPO, "merge-commit"); assert.equal((await gitFacts(REPO)).merging, false); assert.equal(subject(), "by-other"); assert.equal(read("a.txt"), "other\n"); assert.equal(g("log", "-1", "--format=%an", "HEAD").trim(), "Other"); }); it("aborts a conflicted pick with confirm, back to where it started", async () => { const before = sha("HEAD"); await runGitAction(REPO, "cherry-pick", { sha: sha("feat~1") }); await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" }); await runGitAction(REPO, "merge-abort", { confirm: true }); assert.equal((await gitFacts(REPO)).merging, false); assert.equal(sha("HEAD"), before); assert.equal(read("a.txt"), "ours\n"); }); it("undoes an empty pick instead of leaving it open", async () => { await runGitAction(REPO, "cherry-pick", { sha: sha("feat") }); await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: sha("feat") }), { code: "EEMPTY", }); assert.equal((await gitFacts(REPO)).merging, false); }); it("refuses a dirty tree, a bad or unknown sha, a merge commit and a subdirectory lane", async () => { write("a.txt", "wip\n"); await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: sha("feat") }), { code: "EDIRTY", }); g("checkout", "--", "a.txt"); for (const bad of [undefined, "", "--help", "HEAD", "zzzz", "feat"]) { await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: bad }), { code: "EBADSHA" }); } await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: "deadbeefdeadbeef" }), { code: "EBADSHA", }); g("merge", "-q", "-X", "ours", "--no-edit", "feat"); await assert.rejects(runGitAction(REPO, "revert", { sha: sha("HEAD") }), { code: "EMERGECOMMIT", }); await assert.rejects( runGitAction(path.join(REPO, "sub"), "cherry-pick", { sha: sha("feat") }), { code: "ESUBDIR" } ); }); }); describe("revert", () => { it("adds a commit that undoes an older one", async () => { g("switch", "-q", "feat"); const r = await runGitAction(REPO, "revert", { sha: sha("feat~1") }); assert.deepEqual(r.conflicts, []); assert.equal(read("a.txt"), "base\n"); assert.match(subject(), /^Revert "feat-a"/); }); it("reports a conflict and aborts it", async () => { write("a.txt", "later\n"); g("commit", "-qam", "later"); await runGitAction(REPO, "revert", { sha: sha("HEAD~1") }); assert.equal((await gitFacts(REPO)).operation, "revert"); await runGitAction(REPO, "merge-abort", { confirm: true }); assert.equal((await gitFacts(REPO)).merging, false); assert.equal(read("a.txt"), "later\n"); }); }); describe("undo-commit", () => { it("drops the last commit and keeps its changes staged", async () => { await assert.rejects(runGitAction(REPO, "undo-commit"), { code: "ECONFIRM" }); const parent = sha("HEAD~1"); await runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }); assert.equal(sha("HEAD"), parent); assert.equal(read("a.txt"), "ours\n"); assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt"); }); it("never touches a pushed commit, the root commit or a merge commit", async () => { execFileSync("git", ["init", "-q", "--bare", REMOTE]); g("remote", "add", "origin", REMOTE); g("push", "-q", "origin", "main"); await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), { code: "EPUSHED", }); await assert.rejects(runGitAction(REPO, "amend", { message: "x", sha: sha("HEAD") }), { code: "EPUSHED", }); g("switch", "-q", "--orphan", "solo"); g("commit", "-qm", "root", "--allow-empty"); await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), { code: "ENOPARENT", }); g("switch", "-q", "-f", "feat"); g("merge", "-q", "--no-ff", "--no-edit", "-X", "ours", "main"); await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), { code: "EMERGECOMMIT", }); }); }); describe("amend", () => { it("rewrites the message, or folds staged changes in keeping it", async () => { const parent = sha("HEAD~1"); await runGitAction(REPO, "amend", { message: "main, reworded", sha: sha("HEAD") }); assert.equal(subject(), "main, reworded"); assert.equal(sha("HEAD~1"), parent); await assert.rejects(runGitAction(REPO, "amend", { sha: sha("HEAD") }), { code: "ENOCHANGES" }); write("c.txt", "c\n"); g("add", "c.txt"); await runGitAction(REPO, "amend", { sha: sha("HEAD") }); assert.equal(subject(), "main, reworded"); assert.equal(sha("HEAD~1"), parent); assert.match(g("show", "--name-only", "--format=", "HEAD"), /c\.txt/); }); }); describe("log of another branch", () => { it("lists a named branch's commits and refuses a ref that is not a branch", async () => { const commits = await listCommits(REPO, { ref: "feat" }); assert.deepEqual( commits.map((c) => c.subject), ["feat-b", "feat-a", "init"] ); await assert.rejects(listCommits(REPO, { ref: "--all" }), { code: "ENOBRANCH" }); await assert.rejects(listCommits(REPO, { ref: "nope" }), { code: "ENOBRANCH" }); }); }); describe("vs base without a recorded base", () => { it("compares against the repo's default branch", async () => { g("switch", "-q", "feat"); const r = await collectChanges(REPO, { range: "branch", base: null }); assert.equal(r.base, "main"); assert.deepEqual( r.files.map((f) => f.path), ["a.txt", "b.txt"] ); }); }); describe("review fixes", () => { it("undo and amend act only on the HEAD the user saw", async () => { const seen = sha("HEAD"); write("a.txt", "agent\n"); g("commit", "-qam", "agent committed meanwhile"); await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: seen }), { code: "ESTALE", }); await assert.rejects(runGitAction(REPO, "amend", { message: "x", sha: seen }), { code: "ESTALE", }); await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true }), { code: "EBADSHA" }); assert.equal(subject(), "agent committed meanwhile"); }); it("a pick or revert that fails at commit time reports git's error and leaves the tree clean", async () => { g("config", "commit.gpgsign", "true"); g("config", "gpg.program", "false"); for (const [action, ref] of [["cherry-pick", "feat"], ["revert", "HEAD"]]) { const before = sha("HEAD"); await assert.rejects(runGitAction(REPO, action, { sha: sha(ref) }), (e) => { assert.notEqual(e.code, "EEMPTY"); assert.match(e.git?.stderr || "", /gpg/i); return true; }); assert.equal(sha("HEAD"), before); assert.equal(g("status", "--porcelain"), ""); assert.equal((await gitFacts(REPO)).merging, false); } }); it("a revert of something already reverted is EEMPTY", async () => { const target = sha("HEAD"); await runGitAction(REPO, "revert", { sha: target }); g("revert", "--no-edit", "HEAD"); write("a.txt", "base\n"); g("commit", "-qam", "undo by hand"); await assert.rejects(runGitAction(REPO, "revert", { sha: target }), { code: "EEMPTY" }); assert.equal(g("status", "--porcelain"), ""); }); it("amend refuses a subdirectory lane", async () => { await assert.rejects( runGitAction(path.join(REPO, "sub"), "amend", { message: "x", sha: sha("HEAD") }), { code: "ESUBDIR" } ); }); it("a rebase started in a terminal blocks every history action", async () => { g("switch", "-q", "feat"); const env = { ...process.env, GIT_SEQUENCE_EDITOR: "sed -i 1s/^pick/edit/" }; delete env.GIT_DIR; execFileSync("git", ["rebase", "-q", "-i", "HEAD~2"], { cwd: REPO, env, stdio: "ignore" }); for (const [action, body] of [ ["undo-commit", { confirm: true, sha: sha("HEAD") }], ["amend", { message: "x", sha: sha("HEAD") }], ["cherry-pick", { sha: sha("main") }], ["merge-commit", {}], ]) { await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" }); } g("rebase", "--abort"); }); });