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

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

313 lines
11 KiB
JavaScript

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