feat(lanes): add lane-sync core — check/merge/continue (E2)
This commit is contained in:
@@ -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ĩ <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, []);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <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,
|
||||
};
|
||||
Reference in New Issue
Block a user