Files
AgentHub/server/__tests__/git-ops-merge.test.js
T
nntrivi2001 f514602697 feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
2026-09-25 10:44:40 +07:00

137 lines
5.3 KiB
JavaScript

/**
* @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ĩ <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 { gitFacts } = require("../lib/worktree");
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("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("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);
});
});