/*
Closed-mission workpiece immutability (ADR-0085, ADR-0087): detect the `.closed`
sentinel, reduce declared `writes[]` patterns to concrete paths, batch-resolve
gitignore status via `git check-ignore --stdin`, and decide whether a mutating
command may run on a closed workpiece. Shared by execute-pipeline.ts (step-level
early skip for reporting/telemetry) and execute-command.ts (executor-level
enforcement floor for all invocation paths, including direct executeKernelCommand
calls).
- Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file.
- Leaf module — imported by both execute-command.ts and execute-pipeline.ts; must not import either (cycle).
- Gitignored-writes exemption: mutating commands run only when every declared write resolves gitignored — uncommittable churn is impossible.
- No git tracking (collectGitignoredPaths → null) means no write can churn a commit — the command runs.
- ADR-0087: extract closed-workpiece helpers from execute-pipeline.ts into this leaf module so execute-command.ts can enforce the same guard without an import cycle; add mutatingCommandRunsOnClosedWorkpiece (single-call predicate for the executor path).
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
*/
import { join } from "node:path";
import type {
DiscoveredSiteWorkspace,
KernelCommandDefinition,
} from "@warpgogol/werkstatt-shared/kernel";
import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
// @ai-invariant: Closed-mission workpieces are immutable — a mutating command may run only when every declared write resolves to a gitignored path.
export const CLOSED_WORKPIECE_SENTINEL = ".closed";
/**
* ADR-0085: detect a closed-mission workpiece via the `.closed` sentinel
* written by mission.close (RFC-0878). A closed workpiece is immutable —
* writes to git-tracked files can never be committed and produce only
* uncommittable churn.
*/
export async function isClosedWorkpiece(siteDirectory: string, io: WorkspaceIO): Promise {
return io.exists(join(siteDirectory, CLOSED_WORKPIECE_SENTINEL));
}
/**
* ADR-0085: reduce a declared `writes[]` pattern to a concrete path (relative
* to the site directory) suitable for `git check-ignore`. The `/` prefix
* is stripped, `{placeholder}` segments become a literal, and the path is cut
* at the first glob character — `git check-ignore` matches parent directories,
* so `dist/client` is reported ignored when `dist/` is ignored.
* Returns null for patterns outside the site directory (non-`` prefixes)
* or patterns that cannot be reduced to a concrete prefix.
*/
export function writePatternToCheckPath(pattern: string, siteName: string): string | null {
if (!pattern.startsWith("/")) return null;
let rel = pattern.slice("/".length);
rel = rel.replaceAll("{app}", siteName).replace(/\{[a-zA-Z0-9_-]+\}/g, "x");
const globIndex = rel.search(/[*?[\]]/);
if (globIndex >= 0) rel = rel.slice(0, globIndex);
rel = rel.replace(/\/+$/, "");
return rel.length > 0 ? rel : null;
}
/**
* ADR-0085: batch-resolve which candidate paths are gitignored inside the site
* directory via a single `git check-ignore --stdin` call. Returns null when
* the site directory is not a git repository or git is unavailable — without
* git tracking no write can produce committable churn, so callers treat every
* path as untracked.
*/
export async function collectGitignoredPaths(
siteDirectory: string,
candidatePaths: string[],
io: WorkspaceIO,
): Promise | null> {
if (candidatePaths.length === 0) return new Set();
try {
const result = await io.exec("git", ["check-ignore", "--", ...candidatePaths], {
cwd: siteDirectory,
});
if (result.exitCode === 1) return new Set();
if (result.exitCode !== 0) return null;
return new Set(
result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
);
} catch {
// ENOENT (no git binary) → no git tracking at all → null (nothing can
// churn a non-repo workpiece).
return null;
}
}
/**
* ADR-0085: a mutating step may run on a closed workpiece only when every
* declared write resolves to a gitignored path inside the site directory —
* such writes cannot produce committable churn and are required by
* release.prepare's rebuild path (passport.emit, dist mutators). Steps with
* no declared writes, writes outside the site directory, or writes to tracked
* paths are skipped. `ignoredPaths === null` means no git tracking — all
* writes are uncommittable, so the step runs.
*/
export function mutatingStepRunsOnClosedWorkpiece(
command: KernelCommandDefinition,
siteName: string,
ignoredPaths: Set | null,
): boolean {
if (ignoredPaths === null) return true;
const writes = command.writes ?? [];
if (writes.length === 0) return false;
return writes.every((pattern) => {
const checkPath = writePatternToCheckPath(pattern, siteName);
return checkPath !== null && ignoredPaths.has(checkPath);
});
}
/**
* ADR-0087: single-call predicate for the executor path — resolves gitignore
* status internally (one `git check-ignore --stdin` spawn per call, only on
* closed workpieces). The pipeline keeps its own batched resolution across
* all steps; direct executeKernelCommand callers get the same decision here.
*/
export async function mutatingCommandRunsOnClosedWorkpiece(
command: KernelCommandDefinition,
site: DiscoveredSiteWorkspace,
io: WorkspaceIO,
): Promise {
const candidates = (command.writes ?? [])
.map((pattern) => writePatternToCheckPath(pattern, site.name))
.filter((p): p is string => p !== null);
const ignoredPaths = await collectGitignoredPaths(site.directory, candidates, io);
return mutatingStepRunsOnClosedWorkpiece(command, site.name, ignoredPaths);
}