77d79a59d7
5 tasks: MIGRATIONS_DIR/GENERATED_MERGE_PATHS profile declarations, the lane-sync.js git core (check/merge/continue, ported against a real bare-origin fixture mirroring lane-sync-dev.sh's own test suite, plus a dedicated git-worktree fixture to catch the git-dir vs git-common-dir distinction MERGE_HEAD/info-attributes depend on), the sync-base route, the CLI subcommand, and the SKILL.md/docs edits that turn three "if it exists yet" conditionals into real instructions.
1199 lines
50 KiB
Markdown
1199 lines
50 KiB
Markdown
# E2 — `ccam lanes sync-base` Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** `ccam lanes sync-base` exists as a real, working command — the migration-collision preflight (`--check`), the one sanctioned merge (`origin/development` into a feature branch), and its conflict-resolution follow-up (`--continue`) — so `.claude/skills/ship-feature-lane/SKILL.md`'s Stage 2/8/12 references to it stop being "later task" placeholders.
|
||
|
||
**Architecture:** One new core module (`server/lib/lane-sync.js`, pure git operations via `worktree.js`'s existing `git()` helper), two new profile declarations (`MIGRATIONS_DIR`, `GENERATED_MERGE_PATHS`), one new synchronous route (`POST /:id/sync-base`), one new CLI subcommand, and a `SKILL.md` edit that turns three "if it exists yet" conditionals into real instructions.
|
||
|
||
**Tech Stack:** Existing git helper (`server/lib/worktree.js`'s `git(cwd, args)`, execFile-based), existing profile system (`server/lib/lane-profile.js`), existing hook runner (`runHook`, already used for `regen`), existing lane lock (`server/lib/lane-lock.js`).
|
||
|
||
## Global Constraints
|
||
|
||
- Every applicable source file MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`.
|
||
- **Never build a git command as a shell string.** Every git call in `lane-sync.js` goes through `worktree.js`'s `git(cwd, args)` (execFile with an argument array). This is a binding project rule, not a style preference.
|
||
- **`sync-base` never writes a lane's `stage`, `status`, or `notes`.** It returns a structured result; the caller (the skill, or a human via the CLI) decides what a collision or conflict means for the lane's declared stage.
|
||
- `MIGRATIONS_DIR` and `GENERATED_MERGE_PATHS` are off-by-default declarations — a profile that never declares them sees zero behavior change, same pattern every other optional `DEFAULTS` entry in `lane-profile.js` already follows.
|
||
- The integration branch is hardcoded to `"development"` (a module-level constant in `lane-sync.js`, not a new profile setting) — `SKILL.md`'s whole pipeline already hardcodes this branch name throughout; making it configurable here would be scope creep this task doesn't need.
|
||
- Run `npm run test:server` (full suite) plus `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit.
|
||
- Never use `git add -A`. Stage exactly the files each task names.
|
||
- This repo's existing route tests for `server/routes/lanes.js` are all exercised at the `server/lib/*` layer (no HTTP-level test harness for lane routes exists anywhere in this repo) — Task 2's unit tests are the verification for the route's logic; Task 3 adds only what the route layer itself does that the lib doesn't (request parsing, lock acquisition, error-code mapping), covered by targeted assertions against the route handler, not a spun-up HTTP server.
|
||
|
||
---
|
||
|
||
### Task 1: Profile declarations — `MIGRATIONS_DIR`, `GENERATED_MERGE_PATHS`
|
||
|
||
**Files:**
|
||
- Modify: `server/lib/lane-profile.js` (`DEFAULTS`, `resolveProfile`)
|
||
- Test: `server/__tests__/lane-profile.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `resolveProfile(lane).generatedMergePaths` — `string[]`, parsed from `GENERATED_MERGE_PATHS` the same way `ports`/`laneDirs` are already parsed (via the existing `splitList` helper). `resolveProfile(lane).env.MIGRATIONS_DIR` — plain string, used as-is (not a list).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `server/__tests__/lane-profile.test.js` (it already has `writeProfile`/`makeLane` helpers — reuse them, don't redefine):
|
||
|
||
```js
|
||
describe("resolveProfile — E2 declarations", () => {
|
||
it("defaults MIGRATIONS_DIR to empty and GENERATED_MERGE_PATHS to an empty array", () => {
|
||
const lane = makeLane();
|
||
writeProfile(lane.cwd, "PORTS=api\n");
|
||
const profile = profileLib.resolveProfile(lanesLib.getLane(lane.id));
|
||
assert.equal(profile.env.MIGRATIONS_DIR, "");
|
||
assert.deepEqual(profile.generatedMergePaths, []);
|
||
});
|
||
|
||
it("parses declared MIGRATIONS_DIR and GENERATED_MERGE_PATHS", () => {
|
||
const lane = makeLane();
|
||
writeProfile(
|
||
lane.cwd,
|
||
'PORTS=api\nMIGRATIONS_DIR="db/migrations"\nGENERATED_MERGE_PATHS="api/openapi.json api/client.ts"\n'
|
||
);
|
||
const profile = profileLib.resolveProfile(lanesLib.getLane(lane.id));
|
||
assert.equal(profile.env.MIGRATIONS_DIR, "db/migrations");
|
||
assert.deepEqual(profile.generatedMergePaths, ["api/openapi.json", "api/client.ts"]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `node --test server/__tests__/lane-profile.test.js`
|
||
Expected: FAIL — `profile.generatedMergePaths` is `undefined` (deepEqual against `[]` fails), and `MIGRATIONS_DIR`/`GENERATED_MERGE_PATHS` aren't recognized declarations yet (they parse fine as arbitrary keys via `parseEnvFile`, but the *default* for an undeclared `MIGRATIONS_DIR` won't exist in `DEFAULTS` — the first assertion in test 1 fails with `undefined !== ""`).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
In `server/lib/lane-profile.js`, add to `DEFAULTS` (after the existing `QC_BOOT_ENV` entry, `lane-profile.js:78`):
|
||
|
||
```js
|
||
QC_BOOT_ENV: "",
|
||
// E2: repo-relative path to a numbered-migrations directory (e.g.
|
||
// "db/migrations"), consumed by sync-base's collision preflight. Empty =
|
||
// off, same DEFAULTS pattern as every declaration above.
|
||
MIGRATIONS_DIR: "",
|
||
// E2: space-separated repo-relative paths given a keep-ours merge driver
|
||
// by sync-base's merge mode and regenerated post-merge by the profile's
|
||
// `regen` hook (e.g. an OpenAPI contract + its generated client). Empty =
|
||
// off — no driver installed, no regen fold-in attempted.
|
||
GENERATED_MERGE_PATHS: "",
|
||
});
|
||
```
|
||
|
||
In `resolveProfile`'s return object (`lane-profile.js:170-176`), add `generatedMergePaths` alongside the existing `ports`/`laneDirs`:
|
||
|
||
```js
|
||
return {
|
||
dir,
|
||
env,
|
||
hooks,
|
||
ports: splitList(env.PORTS),
|
||
laneDirs: splitList(env.LANE_DIRS),
|
||
generatedMergePaths: splitList(env.GENERATED_MERGE_PATHS),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `node --test server/__tests__/lane-profile.test.js`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add server/lib/lane-profile.js server/__tests__/lane-profile.test.js
|
||
git commit -m "feat(lanes): add MIGRATIONS_DIR + GENERATED_MERGE_PATHS profile declarations (E2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `server/lib/lane-sync.js` — the git core
|
||
|
||
**Files:**
|
||
- Create: `server/lib/lane-sync.js`
|
||
- Test: `server/__tests__/lane-sync.test.js`
|
||
- Modify (read-only reference, no changes needed): `server/lib/worktree.js` (`git` is already exported — confirmed at `worktree.js:642-660`, nothing to add there)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `git(cwd, args) => Promise<{stdout, stderr}>` from `require("./worktree")`; `runHook(lane, profile, name, args, options) => Promise<{code, output}>` from `require("./lane-profile")` (never throws on a non-zero hook exit).
|
||
- Produces:
|
||
- `checkSync(lane, profile, branch?) => Promise<{code: 0|5, devDelta?: string[]|null, overlap?: string[]|null, collisions?: Array<{file, collidesWith, suggestion}>}>`
|
||
- `mergeSync(lane, profile, branch?) => Promise<{code: 0|4|5, conflictedFiles?: string[], collisions?: Array<{file, collidesWith, suggestion}>}>`
|
||
- `continueSync(lane, profile, branch?) => Promise<{code: 0}>` (throws `EBADBRANCH`/`EUNRESOLVED`/`EMERGEUNCOMMITTED` on a precondition violation)
|
||
- All three accept `lane` as `{cwd, ...}` (only `cwd` is read) and `profile` as `resolveProfile()`'s return shape (`env`, `hooks`, `generatedMergePaths`).
|
||
- Thrown errors carry `.code` — `EBADBRANCH` (branch is `development`/`main`, or doesn't exist).
|
||
|
||
- [ ] **Step 1: Write the failing tests — fixture + branch guard + collision check**
|
||
|
||
Create `server/__tests__/lane-sync.test.js`:
|
||
|
||
```js
|
||
/**
|
||
* @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, []);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `node --test server/__tests__/lane-sync.test.js`
|
||
Expected: FAIL — `require("../lib/lane-sync")` throws `MODULE_NOT_FOUND`.
|
||
|
||
- [ ] **Step 3: Implement `lane-sync.js` — guards, collision check, dev-delta report, `checkSync`**
|
||
|
||
Create `server/lib/lane-sync.js`:
|
||
|
||
```js
|
||
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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 };
|
||
}
|
||
|
||
module.exports = {
|
||
INTEGRATION_BRANCH,
|
||
checkSync,
|
||
};
|
||
```
|
||
|
||
(`mergeSync`/`continueSync` are added in Steps 5–8 below — this step only needs `checkSync` to make the current tests pass.)
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `node --test server/__tests__/lane-sync.test.js`
|
||
Expected: PASS (all 4 tests so far — the two guard tests and the two `--check` tests).
|
||
|
||
- [ ] **Step 5: Write the failing tests — clean merge, merge-driver setup, regen fold-in**
|
||
|
||
Append to `server/__tests__/lane-sync.test.js`:
|
||
|
||
```js
|
||
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.writeFileSync(path.join(seed, "api", "openapi.json"), '{"v":1}\n', { flag: "w" });
|
||
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);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they fail**
|
||
|
||
Run: `node --test server/__tests__/lane-sync.test.js`
|
||
Expected: FAIL — `laneSync.mergeSync is not a function`.
|
||
|
||
- [ ] **Step 7: Implement `mergeSync` + the merge-driver setup + regen fold-in**
|
||
|
||
Append to `server/lib/lane-sync.js`, before `module.exports`:
|
||
|
||
```js
|
||
/** 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 };
|
||
}
|
||
```
|
||
|
||
Update `module.exports` at the bottom of `lane-sync.js`:
|
||
|
||
```js
|
||
module.exports = {
|
||
INTEGRATION_BRANCH,
|
||
checkSync,
|
||
mergeSync,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 8: Run tests to verify they pass**
|
||
|
||
Run: `node --test server/__tests__/lane-sync.test.js`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 9: Write the failing tests — conflict left in place, `--continue`**
|
||
|
||
Append to `server/__tests__/lane-sync.test.js`:
|
||
|
||
```js
|
||
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");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 10: Run tests to verify they fail**
|
||
|
||
Run: `node --test server/__tests__/lane-sync.test.js`
|
||
Expected: FAIL — `laneSync.continueSync is not a function`.
|
||
|
||
- [ ] **Step 11: Implement `continueSync`**
|
||
|
||
Append to `server/lib/lane-sync.js`, before `module.exports`:
|
||
|
||
```js
|
||
/** 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 };
|
||
}
|
||
```
|
||
|
||
Update `module.exports`:
|
||
|
||
```js
|
||
module.exports = {
|
||
INTEGRATION_BRANCH,
|
||
checkSync,
|
||
mergeSync,
|
||
continueSync,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 12: Run tests to verify they pass**
|
||
|
||
Run: `node --test server/__tests__/lane-sync.test.js`
|
||
Expected: PASS — every test in the file.
|
||
|
||
- [ ] **Step 13: Verify against a REAL worktree lane (not just plain clones)**
|
||
|
||
The merge-driver setup and `MERGE_HEAD` detection use `--git-common-dir` vs `--git-dir` specifically because a `git worktree add` lane's `.git` is a FILE pointing at a private per-worktree dir, while `info/attributes` lives in the shared common dir. The tests above use plain clones (where both resolve to the same `.git`), which would pass even if this distinction were implemented backwards. Add one more test using this repo's own `worktree.js` to catch that class of bug:
|
||
|
||
```js
|
||
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);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 14: Run full test file, verify pass**
|
||
|
||
Run: `node --test server/__tests__/lane-sync.test.js`
|
||
Expected: PASS — every test, including the worktree one.
|
||
|
||
- [ ] **Step 15: Header check + full suite**
|
||
|
||
```bash
|
||
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||
npm run test:server
|
||
```
|
||
|
||
- [ ] **Step 16: Commit**
|
||
|
||
```bash
|
||
git add server/lib/lane-sync.js server/__tests__/lane-sync.test.js
|
||
git commit -m "feat(lanes): add lane-sync core — check/merge/continue (E2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `POST /api/lanes/:id/sync-base` route
|
||
|
||
**Files:**
|
||
- Modify: `server/routes/lanes.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `checkSync`/`mergeSync`/`continueSync` from Task 2 (`require("../lib/lane-sync")`); `requireProfile` (already imported, `lane-runtime.js`); `withLaneLock` (already imported); `laneOr404`, `sendRuntimeError` (already defined in this file).
|
||
- Produces: `POST /api/lanes/:id/sync-base` — body `{mode?: "check"|"merge"|"continue", branch?: string}` (`mode` defaults `"merge"`). `200` with the raw `checkSync`/`mergeSync`/`continueSync` result (including `code: 4` and `code: 5` — those are documented outcomes, not HTTP errors). `400`/`404`/`409` with `{error: {code, message}}` on a genuine fault (no lane, no profile, bad branch, unresolved `--continue`).
|
||
|
||
- [ ] **Step 1: Add the `lane-sync` import**
|
||
|
||
In `server/routes/lanes.js`, add near the other `lib` requires (after `const { withLaneLock } = require("../lib/lane-lock");` at line 34):
|
||
|
||
```js
|
||
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
|
||
```
|
||
|
||
- [ ] **Step 2: Extend `sendRuntimeError`'s bad-request code list**
|
||
|
||
`lane-sync.js`'s only thrown error code is `EBADBRANCH`; `continueSync` also throws `EUNRESOLVED`/`EMERGEUNCOMMITTED`. All three are caller mistakes (bad branch name, calling `--continue` too early), not server faults — they belong with the existing 400 group. In `sendRuntimeError` (`server/routes/lanes.js:566`):
|
||
|
||
```js
|
||
function sendRuntimeError(res, err) {
|
||
const badRequest = [
|
||
"ENOPROFILE",
|
||
"ENOHOOK",
|
||
"EBADLANEDIR",
|
||
"EBADSVC",
|
||
"EBADBRANCH",
|
||
"EUNRESOLVED",
|
||
"EMERGEUNCOMMITTED",
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 3: Add the route**
|
||
|
||
Insert directly after the `/:id/hook/:name` route's closing `});` (`server/routes/lanes.js:760`, right before the `/:id/:action` catch-all comment block) — route order matters here, since the catch-all would otherwise 400 an unmatched path:
|
||
|
||
```js
|
||
/**
|
||
* The ONE sanctioned merge in the ship-feature-lane pipeline: origin/development
|
||
* INTO a feature branch, gated by a migration-number collision preflight.
|
||
* Synchronous — a fetch + collision-check + merge is seconds of git work, not
|
||
* the minutes a build/test hook can take, so this follows GET /:id/git's
|
||
* pattern rather than the hook route's 202-and-broadcast.
|
||
*
|
||
* Returns 200 with {code: 0|4|5, ...} for every DOCUMENTED outcome — a
|
||
* migration collision or a left-in-place conflict is an expected result, not
|
||
* an HTTP error. A malformed request, a missing profile, or an out-of-order
|
||
* --continue is the only case that answers with an `error` body.
|
||
*
|
||
* Never writes stage/status/notes — same boundary the hook and runtime
|
||
* routes already keep; the caller decides what a collision or conflict means
|
||
* for the lane's declared stage.
|
||
*/
|
||
router.post("/:id/sync-base", sameOriginGuard, async (req, res) => {
|
||
const lane = laneOr404(req, res);
|
||
if (!lane) return;
|
||
let profile;
|
||
try {
|
||
profile = requireProfile(lane);
|
||
} catch (err) {
|
||
return sendRuntimeError(res, err);
|
||
}
|
||
|
||
const mode = ["check", "merge", "continue"].includes(req.body?.mode) ? req.body.mode : "merge";
|
||
const branch =
|
||
typeof req.body?.branch === "string" && req.body.branch ? req.body.branch : undefined;
|
||
|
||
try {
|
||
const result = await withLaneLock(lane.id, () => {
|
||
const current = lanesLib.getLane(lane.id);
|
||
if (mode === "check") return checkSync(current, profile, branch);
|
||
if (mode === "continue") return continueSync(current, profile, branch);
|
||
return mergeSync(current, profile, branch);
|
||
});
|
||
res.json(result);
|
||
} catch (err) {
|
||
sendRuntimeError(res, err);
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Verify with a quick manual smoke check**
|
||
|
||
There's no HTTP-level test harness for lane routes in this repo (per the Global Constraints note) — Task 2's unit tests already cover `checkSync`/`mergeSync`/`continueSync` behavior directly. Confirm the route itself is wired correctly by starting the dev server and hitting it against a real profile-having lane:
|
||
|
||
```bash
|
||
npm run dev &
|
||
sleep 3
|
||
# Replace 1 with a real lane id that has a .ccam/profile and a feature branch.
|
||
curl -s -X POST http://localhost:4820/api/lanes/1/sync-base \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"mode":"check","branch":"feat/some-branch"}' | node -e "process.stdin.pipe(require('node:fs').createWriteStream('/dev/stdout'))"
|
||
```
|
||
|
||
Expected: a JSON body with a `code` field (0 or 5), not an Express 404/500 HTML page. Stop the dev server afterward.
|
||
|
||
- [ ] **Step 5: Run the full suite + header check**
|
||
|
||
```bash
|
||
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||
npm run test:server
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add server/routes/lanes.js
|
||
git commit -m "feat(lanes): add POST /:id/sync-base route (E2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `ccam lanes sync-base` CLI
|
||
|
||
**Files:**
|
||
- Modify: `bin/ccam.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `POST /api/lanes/:id/sync-base` (Task 3); `resolveLaneArg(args)`, `post(path, body, options)` (both already defined in `bin/ccam.js`).
|
||
- Produces: `ccam lanes sync-base [<id>] [--check|--continue] [branch]` — `process.exitCode` set to `0`/`4`/`5` matching the route's `code`, so `SKILL.md`'s documented exit-code contract holds when scripted.
|
||
|
||
- [ ] **Step 1: Add the subcommand to `cmdLanesRuntime`**
|
||
|
||
In `bin/ccam.js`, extend the `if (sub === "hook") { ... }` block (ends at `bin/ccam.js:1902`) with a new `sync-base` branch, inside the same `cmdLanesRuntime(sub, args)` function:
|
||
|
||
```js
|
||
if (sub === "sync-base") {
|
||
const mode = laneArgs.includes("--check")
|
||
? "check"
|
||
: laneArgs.includes("--continue")
|
||
? "continue"
|
||
: "merge";
|
||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||
const result = await post(
|
||
`/api/lanes/${laneId}/sync-base`,
|
||
{ mode, branch },
|
||
{ allowError: true }
|
||
);
|
||
if (result.status) {
|
||
console.error(`✖ sync-base → ${result.data?.error?.message || result.status}`);
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
|
||
if (result.code === 5) {
|
||
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
|
||
for (const c of result.collisions) {
|
||
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
|
||
}
|
||
process.exitCode = 5;
|
||
return;
|
||
}
|
||
|
||
if (result.code === 4) {
|
||
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
|
||
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
|
||
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
|
||
console.error(
|
||
` then: ccam lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
|
||
);
|
||
process.exitCode = 4;
|
||
return;
|
||
}
|
||
|
||
if (mode === "check") {
|
||
if (result.devDelta === null) {
|
||
console.log("DEV_DELTA: unknown (no merge-base with origin/development)");
|
||
} else {
|
||
console.log(
|
||
`DEV_DELTA: ${result.devDelta.length} file(s) changed on origin/development since merge-base`
|
||
);
|
||
for (const f of result.devDelta) console.log(` ${f}`);
|
||
if (result.overlap.length) {
|
||
console.log(
|
||
`DEV_OVERLAP: ${result.overlap.length} file(s) — the upstream delta touches the feature's files:`
|
||
);
|
||
for (const f of result.overlap) console.log(` ${f}`);
|
||
} else {
|
||
console.log("DEV_OVERLAP: none");
|
||
}
|
||
}
|
||
console.log(`lane #${laneId} preflight vs origin/development: OK`);
|
||
return;
|
||
}
|
||
|
||
console.log(`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`);
|
||
return;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the subcommand into the dispatcher**
|
||
|
||
`bin/ccam.js:3088` currently reads:
|
||
|
||
```js
|
||
if (["up", "down", "runtime", "logs", "hook"].includes(rest[0])) {
|
||
return cmdLanesRuntime(rest[0], rest.slice(1));
|
||
}
|
||
```
|
||
|
||
Change to:
|
||
|
||
```js
|
||
if (["up", "down", "runtime", "logs", "hook", "sync-base"].includes(rest[0])) {
|
||
return cmdLanesRuntime(rest[0], rest.slice(1));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add the help-catalog entry**
|
||
|
||
In the command catalog array (`bin/ccam.js:2268`, right after the `lanes hook` entry), add:
|
||
|
||
```js
|
||
[
|
||
"lanes sync-base",
|
||
"[<id>] [--check|--continue] [branch]",
|
||
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
|
||
],
|
||
```
|
||
|
||
- [ ] **Step 4: Manual smoke test against the running dashboard**
|
||
|
||
```bash
|
||
npm run dev &
|
||
sleep 3
|
||
node bin/ccam.js lanes sync-base --check feat/some-branch 1
|
||
echo "exit: $?"
|
||
```
|
||
|
||
Expected: exit code `0` (clean) or `5` (collision) with readable output, not a stack trace. Stop the dev server afterward.
|
||
|
||
- [ ] **Step 5: Run the full suite + header check**
|
||
|
||
```bash
|
||
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||
npm run test:server
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add bin/ccam.js
|
||
git commit -m "feat(lanes): add ccam lanes sync-base CLI (E2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `SKILL.md` edits + docs
|
||
|
||
**Files:**
|
||
- Modify: `.claude/skills/ship-feature-lane/SKILL.md`
|
||
- Modify: `docs/LANES.md`
|
||
- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`
|
||
|
||
**Interfaces:** none — documentation only, but every command referenced must now be real (this repo's docs-markdown rule: keep command examples executable and aligned with actual scripts).
|
||
|
||
- [ ] **Step 1: Stage 2 — replace the "later task" paragraph**
|
||
|
||
In `.claude/skills/ship-feature-lane/SKILL.md`, find (Stage 2, `### 2 — Pre-push CI gates + dev preflight`):
|
||
|
||
```
|
||
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. **This command is a LATER task, not yet built as of this skill's authoring** — until it exists, skip this preflight check and rely on Stage 12's conflict handling to catch a divergence at merge time; note this gap explicitly in your Stage 2 evidence (`ccam stage gates --evidence "sync-base preflight unavailable, skipped"`).
|
||
- Once built, its contract is: exit 5 on a migration-number collision (print the exact rename, do it on the feature branch, re-run Stage 2); informational `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise (you do NOT sync the branch for it — GitHub merges non-conflicting histories fine).
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```
|
||
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
|
||
```
|
||
|
||
- [ ] **Step 2: Stage 8 — make the re-run unconditional**
|
||
|
||
Find (Stage 8, around `SKILL.md:127`):
|
||
|
||
```
|
||
- Re-run the preflight if `ccam lanes sync-base` exists by the time you read this — development may have moved while you were in QC. If it doesn't exist yet, skip straight to the push (same gap noted in Stage 2).
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```
|
||
- Re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
|
||
```
|
||
|
||
- [ ] **Step 3: Stage 12 — unconditional merge + the exit-4/`--continue` mechanics**
|
||
|
||
Find (Stage 12, around `SKILL.md:155`):
|
||
|
||
```
|
||
- `CONFLICTING` → the feature branch conflicts with `development`. Resolve it as real work:
|
||
- If `ccam lanes sync-base` exists by now: `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). Resolve every conflict thoughtfully — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. `git add` ONLY the conflicted files, `git commit --no-edit`.
|
||
- If it doesn't exist yet: `git fetch origin && git merge origin/development` directly on the feature branch, resolve conflicts the same way, commit.
|
||
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```
|
||
- `CONFLICTING` → the feature branch conflicts with `development`. Resolve it as real work:
|
||
- `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step.
|
||
- **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit).
|
||
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
|
||
```
|
||
|
||
- [ ] **Step 4: `docs/LANES.md` — add a sync-base subsection**
|
||
|
||
In `docs/LANES.md`, under `## The ship-feature-lane skill (E1)` (`docs/LANES.md:967`), insert a new subsection after "### QC boot flag and profile integration" (ends around `docs/LANES.md:1005`) and before "### Pipeline template: ship-feature (16 node stages)":
|
||
|
||
```markdown
|
||
### Dev preflight and merge safety: sync-base
|
||
|
||
`ccam lanes sync-base` is the ONE sanctioned merge in the pipeline — `origin/development` into a feature branch — used at Stages 2, 8, and 12. Three modes:
|
||
|
||
```bash
|
||
ccam lanes sync-base --check feat/<slug> # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP
|
||
ccam lanes sync-base feat/<slug> # merge origin/development into the feature branch
|
||
ccam lanes sync-base --continue feat/<slug> # finish after a manually resolved conflict
|
||
```
|
||
|
||
Exit codes: `0` clean, `4` merge conflict (left in place — resolve, commit, then `--continue`), `5` migration-number collision (nothing merged — rename the printed file, re-run).
|
||
|
||
Two profile declarations control it, both empty (off) by default:
|
||
|
||
```bash
|
||
# .ccam/profile/profile.env
|
||
MIGRATIONS_DIR="db/migrations"
|
||
GENERATED_MERGE_PATHS="api/openapi.json api/client.ts"
|
||
```
|
||
|
||
`MIGRATIONS_DIR` enables the collision preflight against a numbered-migrations directory. `GENERATED_MERGE_PATHS` gives the listed files a keep-ours merge driver (never hand-merged) and folds the profile's `regen` hook output into the sync commit — the single most common cross-lane conflict, for a repo that generates an API contract/client.
|
||
```
|
||
|
||
Also update the "Current status" bullets (`docs/LANES.md:983-999`) — the stages 0-8/10-12/14 list is unaffected (sync-base was already inside those stages' scope, just non-functional), so no change needed there; the new subsection above is the only addition.
|
||
|
||
- [ ] **Step 5: Roadmap progress line**
|
||
|
||
In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, find the `## E` section's `**Progress:**` line (`docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md:282`):
|
||
|
||
```
|
||
**Progress:** pipeline template + skill text (E1) done 2026-08-04 — see `docs/superpowers/specs/2026-08-04-ship-feature-skill-design.md`. Agents, `sync-base`, and F's integrations remain.
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```
|
||
**Progress:** pipeline template + skill text (E1) done 2026-08-04 — see `docs/superpowers/specs/2026-08-04-ship-feature-skill-design.md`. `sync-base` (E2) done 2026-08-05 — see `docs/superpowers/specs/2026-08-05-sync-base-design.md`. Agents and F's integrations remain.
|
||
```
|
||
|
||
- [ ] **Step 6: Verify and commit**
|
||
|
||
```bash
|
||
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||
npm run test:server
|
||
```
|
||
|
||
```bash
|
||
git add .claude/skills/ship-feature-lane/SKILL.md docs/LANES.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
|
||
git commit -m "docs(lanes): document ccam lanes sync-base (E2)"
|
||
```
|