import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { findFiles } from '../../../lib/fs.js'; import { detectDbContexts, findStartupProject, hasDotnetProject } from '../lib/detect-dbcontexts.js'; import { listMigrationsJson, dotnetAvailable, dotnetEfAvailable } from '../lib/ef-runner.js'; import { determineBaseBranch } from '../lib/squash-base.js'; import { findMissingReferenceMigrations, referenceMigrationNames } from '../lib/squash-integrity.js'; import type { AssemblyStatus, StatusResult, StatusSpec } from './types.js'; const execFileAsync = promisify(execFile); async function git(args: string[], cwd: string): Promise<{ stdout: string; exitCode: number }> { try { const { stdout } = await execFileAsync('git', args, { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }); return { stdout: stdout.trim(), exitCode: 0 }; } catch (err) { const e = err as { stdout?: string; code?: number }; return { stdout: (e.stdout || '').trim(), exitCode: typeof e.code === 'number' ? e.code : 1 }; } } /** * Resolve the reference branch BY TYPE — same GOLDEN RULE as the squash * (feature→develop, release/hotfix/develop→main). The legacy "develop first" * order mis-reported the branch-only count on release/hotfix branches. * * Returns `{ name, resolved }`. `resolved` is false when neither `origin/` * nor `` exists — the caller must then skip the branch-only computation * instead of treating every local migration as branch-only (finding #2). */ async function resolveBaseBranch( cwd: string, currentBranch: string, preferred?: string, ): Promise<{ name: string; resolved: boolean }> { const decision = determineBaseBranch(currentBranch); // On a protected branch nothing is "branch-only" → compare against itself. const wanted = preferred ?? (decision.blocked ? currentBranch : (decision.baseBranch as string)); for (const candidate of [`origin/${wanted}`, wanted]) { const r = await git(['rev-parse', '--verify', '--quiet', candidate], cwd); if (r.exitCode === 0 && r.stdout) return { name: candidate, resolved: true }; } return { name: wanted, resolved: false }; } /** Pure migration `.cs` files on disk 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 MigrationDiff { /** ✂️ Migrations unique to the current branch (tracked, not in the reference). */ branchOnly: string[]; /** 🔒 Reference-branch migration names (the preserved/untouchable set). */ parentMigrations: string[]; /** ☠️ Reference migrations absent from the working tree — a merge would drop them from prod. */ missingFromWorkingTree: string[]; } async function diffMigrations(migrationsDir: string, cwd: string, base: string): Promise { const empty: MigrationDiff = { branchOnly: [], parentMigrations: [], missingFromWorkingTree: [] }; const rel = path.relative(cwd, migrationsDir).replace(/\\/g, '/'); if (!rel || rel.startsWith('..')) return empty; const baseResult = await git(['ls-tree', '-r', '--name-only', base, rel], cwd); // If the reference tree can't be read, we can't tell what's branch-only — // report nothing rather than flag every migration (finding #2). if (baseResult.exitCode !== 0) return empty; const basePaths = baseResult.stdout .split('\n') .filter((l) => l.endsWith('.cs') && !l.endsWith('ModelSnapshot.cs') && !l.endsWith('.Designer.cs')); const baseSet = new Set(basePaths); // branch-only is computed against TRACKED files (git ls-files), preserving the // prior semantics; missing-from-working-tree is computed against DISK truth. const current = await git(['ls-files', rel], cwd); const currentSet = new Set( (current.exitCode === 0 ? current.stdout.split('\n') : []) .filter((l) => l.endsWith('.cs') && !l.endsWith('ModelSnapshot.cs') && !l.endsWith('.Designer.cs')), ); const onDisk = await listMigrationFilesInDir(migrationsDir); return { branchOnly: Array.from(currentSet) .filter((f) => !baseSet.has(f)) .map((f) => path.basename(f, '.cs')), parentMigrations: referenceMigrationNames(basePaths), missingFromWorkingTree: findMissingReferenceMigrations(basePaths, onDisk), }; } async function isSnapshotDirty(snapshotPath: string, cwd: string): Promise { const rel = path.relative(cwd, snapshotPath).replace(/\\/g, '/'); const r = await git(['status', '--porcelain', '--', rel], cwd); return r.stdout.length > 0; } function capitalize(s: string): string { if (!s) return s; if (s.toLowerCase() === 'sqlserver') return 'SqlServer'; return s.charAt(0).toUpperCase() + s.slice(1); } export async function execute(spec: StatusSpec): Promise { const warnings: string[] = []; if (!(await hasDotnetProject(spec.cwd))) { return { success: true, cwd: spec.cwd, kind: 'unknown', currentBranch: '', baseBranch: '', baseResolved: false, behindBase: 0, assemblies: [], warnings: ['No .csproj found in worktree.'], }; } if (!(await dotnetAvailable())) { return { success: false, cwd: spec.cwd, kind: 'unknown', currentBranch: '', baseBranch: '', baseResolved: false, behindBase: 0, assemblies: [], warnings, error: 'dotnet CLI is not available on PATH', }; } if (!(await dotnetEfAvailable())) { warnings.push('dotnet-ef is not installed globally — install via: dotnet tool install --global dotnet-ef'); } const currentBranch = (await git(['branch', '--show-current'], spec.cwd)).stdout || 'HEAD'; const branchType = determineBaseBranch(currentBranch).branchType; const { name: baseBranch, resolved: baseResolved } = await resolveBaseBranch(spec.cwd, currentBranch, spec.baseBranch); // Behind-base reporting (finding #1) — squashing while behind the reference // can DROP reference-branch migrations. let behindBase = 0; if (baseResolved) { const r = await git(['rev-list', '--count', `HEAD..${baseBranch}`], spec.cwd); if (r.exitCode === 0) { const n = Number(r.stdout.trim()); behindBase = Number.isFinite(n) ? n : 0; } if (behindBase > 0) { warnings.push( `⚠️ "${currentBranch}" is ${behindBase} commit(s) behind "${baseBranch}". ` + `Sync before squashing (e.g. /gitflow sync) — squashing while behind can DROP reference-branch migrations.`, ); } } else { warnings.push( `Reference branch "${baseBranch}" not found (fetch it: git fetch origin ${baseBranch.replace(/^origin\//, '')}). ` + `branch-only migrations were not computed.`, ); } const detection = await detectDbContexts(spec.cwd); const startupProject = await findStartupProject(spec.cwd); const assemblies: AssemblyStatus[] = []; for (const ctx of detection.contexts) { for (const asm of ctx.assemblies) { const extraEnv = asm.provider ? { STUDIO_DESIGN_PROVIDER: capitalize(asm.provider) } : undefined; const listResult = await listMigrationsJson(spec.cwd, { context: ctx.name, projectPath: asm.csprojPath, startupProjectPath: startupProject ?? undefined, extraEnv, }); const diff = baseResolved ? await diffMigrations(asm.migrationsDir, spec.cwd, baseBranch) : { branchOnly: [], parentMigrations: [], missingFromWorkingTree: [] }; const snapshotDirty = await isSnapshotDirty(asm.snapshotPath, spec.cwd); assemblies.push({ assemblyName: asm.assemblyName, contextName: ctx.name, migrationsDir: asm.migrationsDir, provider: asm.provider, version: asm.version, total: listResult.migrations.length, applied: listResult.migrations.filter((m) => m.applied).length, pending: listResult.migrations.filter((m) => !m.applied).length, branchOnly: diff.branchOnly, parentMigrations: diff.parentMigrations, missingFromWorkingTree: diff.missingFromWorkingTree, snapshotDirty, error: listResult.success ? undefined : listResult.error, }); } } // The prod-break signal: any reference migration missing from the working // tree means a manual re-baseline already removed an already-applied // migration. Surface it FIRST and loudly — a merge would drop it from prod. const missing = Array.from(new Set(assemblies.flatMap((a) => a.missingFromWorkingTree))); if (missing.length > 0) { warnings.unshift( `☠️ DANGER: ${missing.length} migration(s) exist on "${baseBranch}" but are MISSING from your working tree (${missing.join(', ')}). ` + `A merge would DROP them from production. Restore them (git checkout ${baseBranch} -- ), ` + `or — only for a deliberate release re-baseline — use /efcore -BruteForce (logged to .claude/Efcore-history). DO NOT merge as-is.`, ); } return { success: true, cwd: detection.cwd, kind: detection.kind, currentBranch, baseBranch, baseResolved, behindBase, branchType, assemblies, warnings, }; }