/** * git-file.ts — Restore a file AT A GIT REF into the working tree WITHOUT * touching the index. * * THE INCIDENT (2026-08-28). The squash restored the reference-branch migrations * with `git checkout -- `, which writes the index AND the worktree. * It then re-ran `dotnet ef migrations add`, which rewrites the snapshot ON DISK * ONLY. Result: `MM` on `…ModelSnapshot.cs` — the STALE base snapshot staged, * the regenerated one on disk (39 770 lines indexed vs 40 057 on disk). A commit * takes the index, so `/gitflow commit` silently shipped the stale snapshot over * the fresh one, and the next `migrations add` scaffolded a false diff. * * `git cat-file --filters :` renders the blob in WORKING-TREE form — * EOL + smudge filters applied, honouring the TARGET repo's `.gitattributes` and * `core.autocrlf`, neither of which we control — and writes nothing anywhere. * * ⚠️ `git show :` is NOT equivalent and must not be substituted: it * returns the RAW blob (LF). On an `autocrlf=true` checkout that rewrites every * inherited migration with LF endings, marking them all as modified — the * "faux modifiés CRLF" churn. Verified: `git show HEAD:package.json` → `{\n`, * `git cat-file --filters HEAD:package.json` → `{\r\n`. * * For the same three reasons this does NOT reuse `lib/git.ts` `getFileFromBranch()`: * it is `git show` (LF), it `.trim()`s the content (eats the trailing newline), * and it shell-interpolates the path. */ import path from 'node:path'; import fs from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { ensureDirectory } from '../../../lib/fs.js'; const execFileAsync = promisify(execFile); /** Snapshots run to ~2 MB; keep the same ceiling as squash/execute.ts's `git()`. */ const MAX_BUFFER = 20 * 1024 * 1024; export interface GitBufferResult { stdout: Buffer; stderr: string; exitCode: number; } /** Runs a git command and returns raw bytes. Injectable so callers can be unit-tested without a repo. */ export type GitBufferRunner = (args: string[], cwd: string) => Promise; /** Writes raw bytes to an absolute path, creating parent directories. */ export type FileWriter = (absPath: string, content: Buffer) => Promise; export interface RestoreDeps { run?: GitBufferRunner; write?: FileWriter; } /** * The argv that reads `rel` at `baseRef` in working-tree form. * * Pure + exported so the "never writes the index" invariant is unit-testable: * this must never become `checkout`, `add`, `reset` or `restore`. */ export function catFileFiltersArgs(baseRef: string, rel: string): string[] { return ['cat-file', '--filters', `${baseRef}:${rel}`]; } /** Byte-exact capture — no `.trim()`, the trailing newline is content, not noise. */ const defaultRun: GitBufferRunner = async (args, cwd) => { try { const { stdout, stderr } = await execFileAsync('git', args, { cwd, encoding: 'buffer', maxBuffer: MAX_BUFFER, }); return { stdout: stdout as unknown as Buffer, stderr: (stderr as unknown as Buffer).toString('utf-8').trim(), exitCode: 0, }; } catch (err) { const e = err as { stdout?: Buffer; stderr?: Buffer; code?: number }; return { stdout: e.stdout ?? Buffer.alloc(0), stderr: (e.stderr ?? Buffer.alloc(0)).toString('utf-8').trim(), exitCode: typeof e.code === 'number' ? e.code : 1, }; } }; const defaultWrite: FileWriter = async (absPath, content) => { // `git checkout` created missing directories; `fs.writeFile` does not — and a // `.Designer.cs` the reference carries may be absent from the working tree. await ensureDirectory(path.dirname(absPath)); // Buffer, never writeText(): that helper is string/utf-8 and would re-encode. await fs.writeFile(absPath, content); }; /** * Read `rel` (cwd-relative, forward slashes) as it exists on `baseRef`, in * working-tree form. Throws when the path does not exist on the ref * (`fatal: Not a valid object name` → exit 128) so callers can fail closed. */ export async function readFileAtRef( rel: string, cwd: string, baseRef: string, run: GitBufferRunner = defaultRun, ): Promise { const r = await run(catFileFiltersArgs(baseRef, rel), cwd); if (r.exitCode !== 0) { throw new Error(`git cat-file --filters ${baseRef}:${rel} failed${r.stderr ? ` — ${r.stderr}` : ''}`); } return r.stdout; } /** * Restore every path in `relFiles` from `baseRef` into the working tree. * Returns the paths that FAILED — the caller keeps its existing fail-closed * behaviour (warn / roll back). One failure never aborts the loop. * * The index is never written, so a rollback that only restores files really is * the no-op `types.ts` claims it is. */ export async function restoreFilesFromRef( relFiles: string[], cwd: string, baseRef: string, deps: RestoreDeps = {}, ): Promise { const run = deps.run ?? defaultRun; const write = deps.write ?? defaultWrite; const failed: string[] = []; for (const rel of relFiles) { try { const content = await readFileAtRef(rel, cwd, baseRef, run); await write(path.join(cwd, rel), content); } catch { failed.push(rel); } } return failed; }