import path from 'node:path'; import fs from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { findFiles, ensureDirectory, copyFile, removeFile, readText, writeText, fileExists } from '../../../lib/fs.js'; import { detectDbContexts, findStartupProject, hasDotnetProject } from '../lib/detect-dbcontexts.js'; import { runDotnet, dotnetAvailable, dotnetEfAvailable, buildMigrationsAddArgs, migrationsOutputDir } from '../lib/ef-runner.js'; import { buildMigrationName } from '../lib/migration-name.js'; import { determineBaseBranch, type SquashBranchType } from '../lib/squash-base.js'; import { minBranchOnly, behindBaseBlocker, withGitignoreLine, decideSnapshotAction, rollbackRemovals, staleHistoryAdvisory, type SquashMode } from '../lib/squash-policy.js'; import { findMissingReferenceMigrations, referenceMigrationNames } from '../lib/squash-integrity.js'; import { restoreFilesFromRef } from '../lib/git-file.js'; import { buildHistoryRecord, appendHistory, type HistoryAssemblyEntry } from '../lib/efcore-history.js'; import { ensureSqlObjectsInMigration, previewSqlObjectsForMigration } from '../../../lib/sql-objects.js'; import type { SquashSpec, SquashResult, SquashAssemblyResult } from './types.js'; const BACKUP_DIR_NAME = '.efcore-squash-backup'; const execFileAsync = promisify(execFile); async function git(args: string[], cwd: string): Promise<{ stdout: string; exitCode: number; stderr: string }> { try { const { stdout, stderr } = await execFileAsync('git', args, { cwd, encoding: 'utf-8', maxBuffer: 20 * 1024 * 1024 }); return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }; } catch (err) { const e = err as { stdout?: string; stderr?: string; code?: number }; return { stdout: (e.stdout || '').trim(), stderr: (e.stderr || '').trim(), exitCode: typeof e.code === 'number' ? e.code : 1, }; } } /** * Resolve a single named branch to a verifiable ref. Tries `origin/` * then the local ``. Returns null if neither exists. * * IMPORTANT: there is NO cross-type fallback. A hotfix/release whose base is * `main` must resolve against `main` — never silently against `develop` — * otherwise migrations already in production would be treated as "branch-only" * and deleted. The caller errors out when this returns null. */ async function resolveRef(cwd: string, name: string): Promise { for (const candidate of [`origin/${name}`, name]) { const r = await git(['rev-parse', '--verify', '--quiet', candidate], cwd); if (r.exitCode === 0 && r.stdout) return candidate; } return null; } function capitalize(s: string): string { if (!s) return s; if (s.toLowerCase() === 'sqlserver') return 'SqlServer'; return s.charAt(0).toUpperCase() + s.slice(1); } function uniq(items: string[]): string[] { return Array.from(new Set(items)); } /** Pure migration `.cs` files in `dir` — excludes ModelSnapshot and .Designer.cs sidecars. */ async function listMigrationFilesInDir(dir: string): Promise { const files = await findFiles('*.cs', { cwd: dir }); return files.filter( (f) => path.dirname(f) === dir && !f.endsWith('ModelSnapshot.cs') && !f.endsWith('.Designer.cs'), ); } interface Classification { /** Local migrations that are NOT present in the reference branch (safe to squash). */ branchOnly: string[]; /** Every `.cs` file the reference branch holds under the migrations dir (migrations + designers + snapshot), relative to cwd — restored verbatim. */ baseRelFiles: string[]; /** Count of pure migrations preserved from the reference branch. */ basePreserved: number; /** False when the reference branch tree could not be read — caller must delete NOTHING. */ baseListingOk: boolean; /** * Does the reference branch carry a `…ModelSnapshot.cs` under the migrations * dir? False ⇒ the reference has never seen this context (empty base): there * is no snapshot to restore, so the squash regenerates a full InitialCreate. */ baseHasSnapshot: boolean; /** 🔒 Names of the reference-branch migrations (the INTOUCHABLE parent set). */ parentMigrations: string[]; /** ☠️ Reference-branch migrations MISSING from the working tree (deleted/renamed locally). */ missingFromWorkingTree: string[]; } /** * Split the local migrations into "branch-only" (consolidatable) vs the * reference-branch set (preserved). The reference set is computed from the * reference branch's git tree — a migration present there is NEVER deleted. * * Also surfaces the prod-break signal: reference migrations absent from the * working tree (`missingFromWorkingTree`) — a manual re-baseline that would * drop an already-applied migration from production at merge. */ async function classifyMigrations(migrationsDir: string, cwd: string, baseRef: string): Promise { const all = await listMigrationFilesInDir(migrationsDir); const rel = path.relative(cwd, migrationsDir).replace(/\\/g, '/'); const baseListing = await git(['ls-tree', '-r', '--name-only', baseRef, rel], cwd); if (baseListing.exitCode !== 0) { return { branchOnly: [], baseRelFiles: [], basePreserved: 0, baseListingOk: false, baseHasSnapshot: false, parentMigrations: [], missingFromWorkingTree: [] }; } const baseAllCs = baseListing.stdout.split('\n').filter(Boolean).filter((f) => f.endsWith('.cs')); const baseHasSnapshot = baseAllCs.some((f) => f.endsWith('ModelSnapshot.cs')); const baseMigrationSet = new Set( baseAllCs.filter((f) => !f.endsWith('ModelSnapshot.cs') && !f.endsWith('.Designer.cs')), ); const branchOnly = all.filter((abs) => { const r = path.relative(cwd, abs).replace(/\\/g, '/'); return !baseMigrationSet.has(r); }); const baseMigrationPaths = Array.from(baseMigrationSet); return { branchOnly, baseRelFiles: baseAllCs, basePreserved: baseMigrationSet.size, baseListingOk: true, baseHasSnapshot, parentMigrations: referenceMigrationNames(baseMigrationPaths), // "present" is DISK truth (`all`) — a file `rm`'d but still tracked is still // gone from the tree, so disk presence is the right signal. missingFromWorkingTree: findMissingReferenceMigrations(baseMigrationPaths, all), }; } /** * Back up `files` under `backupDir`, preserving their path relative to `cwd`. * De-duplicates and silently skips files that don't exist on disk (e.g. a * migration without a `.Designer.cs`) so the caller can pass a superset. */ async function backupFiles(files: string[], backupDir: string, cwd: string): Promise { await ensureDirectory(backupDir); for (const f of Array.from(new Set(files))) { if (!(await fileExists(f))) continue; const rel = path.relative(cwd, f); const dest = path.join(backupDir, rel); await ensureDirectory(path.dirname(dest)); await copyFile(f, dest); } } /** Commits the current branch is BEHIND `baseRef` (HEAD..baseRef). 0 on error. */ async function countBehind(cwd: string, baseRef: string): Promise { const r = await git(['rev-list', '--count', `HEAD..${baseRef}`], cwd); if (r.exitCode !== 0) return 0; const n = Number(r.stdout.trim()); return Number.isFinite(n) ? n : 0; } /** * Transparency for finding #3: when the resolved reference is `origin/` * but a local `` exists and differs, say so — the squash aligns on the * remote, which may not be what the user has locally. */ async function warnIfRemoteDiverges(cwd: string, baseRef: string, warnings: string[]): Promise { if (!baseRef.startsWith('origin/')) return; const local = baseRef.slice('origin/'.length); const localExists = (await git(['rev-parse', '--verify', '--quiet', local], cwd)).exitCode === 0; if (!localExists) return; const ahead = Number((await git(['rev-list', '--count', `${local}..${baseRef}`], cwd)).stdout.trim()) || 0; const behind = Number((await git(['rev-list', '--count', `${baseRef}..${local}`], cwd)).stdout.trim()) || 0; if (ahead || behind) { warnings.push( `Reference is "${baseRef}"; your local "${local}" differs (origin is ${ahead} ahead / ${behind} behind it). ` + `The squash aligns on "${baseRef}" — fetch/merge if that is not what you intend.`, ); } } /** Finding #5: keep the backup folder out of git. Idempotent, append-only. */ async function ensureBackupGitignored(cwd: string, warnings: string[]): Promise { try { const gitignore = path.join(cwd, '.gitignore'); const existing = (await fileExists(gitignore)) ? await readText(gitignore) : null; const next = withGitignoreLine(existing, `${BACKUP_DIR_NAME}/`); if (next !== null) await writeText(gitignore, next); } catch { warnings.push(`Could not update .gitignore for ${BACKUP_DIR_NAME}/ — add it manually so backups are not committed.`); } } /** * Restore the reference-branch migration files (+ their .Designer.cs) verbatim * from the reference branch. This is the 4.x GOLDEN RULE — "NEVER retrieve only * the snapshot without the migrations" — and it guarantees every inherited * migration is byte-identical to the reference branch after the squash. * * Goes through `lib/git-file.ts` (`git cat-file --filters` + a plain write), NOT * `git checkout -- `: checkout also writes the INDEX, which left the * stale base snapshot staged under the regenerated one (the `MM` incident). */ async function restoreFromRef(relFiles: string[], cwd: string, baseRef: string): Promise { return restoreFilesFromRef(relFiles, cwd, baseRef); } async function resetSnapshotToBase(snapshotPath: string, cwd: string, baseRef: string): Promise { const rel = path.relative(cwd, snapshotPath).replace(/\\/g, '/'); const failed = await restoreFilesFromRef([rel], cwd, baseRef); return failed.length === 0; } /** Delete a migration `.cs` and its `.Designer.cs` sidecar; return the basenames removed. */ async function deleteMigrationFile(file: string): Promise { const removed: string[] = []; await removeFile(file); removed.push(path.basename(file)); const designer = file.replace(/\.cs$/, '.Designer.cs'); try { await fs.access(designer); await removeFile(designer); removed.push(path.basename(designer)); } catch { /* no designer */ } return removed; } /** * Atomic rollback for a NORMAL-squash assembly. Restores the working tree to its * exact pre-run state from the backup just taken, so a failed real run is a * NO-OP instead of a half-squashed, non-buildable tree (Part E of the empty-base * bug report). The backup mirrors every pre-run migration file (snapshot + * branch-only + designers + inherited-on-disk), so we: * 1. delete any `.cs` now directly in the migrations dir that the backup does * NOT hold (e.g. the consolidated migration + its `.Designer.cs` that * `dotnet ef migrations add` wrote before/while failing), then * 2. copy every backed-up file back to its original cwd-relative path * (recreates the deleted branch-only migrations + reverts the snapshot). * Returns false if the restore itself failed (caller warns + keeps the backup). */ async function rollbackAssemblyFromBackup(migrationsDir: string, backupDir: string, cwd: string): Promise { try { const backedUpAbs = await findFiles('**/*', { cwd: backupDir }); const backedUpRel = backedUpAbs.map((abs) => path.relative(backupDir, abs).replace(/\\/g, '/')); // 1. Remove files created by the failed run (present now, absent from backup). const nowAbs = (await findFiles('*.cs', { cwd: migrationsDir })).filter( (f) => path.dirname(f) === migrationsDir, ); const nowRel = nowAbs.map((abs) => path.relative(cwd, abs).replace(/\\/g, '/')); for (const rel of rollbackRemovals(nowRel, backedUpRel)) { await removeFile(path.join(cwd, rel)); } // 2. Restore every backed-up file verbatim to its original location. for (const abs of backedUpAbs) { await copyFile(abs, path.join(cwd, path.relative(backupDir, abs))); } return true; } catch { return false; } } export async function execute(spec: SquashSpec): Promise { const warnings: string[] = []; const mode: SquashMode = spec.mode ?? 'squash'; const bruteForce = spec.bruteForce ?? false; let behindBase = 0; if (spec.aggressive) { warnings.push( '`aggressive` is deprecated and ignored — migrations present in the reference branch are ALWAYS preserved. For a deliberate full re-baseline use `bruteForce`.', ); } const currentBranch = (await git(['branch', '--show-current'], spec.cwd)).stdout || 'HEAD'; const decision = determineBaseBranch(currentBranch); const branchType: SquashBranchType = decision.branchType; const fail = (error: string, baseBranch = ''): SquashResult => ({ success: false, cwd: spec.cwd, baseBranch, branchType, currentBranch, behindBase, assemblies: [], warnings, error, }); // 1. Never squash on a protected production branch — bruteForce does NOT // override this. A re-baseline is done on develop/release then merged to // main in lock-step; you never rewrite history while sitting on prod. if (decision.blocked) { return fail( `Refusing to ${bruteForce ? 'brute-force re-baseline' : 'squash'} on "${currentBranch}" — ${decision.reason}` + (bruteForce ? ' bruteForce does NOT override the production-branch block — re-baseline on develop/release, then merge to main in lock-step.' : ''), ); } if (!(await hasDotnetProject(spec.cwd))) { return fail('No .csproj in worktree.'); } // 2. Resolve the reference branch BY TYPE (explicit override wins). No // cross-type fallback — a release/hotfix must resolve against main. let baseRef: string | null; let baseName: string; if (spec.baseBranch) { baseName = spec.baseBranch; baseRef = await resolveRef(spec.cwd, spec.baseBranch); if (!baseRef) { return fail(`Could not resolve the requested base branch "${spec.baseBranch}" (tried origin/${spec.baseBranch} and ${spec.baseBranch}).`, baseName); } } else { baseName = decision.baseBranch as string; // non-null when not blocked baseRef = await resolveRef(spec.cwd, baseName); if (!baseRef) { return fail( `Could not resolve base branch "${baseName}" for ${branchType} branch "${currentBranch}". ` + `Fetch it first (git fetch origin ${baseName}) or pass an explicit baseBranch. ` + `Refusing to fall back to another branch — that would risk deleting migrations already present in the reference branch.`, baseName, ); } } // 3. Safety (finding #1): refuse to squash a branch that is behind its // reference — that regenerates a migration which can DROP what the // reference added. bruteForce lifts this (deliberate re-baseline) but warns. behindBase = await countBehind(spec.cwd, baseRef); await warnIfRemoteDiverges(spec.cwd, baseRef, warnings); if (bruteForce) { if (behindBase > 0) { warnings.push( `⚠️ bruteForce: "${currentBranch}" is ${behindBase} commit(s) behind "${baseRef}" — the re-baseline proceeds anyway (you asked). Ensure "${baseRef}" is re-baselined in lock-step.`, ); } } else { const blocker = behindBaseBlocker({ behindBase, allowBehindBase: spec.allowBehindBase ?? false, currentBranch, baseRef, mode, }); if (blocker) { if (spec.dryRun) { warnings.push(`⚠️ ${blocker}`); } else { return fail(blocker, baseRef); } } } const detection = await detectDbContexts(spec.cwd); const ctx = detection.contexts.find((c) => c.name === spec.contextName); if (!ctx) { return fail(`Context "${spec.contextName}" not detected in worktree`, baseRef); } const targets = spec.assembly ? ctx.assemblies.filter((a) => a.assemblyName === spec.assembly) : ctx.assemblies; if (targets.length === 0) { return fail('No migration assemblies matched filter', baseRef); } // 4. Classify ALL target assemblies up-front (read-only — git + disk). This // feeds both the plan view (the 🔒 / ✂️ / ☠️ lists) and the safety gates // below, and lets us refuse the whole operation before mutating anything. type Classified = { asm: (typeof targets)[number] } & Classification; const classified: Classified[] = []; for (const asm of targets) { const c = await classifyMigrations(asm.migrationsDir, spec.cwd, baseRef); classified.push({ asm, ...c }); } // Build a per-assembly result row pre-filled with the classification lists so // every push carries parentMigrations + missingFromWorkingTree consistently. const baseRow = (c: Classified, over: Partial): SquashAssemblyResult => ({ assemblyName: c.asm.assemblyName, contextName: spec.contextName, backupDir: null, deletedFiles: [], baseMigrationsPreserved: c.basePreserved, parentMigrations: c.parentMigrations, missingFromWorkingTree: c.missingFromWorkingTree, resetSnapshot: false, newMigrationName: null, sqlObjectsInlined: [], dotnetOutput: '', success: true, ...over, }); // 5. THE HARD GATE (the incident fix). A reference migration missing from the // working tree means a manual re-baseline already removed an // already-applied migration — merging would drop it from production. // Refuse by default; only a deliberate, logged bruteForce may proceed. if (!bruteForce) { const allMissing = uniq(classified.flatMap((c) => c.missingFromWorkingTree)); if (allMissing.length > 0) { const msg = `Refusing to squash — ${allMissing.length} migration(s) present on "${baseRef}" are MISSING from your working tree: ${allMissing.join(', ')}. ` + `A merge would DROP them from production. Restore them (git checkout ${baseRef} -- ), ` + `or — for a deliberate release re-baseline — re-run with "bruteForce": true and "confirmBruteForce": true ` + `(the one exception; it is logged to .claude/Efcore-history).`; if (spec.dryRun) { // Surface the danger but still produce the plan rows so the agent can // render the 🔒 / ✂️ / ☠️ lists. warnings.push(`☠️ ${msg}`); } else { return fail(msg, baseRef); } } } // 6. bruteForce confirmation-token gate. Without the explicit token, REFUSE // and report exactly which production migrations would be destroyed. if (bruteForce && !spec.confirmBruteForce) { const casualties = uniq(classified.flatMap((c) => c.parentMigrations)); const planRows = classified.map((c) => baseRow(c, { deletedFiles: [...c.parentMigrations, ...c.branchOnly.map((f) => path.basename(f))], baseMigrationsPreserved: 0, success: false, error: 'Awaiting confirmBruteForce — these migrations (including reference/prod) would be destroyed.', dotnetOutput: `[brute-force preview] would delete ALL ${c.parentMigrations.length + c.branchOnly.length} migration(s) ` + `(incl. ${c.parentMigrations.length} from ${baseRef}) + snapshot, then regenerate one InitialCreate`, }), ); return { success: false, requiresConfirmation: true, cwd: detection.cwd, baseBranch: baseRef, branchType, currentBranch, behindBase, assemblies: planRows, warnings, error: `BRUTE FORCE re-baseline requested. This will DELETE ${casualties.length} migration(s) that already live on "${baseRef}" ` + `(production history): ${casualties.join(', ') || '(none on the reference branch)'} — plus all branch-only migrations — ` + `and regenerate a single InitialCreate. Pass "confirmBruteForce": true to proceed. It will be logged to .claude/Efcore-history.`, }; } if (!spec.dryRun) { if (!(await dotnetAvailable())) { return fail('dotnet CLI not on PATH', baseRef); } if (!(await dotnetEfAvailable())) { return fail('dotnet-ef not installed globally', baseRef); } } const startupProject = (await findStartupProject(spec.cwd)) ?? undefined; const startedAtIso = new Date().toISOString(); const timestamp = startedAtIso.replace(/[:.]/g, '-'); const backupRoot = path.join(spec.cwd, BACKUP_DIR_NAME, timestamp); // Finding #5: make sure the backup folder is git-ignored before we write into it. if (!spec.dryRun) await ensureBackupGitignored(spec.cwd, warnings); const results: SquashAssemblyResult[] = []; for (const c of classified) { const { asm, branchOnly, baseRelFiles, basePreserved, baseListingOk } = c; // Safety: if we cannot read the reference branch's tree we cannot prove // which migrations are inherited — so we delete NOTHING. if (!baseListingOk) { warnings.push(`${asm.assemblyName}: could not read migrations of base "${baseRef}" — skipped to avoid deleting reference-branch migrations.`); results.push(baseRow(c, { error: `Skipped — could not list migrations of base "${baseRef}".` })); continue; } const assemblyBackup = path.join(backupRoot, asm.assemblyName); // ===== BRUTE FORCE: full re-baseline (delete EVERYTHING incl. the snapshot) ===== if (bruteForce) { const onDisk = await listMigrationFilesInDir(asm.migrationsDir); const newMigrationName = buildMigrationName({ contextName: spec.contextName, version: asm.version, sequence: 1, description: spec.description, }); if (spec.dryRun) { const wouldInline = await previewSqlObjectsForMigration({ projectDir: path.dirname(asm.csprojPath), migrationsDir: asm.migrationsDir, }); results.push( baseRow(c, { backupDir: assemblyBackup, deletedFiles: onDisk.map((f) => path.basename(f)), baseMigrationsPreserved: 0, resetSnapshot: true, newMigrationName, sqlObjectsInlined: wouldInline, dotnetOutput: `[dry-run BRUTE FORCE] would delete ALL ${onDisk.length} migration(s) ` + `(incl. ${c.parentMigrations.length} from ${baseRef}) + the snapshot, then dotnet ef migrations add ${newMigrationName} -o ${migrationsOutputDir(asm.csprojPath, asm.migrationsDir)}` + (wouldInline.length ? `, then inline ${wouldInline.length} SQL object(s): ${wouldInline.join(', ')}` : ''), }), ); continue; } // Backup the entire migrations set (+ designers + snapshot) before nuking. await backupFiles( [asm.snapshotPath, ...onDisk, ...onDisk.map((f) => f.replace(/\.cs$/, '.Designer.cs'))], assemblyBackup, spec.cwd, ); const deleted: string[] = []; for (const f of onDisk) deleted.push(...(await deleteMigrationFile(f))); // Drop the snapshot too so EF regenerates a full InitialCreate (otherwise // the new migration would diff against the old snapshot → empty). await removeFile(asm.snapshotPath); const extraEnv = asm.provider ? { STUDIO_DESIGN_PROVIDER: capitalize(asm.provider) } : undefined; const args = buildMigrationsAddArgs({ migrationName: newMigrationName, csprojPath: asm.csprojPath, contextName: spec.contextName, migrationsDir: asm.migrationsDir, startupProject, }); const addRes = await runDotnet(args, spec.cwd, { timeoutMs: 5 * 60_000, extraEnv }); let sqlObjectsInlined: string[] = []; if (addRes.exitCode === 0) { try { const ensure = await ensureSqlObjectsInMigration({ projectDir: path.dirname(asm.csprojPath), migrationsDir: asm.migrationsDir, migrationName: newMigrationName, }); sqlObjectsInlined = ensure.inlined; if (ensure.unparsable.length > 0) { warnings.push(`${asm.assemblyName}: ${ensure.unparsable.length} .sql file(s) under SqlObjects have no CREATE [OR ALTER] header — not inlined: ${ensure.unparsable.join(', ')}`); } } catch (e) { warnings.push(`${asm.assemblyName}: SQL-object inlining skipped (${(e as Error).message}).`); } } results.push( baseRow(c, { backupDir: assemblyBackup, deletedFiles: deleted, baseMigrationsPreserved: 0, resetSnapshot: true, newMigrationName: addRes.exitCode === 0 ? newMigrationName : null, sqlObjectsInlined, dotnetOutput: `${addRes.stdout}${addRes.stderr ? `\n${addRes.stderr}` : ''}`, success: addRes.exitCode === 0, error: addRes.exitCode === 0 ? undefined : addRes.stderr || addRes.stdout || 'dotnet ef migrations add failed', }), ); continue; } // ===== NORMAL SQUASH: consolidate branch-only migrations only ===== // Threshold by mode (finding #4): squash needs ≥2 to consolidate; // rebase-snapshot acts on ≥1 (and resets the snapshot even on 0). const min = minBranchOnly(mode); if (branchOnly.length < min) { if (mode === 'rebase-snapshot' && branchOnly.length === 0) { const snapBackup = path.join(backupRoot, asm.assemblyName); if (spec.dryRun) { results.push( baseRow(c, { resetSnapshot: true, dotnetOutput: `[dry-run] would reset ${path.basename(asm.snapshotPath)} to ${baseRef} (no branch-only migrations to consolidate)`, }), ); continue; } await backupFiles([asm.snapshotPath], snapBackup, spec.cwd); const resetOk = await resetSnapshotToBase(asm.snapshotPath, spec.cwd, baseRef); results.push( baseRow(c, { backupDir: snapBackup, resetSnapshot: resetOk, success: resetOk, error: resetOk ? 'Snapshot reset to reference branch (no branch-only migrations to consolidate)' : `could not restore ${asm.snapshotPath} from ${baseRef}`, }), ); continue; } results.push( baseRow(c, { error: branchOnly.length === 0 ? `No branch-only migrations (all ${basePreserved} are inherited from ${baseRef}) — nothing to ${mode === 'rebase-snapshot' ? 'rebase-snapshot' : 'squash'}` : 'Only 1 branch-only migration — nothing to consolidate', }), ); continue; } // Predict the snapshot path so the plan matches what the real run will do. // Empty base ⇒ no snapshot to restore ⇒ consolidated as a full InitialCreate. const emptyBase = decideSnapshotAction({ baseHasSnapshot: c.baseHasSnapshot, basePreserved }) === 'delete-for-initial-create'; if (spec.dryRun) { const wouldInline = await previewSqlObjectsForMigration({ projectDir: path.dirname(asm.csprojPath), migrationsDir: asm.migrationsDir, }); const newMigrationName = buildMigrationName({ contextName: spec.contextName, version: asm.version, sequence: basePreserved + 1, description: spec.description, }); if (emptyBase) { // Remove the false-GREEN: tell the user up front this is an empty-base // consolidation (the real run cannot "restore 0 from base" — there is // no snapshot on the reference; it deletes the snapshot and regenerates). warnings.push( `${asm.assemblyName}: reference "${baseRef}" carries no migrations/snapshot for ${spec.contextName} — ` + `the consolidated migration will be a full InitialCreate (empty base model).`, ); } results.push( baseRow(c, { backupDir: assemblyBackup, deletedFiles: branchOnly.map((f) => path.basename(f)), resetSnapshot: true, emptyBaseModel: emptyBase, newMigrationName, sqlObjectsInlined: wouldInline, dotnetOutput: emptyBase ? `[dry-run] base "${baseRef}" has no snapshot for ${spec.contextName} (empty base model) — would delete ${branchOnly.length} migration(s) + the snapshot, then dotnet ef migrations add ${newMigrationName} -o ${migrationsOutputDir(asm.csprojPath, asm.migrationsDir)} as a full InitialCreate${wouldInline.length ? `, then inline ${wouldInline.length} SQL object(s): ${wouldInline.join(', ')}` : ''}` : `[dry-run] would delete ${branchOnly.length} branch-only migration(s), restore ${basePreserved} from ${baseRef}, then dotnet ef migrations add ${newMigrationName} -o ${migrationsOutputDir(asm.csprojPath, asm.migrationsDir)}${wouldInline.length ? `, then inline ${wouldInline.length} SQL object(s): ${wouldInline.join(', ')}` : ''}`, }), ); continue; } // 1. Backup snapshot + the branch-only migrations (+ their designers) AND // the inherited reference files currently on disk (finding #5: capture // any local edits to inherited migrations before restore overwrites them). const inheritedOnDisk = baseRelFiles.map((rel) => path.join(spec.cwd, rel)); await backupFiles( [ asm.snapshotPath, ...branchOnly, ...branchOnly.map((f) => f.replace(/\.cs$/, '.Designer.cs')), ...inheritedOnDisk, ], assemblyBackup, spec.cwd, ); // 2. Delete branch-only migration files (+ .Designer.cs). NEVER the inherited ones. const deleted: string[] = []; for (const f of branchOnly) deleted.push(...(await deleteMigrationFile(f))); // 3. Restore the reference-branch migrations + designers verbatim (GOLDEN RULE: // never keep only the snapshot). Snapshot itself reset in step 4. const restoreFailures = await restoreFromRef( baseRelFiles.filter((f) => !f.endsWith('ModelSnapshot.cs')), spec.cwd, baseRef, ); if (restoreFailures.length) { warnings.push(`${asm.assemblyName}: could not restore ${restoreFailures.length} reference-branch file(s) from ${baseRef}.`); } // 4. Snapshot: reset it to the reference branch — OR, for an empty base // (the reference has NEVER carried this context: no snapshot AND no // inherited migrations), DELETE it so `dotnet ef migrations add` // regenerates a full InitialCreate. This mirrors the bruteForce path // (see the snapshot removal there) but with ZERO casualties — there are // no reference migrations to destroy. Without this branch the snapshot // restore below necessarily fails (the file is absent on the reference) // and leaves a half-squashed tree. let resetOk: boolean; if (emptyBase) { await removeFile(asm.snapshotPath); // backed up in step 1; safe — basePreserved === 0 resetOk = true; } else { resetOk = await resetSnapshotToBase(asm.snapshotPath, spec.cwd, baseRef); } if (!resetOk) { // The tree is mutated (branch-only deleted, refs restored) but the snapshot // step failed (e.g. a malformed base with migrations but no snapshot). // Roll back to a NO-OP rather than leaving a half-squashed tree. const rolledBack = await rollbackAssemblyFromBackup(asm.migrationsDir, assemblyBackup, spec.cwd); if (!rolledBack) { warnings.push(`${asm.assemblyName}: automatic rollback FAILED — restore manually from ${assemblyBackup}.`); } results.push( baseRow(c, { backupDir: assemblyBackup, deletedFiles: rolledBack ? [] : deleted, resetSnapshot: false, rolledBack, success: false, error: `could not restore ${asm.snapshotPath} from ${baseRef}` + (rolledBack ? ' — rolled back to the pre-run state (no-op).' : ''), }), ); continue; } // 5. Compute sequence (after restore → reference migration count) + name. const existingCount = (await listMigrationFilesInDir(asm.migrationsDir)).length; const newMigrationName = buildMigrationName({ contextName: spec.contextName, version: asm.version, sequence: existingCount + 1, description: spec.description, }); // 6. Regenerate via dotnet ef migrations add. const extraEnv = asm.provider ? { STUDIO_DESIGN_PROVIDER: capitalize(asm.provider) } : undefined; const args = buildMigrationsAddArgs({ migrationName: newMigrationName, csprojPath: asm.csprojPath, contextName: spec.contextName, migrationsDir: asm.migrationsDir, startupProject, }); const addRes = await runDotnet(args, spec.cwd, { timeoutMs: 5 * 60_000, extraEnv }); // If the regeneration failed, the tree is half-squashed (branch-only deleted, // refs restored, snapshot reset, no consolidated migration). Roll back to a // NO-OP so a failed squash never leaves a non-buildable migration set. if (addRes.exitCode !== 0) { const rolledBack = await rollbackAssemblyFromBackup(asm.migrationsDir, assemblyBackup, spec.cwd); if (!rolledBack) { warnings.push(`${asm.assemblyName}: automatic rollback FAILED — restore manually from ${assemblyBackup}.`); } results.push( baseRow(c, { backupDir: assemblyBackup, deletedFiles: rolledBack ? [] : deleted, resetSnapshot: false, emptyBaseModel: emptyBase, rolledBack, newMigrationName: null, dotnetOutput: `${addRes.stdout}${addRes.stderr ? `\n${addRes.stderr}` : ''}`, success: false, error: (addRes.stderr || addRes.stdout || 'dotnet ef migrations add failed') + (rolledBack ? ' — rolled back to the pre-run state (no-op).' : ''), }), ); continue; } // 7. Re-freeze the SQL objects (functions/views/procs) into the consolidated // migration. The branch-only migrations we just deleted may have carried // them; the model-diff regen never re-emits raw SQL, so without this the // squash would silently drop them. Idempotent CREATE OR ALTER → safe. let sqlObjectsInlined: string[] = []; try { const ensure = await ensureSqlObjectsInMigration({ projectDir: path.dirname(asm.csprojPath), migrationsDir: asm.migrationsDir, migrationName: newMigrationName, }); sqlObjectsInlined = ensure.inlined; if (ensure.unparsable.length > 0) { warnings.push( `${asm.assemblyName}: ${ensure.unparsable.length} .sql file(s) under SqlObjects have no CREATE [OR ALTER] header — not inlined: ${ensure.unparsable.join(', ')}`, ); } if (ensure.injected) { warnings.push( `${asm.assemblyName}: re-froze ${ensure.inlined.length} SQL object(s) into ${newMigrationName} (preserved through the squash): ${ensure.inlined.join(', ')}`, ); } } catch (e) { warnings.push(`${asm.assemblyName}: SQL-object inlining skipped (${(e as Error).message}).`); } results.push( baseRow(c, { backupDir: assemblyBackup, deletedFiles: deleted, resetSnapshot: true, emptyBaseModel: emptyBase, newMigrationName, sqlObjectsInlined, dotnetOutput: `${addRes.stdout}${addRes.stderr ? `\n${addRes.stderr}` : ''}`, success: true, }), ); } // 8. Audit trail: log every destructive run (anything deleted) and ALWAYS a // brute-force re-baseline, to .claude/Efcore-history. Never blocks the op. let historyLogPath: string | undefined; if (!spec.dryRun) { // A successful consolidation changes migration IDs; a dev DB that already // applied the old ones now has a stale __EFMigrationsHistory. Tell the user. const advisory = staleHistoryAdvisory(results); if (advisory) warnings.push(advisory); const histAssemblies: HistoryAssemblyEntry[] = results .filter((r) => bruteForce || r.deletedFiles.length > 0) .map((r) => ({ assemblyName: r.assemblyName, deletedMigrations: r.deletedFiles, parentMigrationsDestroyed: bruteForce ? r.parentMigrations : [], newMigrationName: r.newMigrationName, backupDir: r.backupDir, })); if (histAssemblies.length > 0) { const record = buildHistoryRecord({ timestamp: startedAtIso, bruteForce, rebaseSnapshot: mode === 'rebase-snapshot', confirmed: bruteForce ? spec.confirmBruteForce ?? false : false, cwd: detection.cwd, currentBranch, baseBranch: baseRef, branchType, assemblies: histAssemblies, }); const logged = await appendHistory(spec.cwd, record); if (logged.path) historyLogPath = logged.path; else warnings.push(`Could not write the audit record to .claude/Efcore-history (${logged.error}).`); } } const allOk = results.every((r) => r.success); return { success: allOk, cwd: detection.cwd, baseBranch: baseRef, branchType, currentBranch, behindBase, historyLogPath, assemblies: results, warnings, error: allOk ? undefined : 'One or more assemblies failed — inspect per-assembly error', }; }