/** * @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 mergeHeadPath = path.join(await gitDir(lane.cwd), "MERGE_HEAD"); if (!fs.existsSync(mergeHeadPath)) throw err; // merge never started — a real git failure const conflicted = await unmergedFiles(lane.cwd); if (conflicted.length) { return { code: 4, conflictedFiles: conflicted }; } // rerere auto-resolved every conflict but left the merge uncommitted — finish it. 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, };