/** * efcore-history.ts — Immutable audit trail for destructive migration squashes. * * Every squash that DELETES migration files (and ALWAYS a `-BruteForce` * re-baseline) appends one JSON record under `/.claude/Efcore-history/`. * One file per operation — never rewritten — so there is always a trace of who * rewrote migration history, when, against which reference branch, and exactly * which migrations were destroyed (including any that already lived on prod). * * `buildHistoryRecord` is PURE (timestamp injected) so it is unit-testable. * `appendHistory` does the I/O (git-root resolution + write) and never throws — * a logging failure must not abort a squash that already mutated the tree; it * returns `{ path: null, error }` so the caller can surface a warning. */ import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { ensureDirectory, writeText } from '../../../lib/fs.js'; const execFileAsync = promisify(execFile); export type HistoryOperation = 'squash' | 'squash-bruteforce' | 'rebase-snapshot'; export interface HistoryAssemblyEntry { assemblyName: string; /** Every migration file basename deleted in this assembly. */ deletedMigrations: string[]; /** * Reference/production migrations destroyed. Empty for a normal squash * (which never touches reference migrations); populated only by a brute-force * re-baseline — the genuinely dangerous deletions. */ parentMigrationsDestroyed: string[]; newMigrationName: string | null; backupDir: string | null; } export interface HistoryRecord { timestamp: string; operation: HistoryOperation; cwd: string; currentBranch: string; baseBranch: string; branchType: string; /** True for a `-BruteForce` re-baseline. */ bruteForce: boolean; /** True when the human passed the explicit confirmation token. */ confirmed: boolean; assemblies: HistoryAssemblyEntry[]; } export interface BuildHistoryInput { /** ISO timestamp — injected by the caller so the builder stays pure/testable. */ timestamp: string; bruteForce: boolean; /** True when invoked through the rebase-snapshot mode — labels the audit record. */ rebaseSnapshot?: boolean; confirmed: boolean; cwd: string; currentBranch: string; baseBranch: string; branchType: string; assemblies: HistoryAssemblyEntry[]; } /** Pure record builder — derives the operation kind from the inputs. */ export function buildHistoryRecord(input: BuildHistoryInput): HistoryRecord { const operation: HistoryOperation = input.bruteForce ? 'squash-bruteforce' : input.rebaseSnapshot ? 'rebase-snapshot' : 'squash'; return { timestamp: input.timestamp, operation, cwd: input.cwd, currentBranch: input.currentBranch, baseBranch: input.baseBranch, branchType: input.branchType, bruteForce: input.bruteForce, confirmed: input.confirmed, assemblies: input.assemblies, }; } /** Resolve the git work-tree root so the log always lands at the project's `.claude/`. Falls back to `cwd` when not in a git repo. */ async function gitTopLevel(cwd: string): Promise { try { const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8', }); return stdout.trim() || cwd; } catch { return cwd; } } /** * Append `record` to `/.claude/Efcore-history/_.json`. * Never throws — returns the written path, or `{ path: null, error }` on failure. */ export async function appendHistory( cwd: string, record: HistoryRecord, ): Promise<{ path: string | null; error?: string }> { try { const root = await gitTopLevel(cwd); const dir = path.join(root, '.claude', 'Efcore-history'); await ensureDirectory(dir); const safeStamp = record.timestamp.replace(/[:.]/g, '-'); const file = path.join(dir, `${safeStamp}_${record.operation}.json`); await writeText(file, `${JSON.stringify(record, null, 2)}\n`); return { path: file }; } catch (e) { return { path: null, error: (e as Error).message }; } }