/*
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;
}