Files
Claude-Code-Monitor/server/__tests__/lane-sync.test.js
T
nntrivi2001 d2327408fb fix(lanes): mergeSync commits a fully rerere-auto-resolved merge instead of rethrowing (E2)
Root cause: the catch block only fell through to the auto-commit path when
`unmergedFiles().length && MERGE_HEAD exists` — but a merge rerere resolved
completely has ZERO unmerged files (git already staged the resolution), so
that guard was always false and the raw git error was rethrown instead.
Found via an audit against the Shipyard source this was ported from.

Fixed by checking MERGE_HEAD first (unconditionally — its absence means the
merge never started, a real failure), then branching on whether any files
are still unmerged. Added a real rerere fixture test (teach a resolution,
recreate the identical conflict, confirm mergeSync auto-commits) — the
existing test suite had no coverage for this path.
2026-08-05 14:39:02 +07:00

362 lines
15 KiB
JavaScript

/**
* @file Tests for server/lib/lane-sync.js against a REAL git fixture: a bare
* "origin", a lane clone, and a second clone acting as another lane that
* pushes to origin/development independently. Mirrors the fixture shape of
* Shipyard's own lane-sync-dev.sh test (test_sync_dev.sh) — collision
* detection, clean merges, and conflicts are git's own behavior, so a mocked
* git would only test our idea of git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-sync-"));
const laneSync = require("../lib/lane-sync");
const ORIGIN = path.join(ROOT, "origin.git");
const LANE_DIR = path.join(ROOT, "lane");
const PUSHER_DIR = path.join(ROOT, "pusher");
const g = (cwd, ...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
const gc = (cwd, ...args) => g(cwd, "-c", "user.email=t@h", "-c", "user.name=t", ...args);
function freshFixture() {
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(ROOT, { recursive: true });
g(ROOT, "init", "-q", "--bare", ORIGIN);
const seed = path.join(ROOT, "seed");
g(ROOT, "init", "-q", "-b", "development", seed);
fs.mkdirSync(path.join(seed, "db", "migrations"), { recursive: true });
fs.writeFileSync(path.join(seed, "db", "migrations", "001_init.sql"), "create table a;\n");
fs.writeFileSync(path.join(seed, "README.md"), "hello\n");
gc(seed, "add", "-A");
gc(seed, "commit", "-qm", "init");
gc(seed, "remote", "add", "origin", ORIGIN);
gc(seed, "push", "-q", "origin", "development");
g(ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
g(ROOT, "clone", "-q", ORIGIN, LANE_DIR);
g(ROOT, "clone", "-q", ORIGIN, PUSHER_DIR);
gc(LANE_DIR, "checkout", "-qb", "feat/thing");
}
function lane() {
return { cwd: LANE_DIR };
}
function profile(over = {}) {
return {
env: { MIGRATIONS_DIR: "db/migrations", GENERATED_MERGE_PATHS: "" },
generatedMergePaths: [],
hooks: new Set(),
...over,
};
}
before(freshFixture);
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("lane-sync guards", () => {
it("refuses to run on development or main", async () => {
await assert.rejects(
() => laneSync.checkSync(lane(), profile(), "development"),
(e) => e.code === "EBADBRANCH" && /feature branch/.test(e.message)
);
await assert.rejects(() => laneSync.mergeSync(lane(), profile(), "main"), {
code: "EBADBRANCH",
});
});
it("refuses a branch that doesn't exist", async () => {
await assert.rejects(() => laneSync.checkSync(lane(), profile(), "feat/nope"), {
code: "EBADBRANCH",
});
});
});
describe("lane-sync --check: migration collision", () => {
it("detects a collision and suggests the next free number", () => {
fs.writeFileSync(path.join(LANE_DIR, "db", "migrations", "002_a.sql"), "create table x;\n");
gc(LANE_DIR, "add", "-A");
gc(LANE_DIR, "commit", "-qm", "feat: add x");
fs.writeFileSync(path.join(PUSHER_DIR, "db", "migrations", "002_b.sql"), "create table y;\n");
gc(PUSHER_DIR, "add", "-A");
gc(PUSHER_DIR, "commit", "-qm", "other lane");
gc(PUSHER_DIR, "push", "-q", "origin", "development");
return laneSync.checkSync(lane(), profile(), "feat/thing").then((result) => {
assert.equal(result.code, 5);
assert.equal(result.collisions.length, 1);
assert.match(result.collisions[0].file, /002_a\.sql$/);
assert.match(result.collisions[0].suggestion, /^003_a\.sql$/);
});
});
it("passes clean after the renumber and reports the upstream delta", async () => {
gc(LANE_DIR, "mv", "db/migrations/002_a.sql", "db/migrations/003_a.sql");
gc(LANE_DIR, "commit", "-qm", "renumber migration");
const result = await laneSync.checkSync(lane(), profile(), "feat/thing");
assert.equal(result.code, 0);
assert.equal(result.devDelta.length, 1);
assert.match(result.devDelta[0], /002_b\.sql$/);
assert.deepEqual(result.overlap, []);
});
});
describe("lane-sync merge: clean merge lands upstream on the feature branch", () => {
it("merges origin/development into the feature branch as a merge commit", async () => {
const result = await laneSync.mergeSync(lane(), profile(), "feat/thing");
assert.equal(result.code, 0);
assert.equal(g(LANE_DIR, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/thing");
assert.doesNotThrow(() => g(LANE_DIR, "rev-parse", "-q", "--verify", "HEAD^2"));
assert.ok(fs.existsSync(path.join(LANE_DIR, "db", "migrations", "002_b.sql")));
});
});
describe("lane-sync merge: generated-file merge driver + regen fold-in", () => {
const GEN_DIR = path.join(ROOT, "gen-fixture");
const GEN_ORIGIN = path.join(ROOT, "gen-origin.git");
const GEN_LANE = path.join(ROOT, "gen-lane");
const GEN_PUSHER = path.join(ROOT, "gen-pusher");
before(() => {
fs.mkdirSync(GEN_DIR, { recursive: true });
g(GEN_DIR, "init", "-q", "--bare", GEN_ORIGIN);
const seed = path.join(GEN_DIR, "seed");
g(GEN_DIR, "init", "-q", "-b", "development", seed);
fs.mkdirSync(path.join(seed, "api"), { recursive: true });
fs.writeFileSync(path.join(seed, "api", "openapi.json"), '{"v":1}\n');
gc(seed, "add", "-A");
gc(seed, "commit", "-qm", "init");
gc(seed, "remote", "add", "origin", GEN_ORIGIN);
gc(seed, "push", "-q", "origin", "development");
g(GEN_ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
g(GEN_DIR, "clone", "-q", GEN_ORIGIN, GEN_LANE);
g(GEN_DIR, "clone", "-q", GEN_ORIGIN, GEN_PUSHER);
gc(GEN_LANE, "checkout", "-qb", "feat/gen");
// The lane's own change to the generated file (would conflict without
// the keep-ours driver).
fs.writeFileSync(path.join(GEN_LANE, "api", "openapi.json"), '{"v":2,"branch":"feat"}\n');
gc(GEN_LANE, "add", "-A");
gc(GEN_LANE, "commit", "-qm", "feat: touches the contract");
// Upstream's own change to the same generated file.
fs.writeFileSync(path.join(GEN_PUSHER, "api", "openapi.json"), '{"v":2,"branch":"dev"}\n');
gc(GEN_PUSHER, "add", "-A");
gc(GEN_PUSHER, "commit", "-qm", "dev: also touches the contract");
gc(GEN_PUSHER, "push", "-q", "origin", "development");
// A regen hook the fold-in step will run.
const profileDir = path.join(GEN_LANE, ".ccam", "profile");
fs.mkdirSync(path.join(profileDir, "hooks"), { recursive: true });
fs.writeFileSync(path.join(profileDir, "profile.env"), "PORTS=api\n");
fs.writeFileSync(
path.join(profileDir, "hooks", "regen.sh"),
'#!/usr/bin/env bash\nset -euo pipefail\necho \'{"v":3,"regenerated":true}\' > "$LANE_DIR/api/openapi.json"\n'
);
fs.chmodSync(path.join(profileDir, "hooks", "regen.sh"), 0o755);
});
function genProfile() {
return {
env: { MIGRATIONS_DIR: "", GENERATED_MERGE_PATHS: "api/openapi.json" },
generatedMergePaths: ["api/openapi.json"],
hooks: new Set(["regen"]),
dir: path.join(GEN_LANE, ".ccam", "profile"),
};
}
it("installs a keep-ours driver so the generated file never conflicts, then folds regen output into the merge commit", async () => {
const result = await laneSync.mergeSync(
{ cwd: GEN_LANE, slot: 999, id: 999 },
genProfile(),
"feat/gen"
);
assert.equal(result.code, 0);
const contents = fs.readFileSync(path.join(GEN_LANE, "api", "openapi.json"), "utf8");
assert.match(contents, /"regenerated":true/);
// The regen output landed IN the merge commit, not a separate one.
assert.doesNotThrow(() => g(GEN_LANE, "rev-parse", "-q", "--verify", "HEAD^2"));
const parents = g(GEN_LANE, "log", "-1", "--format=%P").trim().split(" ");
assert.equal(parents.length, 2);
});
});
describe("lane-sync merge: conflict is left in place, --continue finishes it", () => {
it("exits with code 4 and leaves MERGE_HEAD in place on a real conflict", async () => {
fs.writeFileSync(path.join(LANE_DIR, "README.md"), "feature words\n");
gc(LANE_DIR, "add", "-A");
gc(LANE_DIR, "commit", "-qm", "feat: readme");
fs.writeFileSync(path.join(PUSHER_DIR, "README.md"), "upstream words\n");
gc(PUSHER_DIR, "add", "-A");
gc(PUSHER_DIR, "commit", "-qm", "other readme");
gc(PUSHER_DIR, "push", "-q", "origin", "development");
const result = await laneSync.mergeSync(lane(), profile(), "feat/thing");
assert.equal(result.code, 4);
assert.deepEqual(result.conflictedFiles, ["README.md"]);
assert.ok(fs.existsSync(path.join(LANE_DIR, ".git", "MERGE_HEAD")));
});
it("--continue refuses while conflicts are unresolved", async () => {
await assert.rejects(() => laneSync.continueSync(lane(), profile(), "feat/thing"), {
code: "EUNRESOLVED",
});
});
it("--continue refuses while the merge is resolved but not committed", async () => {
fs.writeFileSync(path.join(LANE_DIR, "README.md"), "merged words\n");
gc(LANE_DIR, "add", "README.md");
await assert.rejects(() => laneSync.continueSync(lane(), profile(), "feat/thing"), {
code: "EMERGEUNCOMMITTED",
});
});
it("--continue finishes after the conflict is resolved and committed", async () => {
gc(LANE_DIR, "commit", "-q", "--no-edit");
const result = await laneSync.continueSync(lane(), profile(), "feat/thing");
assert.equal(result.code, 0);
assert.equal(g(LANE_DIR, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/thing");
});
});
describe("lane-sync merge: rerere auto-resolves a previously-seen conflict", () => {
// Bug this guards: mergeSync's catch block used to check
// `conflicted.length && MERGE_HEAD exists` before falling through to the
// rerere-commit path — but a fully rerere-auto-resolved merge has ZERO
// unmerged files (git already staged the resolution), so that condition
// was always false and the code rethrew the raw git error instead of
// committing. Fixed by checking MERGE_HEAD first, unconditionally.
const RR_ROOT = path.join(ROOT, "rerere-fixture");
const RR_ORIGIN = path.join(RR_ROOT, "origin.git");
const RR_LANE = path.join(RR_ROOT, "lane");
const RR_PUSHER = path.join(RR_ROOT, "pusher");
before(() => {
fs.mkdirSync(RR_ROOT, { recursive: true });
g(RR_ROOT, "init", "-q", "--bare", RR_ORIGIN);
const seed = path.join(RR_ROOT, "seed");
g(RR_ROOT, "init", "-q", "-b", "development", seed);
fs.writeFileSync(path.join(seed, "config.txt"), "base\n");
gc(seed, "add", "-A");
gc(seed, "commit", "-qm", "init");
gc(seed, "remote", "add", "origin", RR_ORIGIN);
gc(seed, "push", "-q", "origin", "development");
g(RR_ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
g(RR_ROOT, "clone", "-q", RR_ORIGIN, RR_LANE);
g(RR_ROOT, "clone", "-q", RR_ORIGIN, RR_PUSHER);
gc(RR_LANE, "config", "rerere.enabled", "true");
// autoUpdate is what stages a rerere-recognized resolution automatically —
// without it, git restores the resolved CONTENT but still leaves the file
// as "unmerged" (ls-files -u non-empty), so MERGE_HEAD + zero unmerged
// files (the exact condition the fixed code branches on) never occurs.
gc(RR_LANE, "config", "rerere.autoupdate", "true");
const baseSha = g(RR_LANE, "rev-parse", "development").trim();
// Upstream's side of the conflict — pushed once, applies to both rounds.
fs.writeFileSync(path.join(RR_PUSHER, "config.txt"), "dev version\n");
gc(RR_PUSHER, "add", "-A");
gc(RR_PUSHER, "commit", "-qm", "dev edits config");
gc(RR_PUSHER, "push", "-q", "origin", "development");
gc(RR_LANE, "fetch", "-q", "origin");
// Round 1 — teach rerere the resolution.
gc(RR_LANE, "checkout", "-qb", "feat/rerere-teach", baseSha);
fs.writeFileSync(path.join(RR_LANE, "config.txt"), "lane version\n");
gc(RR_LANE, "add", "-A");
gc(RR_LANE, "commit", "-qm", "lane edits config");
let conflicted = false;
try {
gc(RR_LANE, "merge", "--no-edit", "origin/development");
} catch {
conflicted = true;
}
if (!conflicted) throw new Error("fixture bug: expected the teach-round merge to conflict");
fs.writeFileSync(path.join(RR_LANE, "config.txt"), "resolved version\n");
gc(RR_LANE, "add", "config.txt");
gc(RR_LANE, "commit", "-q", "--no-edit");
// Round 2 — the actual test branch: an IDENTICAL edit to config.txt from
// the same base, so the conflict signature matches what rerere just
// learned and git auto-applies the recorded resolution during the merge
// this test's assertion drives.
gc(RR_LANE, "checkout", "-qb", "feat/rerere-actual", baseSha);
fs.writeFileSync(path.join(RR_LANE, "config.txt"), "lane version\n");
gc(RR_LANE, "add", "-A");
gc(RR_LANE, "commit", "-qm", "lane edits config (again)");
});
it("commits automatically instead of reporting a conflict", async () => {
const result = await laneSync.mergeSync({ cwd: RR_LANE }, profile(), "feat/rerere-actual");
assert.equal(result.code, 0);
assert.equal(fs.readFileSync(path.join(RR_LANE, "config.txt"), "utf8"), "resolved version\n");
assert.ok(!fs.existsSync(path.join(RR_LANE, ".git", "MERGE_HEAD")));
});
});
describe("lane-sync against a real git-worktree lane", () => {
it("resolves MERGE_HEAD and info/attributes correctly under git worktree add", async () => {
const wt = require("../lib/worktree");
const WT_ROOT = path.join(ROOT, "wt-fixture");
fs.mkdirSync(WT_ROOT, { recursive: true });
const src = path.join(WT_ROOT, "src");
g(WT_ROOT, "init", "-q", "-b", "development", src);
fs.writeFileSync(path.join(src, "README.md"), "hello\n");
gc(src, "add", "-A");
gc(src, "commit", "-qm", "init");
gc(src, "remote", "add", "origin", src); // self-origin: fetch is a same-repo no-op, good enough here
gc(src, "branch", "-f", "refs/remotes/origin/development", "development");
const wtDir = path.join(WT_ROOT, "wt-lane");
await wt.addWorktree({ sourceRepo: src, dir: wtDir, branch: "feat/wt", base: "development" });
// Simulate upstream moving, so devDeltaReport / collisionCheck have
// something to resolve against without a real remote. `src` is still on
// "development" here — addWorktree only checks out feat/wt in the NEW
// worktree dir; checking out feat/wt on src too would collide with the
// worktree (git refuses the same branch checked out twice).
fs.writeFileSync(path.join(src, "README.md"), "upstream change\n");
gc(src, "add", "-A");
gc(src, "commit", "-qm", "upstream");
gc(src, "branch", "-f", "refs/remotes/origin/development", "development");
const wtProfile = {
env: { MIGRATIONS_DIR: "", GENERATED_MERGE_PATHS: "" },
generatedMergePaths: [],
hooks: new Set(),
};
const result = await laneSync.checkSync({ cwd: wtDir }, wtProfile, "feat/wt");
assert.equal(result.code, 0);
assert.equal(result.devDelta.length, 1);
});
});