1da46f10f4
Opening a conflicted file in the Changes panel now shows each conflict block as its two sides, labelled from git's markers and tinted apart, with Keep mine / Take theirs / Keep both per block (undoable, ^/v to jump). Save and mark resolved writes the chosen text through the whole-file editor's PUT /git/file (baseSha stale check) then runs resolve done. The Merge conflicts group's buttons are text, and a conflicted file is marked with a red ! in the Files tree. Fix: ls-files --cached lists an unmerged path once per stage, so the tree showed a conflicted file three times; lsFiles now dedupes.
147 lines
5.7 KiB
JavaScript
147 lines
5.7 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 { 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("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);
|
|
});
|
|
});
|