/** * @file Issue #45 part B: merge and conflict resolution from the dashboard, * against a REAL repo whose `main` and `feat` edit the same line. Pins the * guards (clean tree, repo-root lane, a ref that exists, one merge at a time, * a path that IS conflicted, markers gone before "resolved", confirm on * abort) and the happy paths (ours, theirs, hand-edited, commit, abort). * @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 { listTree, listPaths } = require("../lib/git-diff"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-merge-")); const REPO = path.join(ROOT, "repo"); 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"); beforeEach(() => { fs.rmSync(REPO, { recursive: true, force: true }); fs.mkdirSync(REPO, { recursive: true }); g("init", "-q", "-b", "main"); g("config", "user.email", "t@h"); g("config", "user.name", "t"); fs.mkdirSync(path.join(REPO, "sub")); 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"); write("b.txt", "only on feat\n"); g("add", "-A"); g("commit", "-qm", "feat"); g("switch", "-q", "main"); write("a.txt", "ours\n"); g("commit", "-qam", "main"); }); const merge = () => runGitAction(REPO, "merge", { branch: "feat" }); describe("merge", () => { it("reports conflicts instead of failing, and gitFacts shows the merge", async () => { const r = await merge(); assert.equal(r.ok, true); assert.deepEqual(r.conflicts, ["a.txt"]); const facts = await gitFacts(REPO); assert.equal(facts.merging, true); assert.equal(facts.conflicts, 1); }); it("merges cleanly when there is nothing to resolve", async () => { write("a.txt", "theirs\n"); g("commit", "-qam", "agree"); const r = await merge(); assert.deepEqual(r.conflicts, []); assert.equal(read("b.txt"), "only on feat\n"); assert.equal((await gitFacts(REPO)).merging, false); }); it("refuses a dirty tree, an unknown ref, a subdirectory lane and a second merge", async () => { write("a.txt", "wip\n"); await assert.rejects(merge(), { code: "EDIRTY" }); g("checkout", "--", "a.txt"); await assert.rejects(runGitAction(REPO, "merge", { branch: "nope" }), { code: "ENOBRANCH" }); await assert.rejects(runGitAction(REPO, "merge", { branch: "--help" }), { code: "EBADBRANCH" }); await assert.rejects(runGitAction(path.join(REPO, "sub"), "merge", { branch: "feat" }), { code: "ESUBDIR", }); await merge(); await assert.rejects(merge(), { code: "EMERGING" }); await assert.rejects(runGitAction(REPO, "pull"), { code: "EMERGING" }); }); }); describe("listing files mid-merge", () => { it("lists a conflicted file once, not once per merge stage", async () => { await merge(); const root = await listTree(REPO, {}); assert.equal(root.entries.filter((e) => e.name === "a.txt").length, 1); assert.deepEqual((await listPaths(REPO, { q: "a.txt" })).paths, ["a.txt"]); }); }); describe("resolve", () => { it("takes our side or theirs, and only for a conflicted path", async () => { await merge(); await assert.rejects(runGitAction(REPO, "resolve", { path: "b.txt", side: "ours" }), { code: "EBADPATH", }); await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "mine" }), { code: "EBADSIDE", }); await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" }); assert.equal(read("a.txt"), "theirs\n"); assert.equal((await gitFacts(REPO)).conflicts, 0); }); it("marks a hand-edited file resolved only once its markers are gone", async () => { await merge(); await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "done" }), { code: "EMARKERS", }); await assert.rejects(runGitAction(REPO, "stage", { paths: ["a.txt"] }), { code: "EMARKERS" }); write("a.txt", "both\n"); await runGitAction(REPO, "resolve", { path: "a.txt", side: "done" }); assert.equal((await gitFacts(REPO)).conflicts, 0); }); }); describe("finish or abort", () => { it("commits the merge only when nothing is left unresolved", async () => { await merge(); await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" }); await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" }); await runGitAction(REPO, "merge-commit"); assert.equal((await gitFacts(REPO)).merging, false); assert.equal(g("rev-list", "--parents", "-n1", "HEAD").trim().split(" ").length, 3); assert.equal(read("a.txt"), "ours\n"); await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" }); }); it("names the merge after the branch, as git merge would — not its full refname", async () => { await merge(); await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" }); await runGitAction(REPO, "merge-commit"); assert.equal(g("log", "-1", "--format=%s").trim(), "Merge branch 'feat'"); // Into a branch other than main/master, git adds where it went. g("switch", "-qc", "side", "feat~1"); write("c.txt", "c\n"); g("add", "-A"); g("commit", "-qm", "side"); await runGitAction(REPO, "merge", { branch: "main" }); assert.equal(g("log", "-1", "--format=%s").trim(), "Merge branch 'main' into side"); }); it("aborts only with confirm, back to where it started", async () => { await merge(); 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(read("a.txt"), "ours\n"); assert.equal(fs.existsSync(path.join(REPO, "b.txt")), false); }); });