/** * @file History rewrites, remote writes and the better-conflicts reads * (git-rewrite.js, git-conflicts.js) against REAL temp repos with a local bare * repo as `origin`: the merge editor's stage reads + save + resolve, the * reflog, rebase (clean, conflict → continue/skip/abort, EMERGING for other * writes), interactive rebase (squash/reword/drop/reorder, EPUSHED, undo), * reset (soft/mixed/keep/hard guards, undo), force-with-lease (ok, ESTALE), * protected-branch refusals, remote branch delete and remotes add/rename/ * remove with the URL allowlist. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach, after } = 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 { readConflict, readReflog } = require("../lib/git-conflicts"); const { rewritePlan, listRemotes, redactUrl } = require("../lib/git-rewrite"); const { contentSha } = require("../lib/git-diff"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-rewrite-")); const REPO = path.join(ROOT, "repo"); const BARE = path.join(ROOT, "origin.git"); const OTHER = path.join(ROOT, "other"); after(() => fs.rmSync(ROOT, { recursive: true, force: true })); const env = () => { const e = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) delete e[k]; return e; }; const gIn = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", env: env() }); const g = (...args) => gIn(REPO, ...args); 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 subjects = (range = "main") => g("log", "--format=%s", range).trim().split("\n"); const commit = (file, body, msg) => { write(file, body); g("add", "-A"); g("commit", "-qm", msg); }; beforeEach(() => { for (const d of [REPO, BARE, OTHER]) fs.rmSync(d, { recursive: true, force: true }); gIn(ROOT, "init", "-q", "--bare", "-b", "main", BARE); fs.mkdirSync(REPO); g("init", "-q", "-b", "main"); g("config", "user.email", "t@h"); g("config", "user.name", "t"); commit("a.txt", "base\n", "init"); g("remote", "add", "origin", BARE); g("push", "-q", "-u", "origin", "main"); }); /** A `feat` branch and a `main` that both edit a.txt (one line). */ function divergent() { g("switch", "-qc", "feat"); commit("a.txt", "theirs\n", "feat edit"); commit("f.txt", "f\n", "feat file"); g("switch", "-q", "main"); commit("a.txt", "ours\n", "main edit"); } describe("merge editor reads and save", () => { it("reads base/ours/theirs and saves a resolved result", async () => { divergent(); const m = await runGitAction(REPO, "merge", { branch: "feat" }); assert.deepEqual(m.conflicts, ["a.txt"]); const c = await readConflict(REPO, "a.txt"); assert.equal(c.base, "base\n"); assert.equal(c.ours, "ours\n"); assert.equal(c.theirs, "theirs\n"); assert.equal(c.operation, "merge"); assert.equal(c.usable, true); assert.match(c.working, /<<<<<<< /); assert.equal(c.sha, contentSha(fs.readFileSync(path.join(REPO, "a.txt")))); assert.equal(c.incoming.subject, "feat file"); await assert.rejects(readConflict(REPO, "f.txt"), { code: "EBADPATH" }); await assert.rejects(readConflict(REPO, "../x"), { code: "EBADPATH" }); // Markers left: refused before anything is written. await assert.rejects( runGitAction(REPO, "conflict-save", { path: "a.txt", content: c.working, baseSha: c.sha }), { code: "EMARKERS" } ); await assert.rejects( runGitAction(REPO, "conflict-save", { path: "a.txt", content: "x\n", baseSha: "0".repeat(40), }), { code: "ESTALE" } ); const r = await runGitAction(REPO, "conflict-save", { path: "a.txt", content: "ours\ntheirs\n", baseSha: c.sha, }); assert.equal(r.resolved, true); assert.equal(read("a.txt"), "ours\ntheirs\n"); assert.equal((await gitFacts(REPO)).conflicts, 0); // No longer conflicted: neither readable nor savable here. await assert.rejects(readConflict(REPO, "a.txt"), { code: "EBADPATH" }); await runGitAction(REPO, "merge-commit", {}); }); it("saves progress without resolving when resolve is false", async () => { divergent(); await runGitAction(REPO, "merge", { branch: "feat" }); const c = await readConflict(REPO, "a.txt"); await runGitAction(REPO, "conflict-save", { path: "a.txt", content: c.working, baseSha: c.sha, resolve: false, }); assert.equal((await gitFacts(REPO)).conflicts, 1); }); }); describe("reflog", () => { it("pages HEAD's and a branch's reflog and refuses an unknown ref", async () => { commit("b.txt", "1\n", "second"); commit("b.txt", "2\n", "third"); const page = await readReflog(REPO, { limit: 2 }); assert.equal(page.entries.length, 2); assert.equal(page.more, true); assert.equal(page.entries[0].action, "commit"); assert.equal(page.entries[0].message, "third"); assert.equal(page.entries[0].selector, "HEAD@{0}"); assert.ok(page.entries[0].time > 0); const next = await readReflog(REPO, { skip: 2, limit: 2 }); assert.equal(next.entries[0].index, 2); const br = await readReflog(REPO, { ref: "main" }); assert.equal(br.ref, "main"); assert.ok(br.entries.length >= 3); await assert.rejects(readReflog(REPO, { ref: "nope" }), { code: "ENOBRANCH" }); await assert.rejects(readReflog(REPO, { ref: "-x" }), { code: "EBADBRANCH" }); }); }); describe("rebase", () => { it("rebases cleanly and offers an undo", async () => { g("switch", "-qc", "feat"); commit("f.txt", "f\n", "feat file"); g("switch", "-q", "main"); commit("m.txt", "m\n", "main file"); g("switch", "-q", "feat"); const plan = await rewritePlan(REPO, { kind: "rebase", onto: "main" }); assert.equal(plan.commits.length, 1); assert.equal(plan.behind, 1); const r = await runGitAction(REPO, "rebase", { onto: "main", head: plan.head }); assert.deepEqual(r.conflicts, []); assert.equal(r.before, plan.head); assert.deepEqual(subjects("HEAD"), ["feat file", "main file", "init"]); const u = await runGitAction(REPO, "undo-rewrite", { to: r.before, head: r.after, confirm: true, }); assert.equal(u.after, plan.head); }); it("stops on a conflict; other writes answer EMERGING; continue finishes it", async () => { divergent(); g("switch", "-q", "feat"); const r = await runGitAction(REPO, "rebase", { onto: "main", head: sha("HEAD") }); assert.deepEqual(r.conflicts, ["a.txt"]); const facts = await gitFacts(REPO); assert.equal(facts.operation, "rebase"); assert.equal(facts.conflicts, 1); for (const [action, body] of [ ["commit", { message: "x" }], ["switch", { branch: "main" }], ["merge", { branch: "main" }], ["reset", { target: sha("HEAD"), mode: "soft", head: sha("HEAD"), confirm: true }], ["rebase", { onto: "main", head: sha("HEAD") }], ["push", {}], // Shelves and the Branches & tags tools wait for the rebase too. ["shelve", { confirm: true }], ["unshelve", { sha: "abcd", confirm: true }], ["shelf-drop", { sha: "abcd", confirm: true }], ["tag-create", { tag: "v1" }], ["branch-rename", { branch: "feat", to: "feat2" }], ]) { await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" }, action); } await assert.rejects(runGitAction(REPO, "rebase-continue", {}), { code: "ECONFLICTS" }); const c = await readConflict(REPO, "a.txt"); assert.equal(c.operation, "rebase"); await runGitAction(REPO, "conflict-save", { path: "a.txt", content: "merged\n", baseSha: c.sha, }); const done = await runGitAction(REPO, "rebase-continue", {}); assert.equal(done.stopped, false); assert.equal((await gitFacts(REPO)).merging, false); assert.deepEqual(subjects("HEAD"), ["feat file", "feat edit", "main edit", "init"]); assert.equal(read("a.txt"), "merged\n"); }); it("skip drops the stopped commit; abort needs confirm and restores", async () => { divergent(); g("switch", "-q", "feat"); const before = sha("HEAD"); await runGitAction(REPO, "rebase", { onto: "main", head: before }); const s = await runGitAction(REPO, "rebase-skip", {}); assert.equal(s.stopped, false); assert.deepEqual(subjects("HEAD"), ["feat file", "main edit", "init"]); g("reset", "-q", "--hard", before); await runGitAction(REPO, "rebase", { onto: "main", head: before }); await assert.rejects(runGitAction(REPO, "rebase-abort", {}), { code: "ECONFIRM" }); // The banner's Abort (merge-abort) aborts a rebase too. await runGitAction(REPO, "merge-abort", { confirm: true }); assert.equal(sha("HEAD"), before); await assert.rejects(runGitAction(REPO, "rebase-continue", {}), { code: "ENOREBASE" }); }); it("refuses pushed commits without force, a dirty tree, a stale head and a bad ref", async () => { g("switch", "-qc", "feat"); commit("f.txt", "f\n", "feat file"); g("push", "-q", "-u", "origin", "feat"); g("switch", "-q", "main"); commit("m.txt", "m\n", "main file"); g("switch", "-q", "feat"); const head = sha("HEAD"); await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head }), { code: "EPUSHED", }); await assert.rejects(runGitAction(REPO, "rebase", { onto: "nope", head }), { code: "ENOBRANCH", }); await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head: "0".repeat(40) }), { code: "ESTALE", }); write("f.txt", "dirty\n"); await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head, force: true }), { code: "EDIRTY", }); g("checkout", "--", "f.txt"); const r = await runGitAction(REPO, "rebase", { onto: "main", head, force: true }); assert.equal(r.needsForcePush, true); }); it("refuses a subdirectory lane", async () => { fs.mkdirSync(path.join(REPO, "sub")); commit("sub/s.txt", "s\n", "sub"); await assert.rejects( runGitAction(path.join(REPO, "sub"), "rebase", { onto: "main", head: sha("HEAD") }), { code: "ESUBDIR" } ); }); }); describe("interactive rebase", () => { beforeEach(() => { commit("1.txt", "1\n", "one"); commit("2.txt", "2\n", "two"); commit("3.txt", "3\n", "three"); commit("4.txt", "4\n", "four"); }); it("plans only the unpushed commits by default", async () => { const plan = await rewritePlan(REPO, { kind: "interactive" }); assert.deepEqual( plan.commits.map((c) => c.subject), ["one", "two", "three", "four"] ); assert.equal(plan.pushed, 0); assert.equal(plan.base, sha("origin/main")); }); it("squashes, rewords, drops and reorders without an editor", async () => { const plan = await rewritePlan(REPO, { kind: "interactive" }); const [one, two, three, four] = plan.commits; const r = await runGitAction(REPO, "rebase-interactive", { head: plan.head, confirm: true, todo: [ { sha: four.sha, action: "reword", message: "four, first now\n\nwith a body; $(rm -rf x) 'q'", }, { sha: one.sha, action: "pick" }, { sha: two.sha, action: "squash" }, { sha: three.sha, action: "drop" }, ], }); assert.deepEqual(r.conflicts, []); assert.equal(r.stopped, false); assert.deepEqual(subjects("HEAD"), ["one", "four, first now", "init"]); assert.equal(g("log", "-1", "--format=%B", "HEAD").trim(), "one\n\ntwo"); assert.match(g("log", "-1", "--format=%B", "HEAD~1"), /\$\(rm -rf x\) 'q'/); assert.equal(fs.existsSync(path.join(REPO, "3.txt")), false); assert.equal(fs.existsSync(path.join(REPO, "2.txt")), true); // No scratch file is left in the git dir. assert.deepEqual( fs.readdirSync(path.join(REPO, ".git")).filter((n) => n.startsWith("agenthub-rebase-")), [] ); const u = await runGitAction(REPO, "undo-rewrite", { to: r.before, head: r.after, confirm: true, }); assert.equal(u.after, plan.head); }); it("squash with a message and fixup keep exactly what was asked", async () => { const plan = await rewritePlan(REPO, { kind: "interactive" }); const [one, two, three, four] = plan.commits; await runGitAction(REPO, "rebase-interactive", { head: plan.head, confirm: true, todo: [ { sha: one.sha, action: "pick" }, { sha: two.sha, action: "fixup" }, { sha: three.sha, action: "squash", message: "one to three" }, { sha: four.sha, action: "pick" }, ], }); assert.deepEqual(subjects("HEAD"), ["four", "one to three", "init"]); }); it("refuses bad todos, no change, pushed commits and needs confirm", async () => { const plan = await rewritePlan(REPO, { kind: "interactive" }); const shas = plan.commits.map((c) => c.sha); const base = { head: plan.head, confirm: true }; const picks = shas.map((s) => ({ sha: s, action: "pick" })); await assert.rejects( runGitAction(REPO, "rebase-interactive", { ...base, confirm: false, todo: picks }), { code: "ECONFIRM", } ); await assert.rejects(runGitAction(REPO, "rebase-interactive", { ...base, todo: picks }), { code: "ENOCHANGES", }); await assert.rejects( runGitAction(REPO, "rebase-interactive", { ...base, todo: picks.slice(1) }), { code: "EBADTODO" } ); await assert.rejects( runGitAction(REPO, "rebase-interactive", { ...base, todo: [{ sha: shas[0], action: "squash" }, ...picks.slice(1)], }), { code: "EBADTODO" } ); await assert.rejects( runGitAction(REPO, "rebase-interactive", { ...base, todo: [{ sha: shas[0], action: "exec rm" }, ...picks.slice(1)], }), { code: "EBADTODO" } ); await assert.rejects( runGitAction(REPO, "rebase-interactive", { ...base, todo: [{ sha: shas[0], action: "reword" }, ...picks.slice(1)], }), { code: "EBADMESSAGE" } ); // Push two of them: a base before them is now EPUSHED without force. g("push", "-q", "origin", `${shas[1]}:refs/heads/main`); const wide = await rewritePlan(REPO, { kind: "interactive", base: sha("main~4") }); assert.equal(wide.pushed, 2); const reversed = [...wide.commits].reverse().map((c) => ({ sha: c.sha, action: "pick" })); await assert.rejects( runGitAction(REPO, "rebase-interactive", { ...base, base: wide.base, todo: reversed }), { code: "EPUSHED" } ); // The default base now starts after the pushed ones. const narrow = await rewritePlan(REPO, { kind: "interactive" }); assert.deepEqual( narrow.commits.map((c) => c.subject), ["three", "four"] ); }); }); describe("reset", () => { beforeEach(() => { commit("1.txt", "1\n", "one"); commit("2.txt", "2\n", "two"); }); const reset = (mode, extra = {}) => runGitAction(REPO, "reset", { target: sha("HEAD~2"), mode, head: sha("HEAD"), confirm: true, ...extra, }); it("soft keeps the changes staged, mixed unstaged; undo puts the branch back", async () => { const plan = await rewritePlan(REPO, { kind: "reset", target: sha("HEAD~2") }); assert.equal(plan.dropped, 2); assert.equal(plan.pushed, 0); assert.equal(plan.related, true); const r = await reset("soft"); assert.match(g("status", "--porcelain"), /^A {2}1\.txt/m); const u = await runGitAction(REPO, "undo-rewrite", { to: r.before, head: r.after, confirm: true, }); assert.equal(u.after, r.before); await reset("mixed"); assert.match(g("status", "--porcelain"), /^\?\? 1\.txt/m); }); it("keep refuses to overwrite a local change; hard needs a clean tree and the branch name", async () => { write("2.txt", "local\n"); await assert.rejects( reset("keep"), (e) => typeof e.code === "number" || e.code === 128 || !!e.git ); assert.equal(read("2.txt"), "local\n"); await assert.rejects(reset("hard", { confirmBranch: "main" }), { code: "EDIRTY" }); g("checkout", "--", "2.txt"); await assert.rejects(reset("hard"), { code: "ECONFIRM" }); await assert.rejects(reset("hard", { confirmBranch: "mian" }), { code: "ECONFIRM" }); const r = await reset("hard", { confirmBranch: "main" }); assert.equal(r.mode, "hard"); assert.equal(fs.existsSync(path.join(REPO, "1.txt")), false); }); it("refuses pushed commits without force, an unrelated target, a bad mode, no confirm", async () => { g("push", "-q", "origin", "main"); await assert.rejects(reset("soft"), { code: "EPUSHED" }); const r = await reset("soft", { force: true }); assert.equal(r.needsForcePush, true); g("reset", "-q", "--hard", r.before); await assert.rejects(reset("bogus"), { code: "EBADMODE" }); await assert.rejects(reset("soft", { confirm: false }), { code: "ECONFIRM" }); g("switch", "-q", "--orphan", "lonely"); commit("z.txt", "z\n", "lonely"); await assert.rejects( runGitAction(REPO, "reset", { target: sha("main"), mode: "soft", head: sha("HEAD"), confirm: true, }), { code: "ENOTRELATED" } ); }); }); describe("force push and remote branches", () => { function pushedFeat() { g("switch", "-qc", "feat"); commit("f.txt", "1\n", "f1"); g("push", "-q", "-u", "origin", "feat"); // Rewrite it locally so a plain push is rejected. g("commit", "-q", "--amend", "-m", "f1 amended"); } it("force-pushes with a lease on the sha the plan showed", async () => { pushedFeat(); const plan = await rewritePlan(REPO, { kind: "push" }); assert.equal(plan.remote, "origin"); assert.equal(plan.remoteBranch, "feat"); assert.equal(plan.remoteSha, sha("origin/feat")); assert.equal(plan.allowed, true); await assert.rejects(runGitAction(REPO, "force-push", { expect: plan.remoteSha }), { code: "ECONFIRM", }); await runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true }); assert.equal(gIn(BARE, "rev-parse", "feat").trim(), sha("HEAD")); }); it("answers ESTALE when the remote moved", async () => { pushedFeat(); const expect = sha("origin/feat"); gIn(ROOT, "clone", "-q", "-b", "feat", BARE, OTHER); gIn( OTHER, "-c", "user.email=o@h", "-c", "user.name=o", "commit", "-q", "--allow-empty", "-m", "other" ); gIn(OTHER, "push", "-q", "origin", "feat"); g("fetch", "-q"); await assert.rejects(runGitAction(REPO, "force-push", { expect, confirm: true }), { code: "ESTALE", }); }); it("refuses a protected branch unless the server allows it", async () => { commit("x.txt", "x\n", "x"); const plan = await rewritePlan(REPO, { kind: "push" }); assert.equal(plan.protected, true); assert.equal(plan.allowed, false); await assert.rejects( runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true }), { code: "EPROTECTED" } ); process.env.AGENTHUB_GIT_FORCE_PUSH_PROTECTED = "1"; try { await runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true }); } finally { delete process.env.AGENTHUB_GIT_FORCE_PUSH_PROTECTED; } assert.equal(gIn(BARE, "rev-parse", "main").trim(), sha("HEAD")); }); it("deletes a remote branch but never the default or a protected one", async () => { g("push", "-q", "origin", "main:refs/heads/topic", "main:refs/heads/develop"); g("remote", "set-head", "origin", "main"); g("fetch", "-q"); await assert.rejects(runGitAction(REPO, "remote-branch-delete", { branch: "origin/topic" }), { code: "ECONFIRM", }); await assert.rejects( runGitAction(REPO, "remote-branch-delete", { branch: "origin/develop", confirm: true }), { code: "EPROTECTED" } ); await assert.rejects( runGitAction(REPO, "remote-branch-delete", { branch: "origin/nope", confirm: true }), { code: "ENOBRANCH" } ); await assert.rejects( runGitAction(REPO, "remote-branch-delete", { branch: "--all", confirm: true }), { code: "EBADBRANCH" } ); // A non-protected default branch is refused too. g("push", "-q", "origin", "main:refs/heads/trunk"); gIn(BARE, "symbolic-ref", "HEAD", "refs/heads/trunk"); g("remote", "set-head", "origin", "trunk"); g("fetch", "-q"); await assert.rejects( runGitAction(REPO, "remote-branch-delete", { branch: "origin/trunk", confirm: true }), { code: "EDEFAULTBRANCH" } ); await runGitAction(REPO, "remote-branch-delete", { branch: "origin/topic", confirm: true }); assert.throws(() => gIn(BARE, "rev-parse", "--verify", "-q", "topic")); }); }); describe("remotes", () => { it("adds, renames and removes with the URL allowlist", async () => { for (const url of [ "file:///tmp/x", "ext::sh -c touch% /tmp/pwn", "-xfoo", "/tmp/local", "http://h/x", "https://u:p@h/x", ]) { await assert.rejects(runGitAction(REPO, "remote-add", { name: "up", url }), { code: "EBADURL", }); } for (const name of ["-x", "a b", "", "x..y", "x.lock"]) { await assert.rejects( runGitAction(REPO, "remote-add", { name, url: "https://example.com/r.git" }), { code: "EBADREMOTE" } ); } await assert.rejects( runGitAction(REPO, "remote-add", { name: "origin", url: "https://example.com/r.git" }), { code: "EREMOTEEXISTS" } ); await runGitAction(REPO, "remote-add", { name: "up", url: "git@example.com:o/r.git" }); await runGitAction(REPO, "remote-rename", { name: "up", to: "upstream" }); await assert.rejects(runGitAction(REPO, "remote-rename", { name: "upstream", to: "origin" }), { code: "EREMOTEEXISTS", }); let list = await listRemotes(REPO); assert.deepEqual( list.map((r) => r.name), ["origin", "upstream"] ); await assert.rejects(runGitAction(REPO, "remote-remove", { name: "upstream" }), { code: "ECONFIRM", }); await runGitAction(REPO, "remote-remove", { name: "upstream", confirm: true }); list = await listRemotes(REPO); assert.deepEqual( list.map((r) => r.name), ["origin"] ); await assert.rejects(runGitAction(REPO, "remote-remove", { name: "gone", confirm: true }), { code: "EBADREMOTE", }); }); it("never lists a password", () => { assert.equal(redactUrl("https://bob:tok@h.com/x.git"), "https://h.com/x.git"); assert.equal(redactUrl("https://tok@h.com/x.git"), "https://h.com/x.git"); assert.equal(redactUrl("ssh://git:pw@h.com/x.git"), "ssh://git@h.com/x.git"); assert.equal(redactUrl("git@h.com:x.git"), "git@h.com:x.git"); }); });