/** * squash-policy.ts — Pure decision helpers for the squash / rebase-snapshot * flow. No I/O, no git, no dotnet — every function here is deterministic so it * can be unit-tested in isolation. The side-effecting parts (git tree reads, * file delete/restore, `dotnet ef migrations add`) live in squash/execute.ts. */ export type SquashMode = 'squash' | 'rebase-snapshot'; /** * Minimum number of branch-only migrations required to act. * * - `squash` → 2 : consolidating a single migration is a no-op. * - `rebase-snapshot` → 1 : its job is to REPAIR a conflicted snapshot, so it * must regenerate even a single branch-only migration. With zero branch-only * migrations the caller still resets the snapshot to the base (see execute). */ export function minBranchOnly(mode: SquashMode): number { return mode === 'rebase-snapshot' ? 1 : 2; } export interface BehindBaseInput { /** How many commits the current branch is behind its reference branch. */ behindBase: number; /** Deliberate user override (escape hatch). */ allowBehindBase: boolean; currentBranch: string; /** The reference ref actually used, e.g. "origin/develop". */ baseRef: string; mode: SquashMode; } /** * Decide whether the operation must refuse because the current branch is behind * its reference branch. * * Squashing a branch that has NOT integrated the latest reference migrations is * a data-loss hazard: the flow resets the snapshot to the reference tip and * regenerates one migration from the current *code*. If the code lacks the * reference's latest entity changes, the regenerated migration tries to REVERT * them (drop the tables/columns the reference added) — and that revert lands in * production at merge time. * * Returns a human-readable blocker message when it must refuse, or `null` when * it is safe (in sync, or explicitly overridden). */ export function behindBaseBlocker(input: BehindBaseInput): string | null { const { behindBase, allowBehindBase, currentBranch, baseRef, mode } = input; if (behindBase <= 0) return null; if (allowBehindBase) return null; const verb = mode === 'rebase-snapshot' ? 'rebase-snapshot' : 'squash'; return ( `"${currentBranch}" is ${behindBase} commit(s) behind "${baseRef}". ` + `Running ${verb} now would re-anchor the snapshot on ${baseRef} and regenerate a migration ` + `from code that lacks ${baseRef}'s latest changes — the consolidated migration could DROP ` + `what ${baseRef} added (data loss at merge). ` + `Sync first (e.g. /gitflow sync, or merge/rebase ${baseRef}), ` + `or pass "allowBehindBase": true to override deliberately.` ); } export type SnapshotAction = 'reset-to-base' | 'delete-for-initial-create'; export interface SnapshotActionInput { /** Does the reference branch carry a `…ModelSnapshot.cs` for this context? */ baseHasSnapshot: boolean; /** Count of migrations inherited from the reference branch (0 ⇒ empty base). */ basePreserved: number; } /** * Decide what the NORMAL squash must do with the model snapshot. * * - `reset-to-base` (default): the reference branch is the model's starting * point — restore its `ModelSnapshot.cs` verbatim so the regenerated * migration diffs against it. This is the common case (the branch adds * migrations on top of a context the base already carries). * - `delete-for-initial-create`: the reference branch has NEVER carried this * context (no snapshot AND no inherited migrations — an "empty base", normal * on the FIRST feature branch that introduces a new DbContext). There is * nothing to restore; delete the working-tree snapshot so * `dotnet ef migrations add` regenerates a full InitialCreate. Mirrors the * bruteForce path — but with ZERO casualties (no reference migration exists * to delete). * * The delete branch is gated on `basePreserved === 0` as a hard invariant: a * snapshot that encodes inherited reference migrations is NEVER deleted. A * malformed base (migrations present but snapshot absent) falls through to * `reset-to-base`, which then fails loudly rather than silently dropping * inherited history. */ export function decideSnapshotAction(input: SnapshotActionInput): SnapshotAction { if (!input.baseHasSnapshot && input.basePreserved === 0) { return 'delete-for-initial-create'; } return 'reset-to-base'; } /** * Pure core of the atomic rollback (Part E of the empty-base bug report). Given * the migration files currently on disk and the files captured in the pre-run * backup (both as cwd-relative, forward-slash paths), return the files that must * be DELETED to restore the pre-run state — everything present now that the * backup does NOT contain (typically the consolidated migration + its * `.Designer.cs` that `dotnet ef migrations add` wrote before the run failed). * Files already in the backup are restored by copy, not listed here. */ export function rollbackRemovals(currentRelPaths: string[], backedUpRelPaths: string[]): string[] { const backedUp = new Set(backedUpRelPaths); return currentRelPaths.filter((p) => !backedUp.has(p)); } /** * Idempotently ensure `line` is present in a `.gitignore` body. * * Returns the new content (with a single trailing newline) when the line must * be added, or `null` when it is already present (so the caller can skip the * write). Trailing slashes are ignored for the presence check, so * `.efcore-squash-backup` and `.efcore-squash-backup/` are treated as equal. * Pure string transform — no I/O. */ export function withGitignoreLine(existing: string | null, line: string): string | null { const norm = (s: string): string => s.trim().replace(/\/+$/, ''); const target = line.trim(); const lines = (existing ?? '').split(/\r?\n/); if (lines.some((l) => norm(l) !== '' && norm(l) === norm(target))) return null; const base = existing && existing.trim().length > 0 ? existing.replace(/\s*$/, '') + '\n' : ''; return `${base}${target}\n`; } /** * Build the "your local dev DB history is now stale" advisory for a completed * squash. A squash that folds N migrations into a single consolidated one * changes their identifiers, so a dev database that has ALREADY applied the old * migrations is left with an `__EFMigrationsHistory` that lists IDs no longer * present on disk (the physical schema is fine; only the history is stale). * * The CLI cannot know whether the dev DB applied them, so the message is phrased * conditionally. Returns the advisory when at least one assembly actually * consolidated (succeeded, produced a new migration, and replaced ≥1 existing * migration), or `null` otherwise (no-op / failure). Callers must skip it on a * dry run. Pure — no I/O. */ export function staleHistoryAdvisory( rows: Array<{ success: boolean; newMigrationName: string | null; deletedFiles: string[] }>, ): string | null { const consolidated = rows.filter( (r) => r.success && r.newMigrationName && r.deletedFiles.length > 0, ); if (consolidated.length === 0) return null; const replaced = consolidated.reduce((n, r) => n + r.deletedFiles.length, 0); const names = consolidated.map((r) => r.newMigrationName).join(', '); const one = consolidated.length === 1; return ( `Dev DB history may now be stale: this squash replaced ${replaced} migration file(s) ` + `with ${one ? 'a single consolidated migration' : 'consolidated migrations'} (${names}). ` + `If you have already applied the old migrations to your local dev database, its ` + `__EFMigrationsHistory still lists the old IDs — recreate the dev DB ` + `(drop → reapply Core + the consolidated migration → reseed) to realign. ` + `No data loss if the database holds no real data.` ); }