diff --git a/server/__tests__/lane-sync.test.js b/server/__tests__/lane-sync.test.js new file mode 100644 index 0000000..7dc3429 --- /dev/null +++ b/server/__tests__/lane-sync.test.js @@ -0,0 +1,284 @@ +/** + * @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 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); + }); +}); diff --git a/server/lib/lane-sync.js b/server/lib/lane-sync.js new file mode 100644 index 0000000..1869f94 --- /dev/null +++ b/server/lib/lane-sync.js @@ -0,0 +1,316 @@ +/** + * @file The dev-based-flow safety primitive: the ONE sanctioned merge in the + * ship-feature-lane pipeline, origin/development INTO a lane's feature + * branch, gated by a migration-number collision preflight. Port of + * Shipyard's lane-sync-dev.sh. Pure git — every operation goes through + * worktree.js's git() execFile wrapper, never a shell string. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { git } = require("./worktree"); +const { runHook } = require("./lane-profile"); + +/** The PR base branch. Hardcoded — the whole ship-feature-lane pipeline + * already hardcodes this name throughout SKILL.md; a configurable version + * would be scope this task doesn't need. */ +const INTEGRATION_BRANCH = "development"; + +function badBranch(message) { + return Object.assign(new Error(message), { code: "EBADBRANCH" }); +} + +/** The branch to operate on: the caller's explicit choice, or the lane's + * current HEAD when omitted (mirrors the source script's own fallback). */ +async function resolveBranch(cwd, branch) { + if (branch) return branch; + const result = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]); + return result.stdout.trim(); +} + +function assertFeatureBranch(branch) { + if (branch === INTEGRATION_BRANCH || branch === "main") { + throw badBranch( + `branch is '${branch}' — sync-base works on a feature branch (pass it explicitly)` + ); + } +} + +async function assertBranchExists(cwd, branch) { + try { + await git(cwd, ["rev-parse", "--verify", "--quiet", branch]); + } catch { + throw badBranch(`feature branch '${branch}' not found`); + } +} + +/** + * Migration-number collision guard: two lanes independently add NNN_* files + * with the same number under MIGRATIONS_DIR — git merges both without + * conflict, and the collision only surfaces as red CI on development AFTER a + * human merges the PR. Detected from refs alone, before anything is merged. + */ +async function collisionCheck(cwd, migrationsDir, branch) { + if (!migrationsDir) return []; + + const addedResult = await git(cwd, [ + "diff", + "--name-only", + "--diff-filter=A", + `origin/${INTEGRATION_BRANCH}...${branch}`, + "--", + migrationsDir, + ]); + const added = addedResult.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (!added.length) return []; + + const treeResult = await git(cwd, [ + "ls-tree", + "-r", + "--name-only", + `origin/${INTEGRATION_BRANCH}`, + "--", + migrationsDir, + ]); + const devMigrations = treeResult.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => /\/\d+_[^/]+$/.test(line)); + + let maxNum = 0; + for (const file of devMigrations) { + const m = /\/(\d+)_[^/]+$/.exec(file); + if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10)); + } + let nextNum = maxNum + 1; + + const collisions = []; + for (const file of added) { + const base = path.basename(file); + const m = /^(\d+)_/.exec(base); + if (!m) continue; + const num = m[1]; + const clash = devMigrations.find((f) => f.includes(`/${num}_`)); + if (!clash) continue; + const suggestion = `${String(nextNum).padStart(3, "0")}_${base.replace(/^\d+_/, "")}`; + collisions.push({ file, collidesWith: clash, suggestion }); + nextNum += 1; + } + return collisions; +} + +/** What moved on origin/development since branch's merge-base, and whether + * that delta touches branch's own changed files. Informational. */ +async function devDeltaReport(cwd, branch, generatedPaths) { + let mergeBase = ""; + try { + const result = await git(cwd, ["merge-base", `origin/${INTEGRATION_BRANCH}`, branch]); + mergeBase = result.stdout.trim(); + } catch { + mergeBase = ""; + } + if (!mergeBase) return { devDelta: null, overlap: null }; + + const filterGenerated = (files) => + generatedPaths.length ? files.filter((f) => !generatedPaths.includes(f)) : files; + const namesOnly = (stdout) => + stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const devDiff = await git(cwd, [ + "diff", + "--name-only", + mergeBase, + `origin/${INTEGRATION_BRANCH}`, + ]); + const delta = filterGenerated(namesOnly(devDiff.stdout)); + + const featDiff = await git(cwd, ["diff", "--name-only", mergeBase, branch]); + const featFiles = new Set(filterGenerated(namesOnly(featDiff.stdout))); + + const overlap = delta.filter((f) => featFiles.has(f)); + return { devDelta: delta, overlap }; +} + +/** Read-only preflight: fetch + collision check + dev-delta report. Merges + * nothing. */ +async function checkSync(lane, profile, branchArg) { + const branch = await resolveBranch(lane.cwd, branchArg); + assertFeatureBranch(branch); + await assertBranchExists(lane.cwd, branch); + await git(lane.cwd, ["fetch", "origin", "--prune"]); + + const collisions = await collisionCheck(lane.cwd, profile.env.MIGRATIONS_DIR, branch); + if (collisions.length) return { code: 5, collisions }; + + const { devDelta, overlap } = await devDeltaReport(lane.cwd, branch, profile.generatedMergePaths); + return { code: 0, devDelta, overlap }; +} + +/** The worktree-private git dir (HEAD, index, MERGE_HEAD live here — distinct + * from the shared common dir below). Resolved fresh each call: cheap, and a + * cached value would go stale the moment a lane's slot/worktree changes. */ +async function gitDir(cwd) { + const result = await git(cwd, ["rev-parse", "--git-dir"]); + const dir = result.stdout.trim(); + return path.isAbsolute(dir) ? dir : path.join(cwd, dir); +} + +/** The dir shared across every worktree of a repo — where info/attributes + * and git config live. For a plain (non-worktree) clone this is the same + * as gitDir(); for a `git worktree add` lane it is the source repo's own + * .git, so the merge driver is configured once per repository, not once + * per lane. */ +async function commonGitDir(cwd) { + const result = await git(cwd, ["rev-parse", "--git-common-dir"]); + const dir = result.stdout.trim(); + return path.isAbsolute(dir) ? dir : path.join(cwd, dir); +} + +async function unmergedFiles(cwd) { + const result = await git(cwd, ["ls-files", "-u"]); + const files = new Set(); + for (const line of result.stdout.split("\n")) { + const tab = line.indexOf("\t"); + if (tab > -1) files.add(line.slice(tab + 1)); + } + return [...files]; +} + +/** Generated artifacts (an OpenAPI contract, its generated client, ...) must + * never be hand-merged: a keep-ours driver (`true` exits 0 -> keep our + * side, no conflict) via the clone-local attributes file, idempotent every + * call — same "idempotent, never automatic" shape this repo's proof-link + * already established. */ +async function setupMergeDriver(cwd, generatedPaths) { + if (!generatedPaths.length) return; + await git(cwd, ["config", "merge.ccam-generated.driver", "true"]); + await git(cwd, [ + "config", + "merge.ccam-generated.name", + "keep ours; regenerated post-merge by the profile regen hook", + ]); + + const infoDir = path.join(await commonGitDir(cwd), "info"); + fs.mkdirSync(infoDir, { recursive: true }); + const attrPath = path.join(infoDir, "attributes"); + const existing = fs.existsSync(attrPath) ? fs.readFileSync(attrPath, "utf8") : ""; + const lines = new Set(existing.split("\n").filter(Boolean)); + let changed = false; + for (const gp of generatedPaths) { + const line = `${gp} merge=ccam-generated`; + if (!lines.has(line)) { + lines.add(line); + changed = true; + } + } + if (changed) fs.writeFileSync(attrPath, [...lines].join("\n") + "\n"); +} + +/** Regenerate generated artifacts from the just-synced tree and fold them + * into the merge commit (or, on the --continue path, a follow-up commit). + * A no-op when nothing changed. */ +async function regenFold(lane, profile, generatedPaths) { + if (!generatedPaths.length || !profile.hooks.has("regen")) return; + await runHook(lane, profile, "regen", []); + try { + await git(lane.cwd, ["add", "--", ...generatedPaths]); + } catch { + // A generated path that doesn't exist yet on this branch is fine — + // nothing to stage for it. + } + const staged = await git(lane.cwd, ["diff", "--cached", "--name-only"]); + if (!staged.stdout.trim()) return; + + let isMergeCommit = true; + try { + await git(lane.cwd, ["rev-parse", "-q", "--verify", "HEAD^2"]); + } catch { + isMergeCommit = false; + } + if (isMergeCommit) { + await git(lane.cwd, ["commit", "--amend", "--no-edit"]); + } else { + await git(lane.cwd, ["commit", "-m", "chore: regenerate artifacts after dev sync"]); + } +} + +/** The one sanctioned merge: origin/development INTO the feature branch. */ +async function mergeSync(lane, profile, branchArg) { + const branch = await resolveBranch(lane.cwd, branchArg); + assertFeatureBranch(branch); + await assertBranchExists(lane.cwd, branch); + await git(lane.cwd, ["fetch", "origin", "--prune"]); + + const generatedPaths = profile.generatedMergePaths; + await setupMergeDriver(lane.cwd, generatedPaths); + + const collisions = await collisionCheck(lane.cwd, profile.env.MIGRATIONS_DIR, branch); + if (collisions.length) return { code: 5, collisions }; + + await git(lane.cwd, ["checkout", "--quiet", branch]); + + try { + await git(lane.cwd, ["merge", "--no-edit", `origin/${INTEGRATION_BRANCH}`]); + } catch (err) { + const conflicted = await unmergedFiles(lane.cwd); + const mergeHeadPath = path.join(await gitDir(lane.cwd), "MERGE_HEAD"); + if (conflicted.length && fs.existsSync(mergeHeadPath)) { + return { code: 4, conflictedFiles: conflicted }; + } + throw err; + } + + // rerere may have auto-resolved every conflict but left the merge + // uncommitted — finish it. + const mergeHeadPath = path.join(await gitDir(lane.cwd), "MERGE_HEAD"); + if (fs.existsSync(mergeHeadPath) && !(await unmergedFiles(lane.cwd)).length) { + await git(lane.cwd, ["commit", "--no-edit"]); + } + + await regenFold(lane, profile, generatedPaths); + return { code: 0 }; +} + +/** Finish a sync after the session resolved a conflicted merge and + * committed it. Stateless — reads the lane's own git state directly rather + * than trusting a separate flag, so it can never disagree with reality. */ +async function continueSync(lane, profile, branchArg) { + const branch = await resolveBranch(lane.cwd, branchArg); + + const current = (await git(lane.cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim(); + if (current !== branch) { + throw badBranch(`--continue: lane is not on '${branch}' (currently on '${current}')`); + } + + const unresolved = await unmergedFiles(lane.cwd); + if (unresolved.length) { + throw Object.assign( + new Error(`--continue: unresolved conflicts remain: ${unresolved.join(", ")}`), + { code: "EUNRESOLVED" } + ); + } + + const mergeHeadPath = path.join(await gitDir(lane.cwd), "MERGE_HEAD"); + if (fs.existsSync(mergeHeadPath)) { + throw Object.assign(new Error("--continue: merge not committed yet — git commit --no-edit"), { + code: "EMERGEUNCOMMITTED", + }); + } + + await regenFold(lane, profile, profile.generatedMergePaths); + return { code: 0 }; +} + +module.exports = { + INTEGRATION_BRANCH, + checkSync, + mergeSync, + continueSync, +};