/** * @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ĩ */ 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); }); });