/* RFC-0913: cache-clone .gitignore preservation — restore cache-clone-only .gitignore patterns after git merge and untrack forbidden/generated files that were re-tracked by the merge. Does not perform the merge itself — called by mission.reconcile after git merge --no-ff succeeds. Does not validate bundle contract — sternsystem.validate remains the authority on forbidden file detection. RFC-0913: initial module — CACHE_CLONE_GITIGNORE_SENTINEL, CACHE_CLONE_GENERATED_PATTERNS, CACHE_CLONE_ONLY_PATTERNS, restoreCacheCloneGitignore, untrackForbiddenGeneratedFiles. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1179: add releases/ to CACHE_CLONE_ONLY_PATTERNS so merge-restore keeps release evidence gitignored (ADR-0085 closed-workpiece exemption for deploy.smoke.run writes) RFC-1327: run-twice idempotency — declarations, command.idempotency.validate, rerun tests, reconcile/ship/abort convergence fixes RFC-1327: review r1 — lock-safe reconcile gate, residue sweep, honest idempotency classes */ import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import path from "node:path"; import { writeFileIfChanged } from "@warpgogol/werkstatt-engine/kernel"; import { FORBIDDEN_PATTERNS } from "../sternsystem/sternsystem-validate.ts"; export const CACHE_CLONE_GITIGNORE_SENTINEL = "# CACHE-CLONE-ONLY — do not remove"; export const CACHE_CLONE_GENERATED_PATTERNS: readonly string[] = [ "behavior.snapshot.generated.yaml", "bordbuch/status.generated.yaml", "public/.well-known/bordbuch/status.generated.yaml", "src/agent-capabilities.generated.json", "src/agent-capabilities.generated.yaml", "src/agent-surface.generated.json", "src/agent-surface.generated.yaml", "src/entitlements.generated.yaml", "src/env.schema.generated.mjs", "src/freshness.generated.yaml", "src/styles/biome.generated.css", "src/surface.generated.yaml", ]; export const CACHE_CLONE_ONLY_PATTERNS: readonly string[] = [ ...FORBIDDEN_PATTERNS.filter((p) => p !== "dist" && p !== "node_modules" && p !== "packages"), ...CACHE_CLONE_GENERATED_PATTERNS, // RFC-1179: release evidence must stay gitignored inside the site so the // ADR-0085 closed-workpiece exemption resolves declared writes[] for // deploy.smoke.run / site.smoke.run. "releases/", ]; export async function restoreCacheCloneGitignore( systemDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const gitignorePath = path.join(systemDir, ".gitignore"); if (!(await io.exists(gitignorePath))) { const newContent = CACHE_CLONE_GITIGNORE_SENTINEL + "\n" + CACHE_CLONE_ONLY_PATTERNS.join("\n") + "\n"; await writeFileIfChanged(gitignorePath, newContent); return true; } const content = await io.readFile(gitignorePath); if (content.includes(CACHE_CLONE_GITIGNORE_SENTINEL)) { return false; } const newContent = content.trimEnd() + "\n\n" + CACHE_CLONE_GITIGNORE_SENTINEL + "\n" + CACHE_CLONE_ONLY_PATTERNS.join("\n") + "\n"; await writeFileIfChanged(gitignorePath, newContent); return true; } export async function untrackForbiddenGeneratedFiles( systemDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const untracked: string[] = []; const args = CACHE_CLONE_ONLY_PATTERNS.map((p) => JSON.stringify(p)).join(" "); const bulk = await io.exec("sh", ["-c", `git rm --cached --quiet ${args}`], { cwd: systemDir }); if (bulk.exitCode === 0) { untracked.push(...CACHE_CLONE_ONLY_PATTERNS); } else { for (const pattern of CACHE_CLONE_ONLY_PATTERNS) { const single = await io.exec( "sh", ["-c", `git rm --cached --quiet ${JSON.stringify(pattern)}`], { cwd: systemDir }, ); if (single.exitCode === 0) untracked.push(pattern); } } // RFC-1327: untracked generated files left on disk become "dirty" again after // the next reconcile's preReconcileSha rewind strips the .gitignore block — // delete the regenerable copies so a re-run stays clean. Only the generated // set is removed: forbidden/clone-only entries (releases/, operator files) // may hold real evidence and are never deleted. for (const pattern of CACHE_CLONE_GENERATED_PATTERNS) { if (!untracked.includes(pattern)) continue; const abs = path.join(systemDir, pattern); if (await io.exists(abs)) { await io.rm(abs, { recursive: true }).catch(() => {}); } } return untracked; } /** * RFC-1327: delete on-disk copies of generated artifacts that git does not * track — residue left by pre-RFC-1327 untrack passes (or a .gitignore-stripping * rewind) would otherwise crash `git merge` with "untracked working tree files * would be overwritten". Regenerable files only; tracked files are never * touched. Call before fetch-and-merge, after any rewind. */ export async function sweepUntrackedGeneratedResidue( systemDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const swept: string[] = []; for (const pattern of CACHE_CLONE_GENERATED_PATTERNS) { const abs = path.join(systemDir, pattern); if (!(await io.exists(abs))) continue; const tracked = await io.exec("git", ["ls-files", "--error-unmatch", "--", pattern], { cwd: systemDir, }); if (tracked.exitCode === 0) continue; // tracked — merge owns it await io.rm(abs, { recursive: true }).catch(() => {}); swept.push(pattern); } return swept; }