/** * Migration parity gate — blocks a PR to MAIN when a migration that ALREADY * EXISTS on the target (production) branch is ABSENT from this branch's HEAD * tree. * * Why this is the release→main gate that matters most. `ModelSnapshot.cs` is a * SINGLE file per DbContext. When a hotfix lands on `main` after a release was * cut, the release branch carries an OLDER snapshot. release→main uses a real * merge (never a squash — see merge/execute.ts), so that single file can * resolve to the release's version: the hotfix's model silently leaves the * snapshot and its migration file leaves the tree, WITHOUT a git conflict. * Production then holds an `__EFMigrationsHistory` row with no code behind it, * and the next `dotnet ef migrations add` scaffolds a diff against a schema it * mis-models — the migration after that one is the destructive one. * * The exact same signal already hard-gates `/efcore squash` * (`efcore/cli/lib/squash-integrity.ts` → `findMissingReferenceMigrations`), * but that only fires when someone deliberately squashes. This puts it on the * PATH TO PRODUCTION, where it cannot be forgotten. * * Contract with squash-integrity.ts (duplicated here on purpose — gitflow must * not import another skill's modules at runtime; the repo-side test * `__tests__/migration-parity-gate.test.ts` asserts the two stay in lockstep): * - a migration's comparable key is its file BASE NAME with `.cs` / * `.Designer.cs` stripped, so a migration and its Designer sidecar collapse * to a single key and a migration MOVED between folders is still matched; * - `*ModelSnapshot.cs` is never a migration key. * * Inert by construction for repos holding no migrations (SmartStack.cli itself). * Fail-OPEN when a tree cannot be listed — a fresh repo whose `main` does not * exist yet must not be bricked — but it says so loudly in `warnings`. */ import * as git from './git.js' /** Any `.cs` under a `Migrations/` (or singular `Migration/`) folder, at any depth. */ const MIGRATION_PATH_RE = /(^|\/)Migrations?\//i /** * Reduce a migration file path to its comparable key: the base name without * the `.cs` / `.Designer.cs` suffix. Returns `null` for anything that is not a * migration (snapshots, non-`.cs`) so it never pollutes the diff. * * Kept byte-for-byte equivalent to `migrationKey` in * `efcore/cli/lib/squash-integrity.ts` — drift-tested. */ export function migrationKey(filePath: string): string | null { const base = filePath.replace(/\\/g, '/').split('/').pop() if (!base || !base.endsWith('.cs')) return null if (base.endsWith('ModelSnapshot.cs')) return null return base.replace(/\.Designer\.cs$/i, '').replace(/\.cs$/i, '') } /** * The sorted, de-duplicated migration keys held by a list of tree paths. * Applies BOTH filters: the path must live under a Migrations/ folder AND * reduce to a migration key. The folder filter is what keeps an unrelated * `Foo.cs` out; it is applied identically to both sides of the comparison, so * it can never manufacture a false positive. */ export function migrationKeysOf(paths: string[]): string[] { const keys = new Set() for (const p of paths) { if (!MIGRATION_PATH_RE.test(p.replace(/\\/g, '/'))) continue const key = migrationKey(p) if (key) keys.add(key) } return Array.from(keys).sort() } export interface MigrationParityDecision { ok: boolean error?: string warnings: string[] /** Target-branch migrations ABSENT from HEAD — non-empty ⇒ a prod schema regression. */ missing: string[] /** Target-branch migrations verified still present at HEAD. */ preserved: number } /** * Pure decision: every migration the target branch holds must still be present * at HEAD. `confirmRebaseline` downgrades the block to a warning — the ONLY * legitimate case is a deliberate lock-step re-baseline (an `/efcore squash * --bruteForce` whose reference branch is re-baselined in the same breath). */ export function decideMigrationParityGate( referencePaths: string[], headPaths: string[], targetBranch: string, confirmRebaseline = false, ): MigrationParityDecision { const reference = migrationKeysOf(referencePaths) const present = new Set(migrationKeysOf(headPaths)) const missing = reference.filter((k) => !present.has(k)) const preserved = reference.length - missing.length if (missing.length === 0) return { ok: true, warnings: [], missing, preserved } const names = missing.join(', ') const detail = `${missing.length} migration(s) present on '${targetBranch}' are MISSING from this branch: ${names}. ` + `Merging would remove them from production: ModelSnapshot.cs is a single file, so the merge can regress ` + `the prod model with no conflict, leaving __EFMigrationsHistory rows with no code behind them.` if (confirmRebaseline) { return { ok: true, warnings: [ `EF Core parity OVERRIDDEN (confirmRebaseline): ${detail} ` + `Proceeding only because this was declared a deliberate lock-step re-baseline — ` + `'${targetBranch}' MUST be re-baselined in the same operation.`, ], missing, preserved, } } return { ok: false, warnings: [], missing, preserved, error: `${detail} Bring the target in first — run /gitflow update on this branch ` + `(it merges origin/${targetBranch}), resolve the Migrations/ + ModelSnapshot.cs conflicts by KEEPING both ` + `sides' migrations, then retry the PR. If this really is a deliberate lock-step re-baseline, re-run with ` + `confirmRebaseline:true in the spec.`, } } /** * I/O wrapper: lists the migration files of the target tree and of HEAD * (read-only `ls-tree`) and applies the pure decision. * * HEAD — not the working tree — is the right "present" signal here: a PR * merges COMMITS. (The squash CLI compares against disk instead, because it is * about to delete files from disk.) `core.quotePath=false` keeps non-ASCII * paths from being escaped into unparseable lines. */ export async function checkMigrationParityGate( targetBranch: string, cwd?: string, confirmRebaseline = false, ): Promise { const baseRef = await git.resolveBaseRef(targetBranch, cwd) const listTree = async (ref: string) => git.execGit(['-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', ref], cwd) const refListing = await listTree(baseRef) if (refListing.exitCode !== 0) { return { ok: true, warnings: [ `EF Core parity check SKIPPED — could not read the tree of '${baseRef}'. ` + `Migrations already in production cannot be proven present on this branch.`, ], missing: [], preserved: 0, } } const referencePaths = refListing.stdout.split('\n').filter(Boolean) // Nothing to protect — no migration on the target branch (fresh repo, or a // repo like SmartStack.cli that ships no EF context at all). if (migrationKeysOf(referencePaths).length === 0) { return { ok: true, warnings: [], missing: [], preserved: 0 } } const headListing = await listTree('HEAD') if (headListing.exitCode !== 0) { return { ok: true, warnings: [ `EF Core parity check SKIPPED — could not read the HEAD tree.`, ], missing: [], preserved: 0, } } return decideMigrationParityGate( referencePaths, headListing.stdout.split('\n').filter(Boolean), targetBranch, confirmRebaseline, ) }