317 lines
11 KiB
JavaScript
317 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 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,
|
|
};
|