test(repo): cover worktree hygiene guardrails

This commit is contained in:
2026-04-01 09:02:37 +02:00
parent e75f69bcf5
commit b2568a3cb4
4 changed files with 185 additions and 38 deletions
+3
View File
@@ -35,6 +35,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Run repo script tests
run: pnpm test:scripts
- name: Check architecture guardrails - name: Check architecture guardrails
run: pnpm check:architecture run: pnpm check:architecture
+1
View File
@@ -11,6 +11,7 @@
"test": "node ./scripts/run-from-workspace-root.mjs turbo run test:unit", "test": "node ./scripts/run-from-workspace-root.mjs turbo run test:unit",
"test:unit": "node ./scripts/run-from-workspace-root.mjs turbo test:unit", "test:unit": "node ./scripts/run-from-workspace-root.mjs turbo test:unit",
"test:e2e": "node ./scripts/run-from-workspace-root.mjs turbo test:e2e", "test:e2e": "node ./scripts/run-from-workspace-root.mjs turbo test:e2e",
"test:scripts": "node --test scripts/*.test.mjs",
"check:architecture": "node ./scripts/check-architecture-guardrails.mjs", "check:architecture": "node ./scripts/check-architecture-guardrails.mjs",
"check:exports": "node ./scripts/check-workspace-exports.mjs", "check:exports": "node ./scripts/check-workspace-exports.mjs",
"check:imports": "node ./scripts/check-workspace-imports.mjs", "check:imports": "node ./scripts/check-workspace-imports.mjs",
+88 -38
View File
@@ -2,9 +2,10 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import process from "node:process"; import process from "node:process";
import { pathToFileURL } from "node:url";
function printUsage() { export function renderUsage() {
console.log(`Usage: return `Usage:
node scripts/worktree-hygiene.mjs [--scope <path>]... [--allow-outside-scope] [--fail-outside-scope] [--fail-on-dirty] [--json] node scripts/worktree-hygiene.mjs [--scope <path>]... [--allow-outside-scope] [--fail-outside-scope] [--fail-on-dirty] [--json]
Examples: Examples:
@@ -13,19 +14,20 @@ Examples:
node scripts/worktree-hygiene.mjs --scope docs/ --scope scripts/ --fail-outside-scope node scripts/worktree-hygiene.mjs --scope docs/ --scope scripts/ --fail-outside-scope
node scripts/worktree-hygiene.mjs --fail-on-dirty node scripts/worktree-hygiene.mjs --fail-on-dirty
node scripts/worktree-hygiene.mjs --scope packages/api/src/router/ --allow-outside-scope node scripts/worktree-hygiene.mjs --scope packages/api/src/router/ --allow-outside-scope
`); `;
} }
function normalizeScope(scope) { export function normalizeScope(scope) {
return scope.replace(/\\/gu, "/").replace(/^\.\/+/u, ""); return scope.replace(/\\/gu, "/").replace(/^\.\/+/u, "");
} }
function parseArgs(argv) { export function parseArgs(argv) {
const scopes = []; const scopes = [];
let allowOutsideScope = false; let allowOutsideScope = false;
let failOutsideScope = false; let failOutsideScope = false;
let failOnDirty = false; let failOnDirty = false;
let json = false; let json = false;
let help = false;
for (let index = 0; index < argv.length; index += 1) { for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]; const arg = argv[index];
@@ -58,23 +60,23 @@ function parseArgs(argv) {
continue; continue;
} }
if (arg === "--help" || arg === "-h") { if (arg === "--help" || arg === "-h") {
printUsage(); help = true;
process.exit(0); continue;
} }
throw new Error(`Unknown argument: ${arg}`); throw new Error(`Unknown argument: ${arg}`);
} }
return { scopes, allowOutsideScope, failOutsideScope, failOnDirty, json }; return { scopes, allowOutsideScope, failOutsideScope, failOnDirty, json, help };
} }
function runGit(args) { export function runGit(args) {
return execFileSync("git", args, { return execFileSync("git", args, {
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
} }
function parsePorcelain(output) { export function parsePorcelain(output) {
return output return output
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
@@ -90,11 +92,11 @@ function parsePorcelain(output) {
}); });
} }
function matchesScope(path, scopes) { export function matchesScope(path, scopes) {
return scopes.some((scope) => path === scope || path.startsWith(scope.endsWith("/") ? scope : `${scope}/`)); return scopes.some((scope) => path === scope || path.startsWith(scope.endsWith("/") ? scope : `${scope}/`));
} }
function summarize(entries) { export function summarize(entries) {
const summary = { const summary = {
staged: 0, staged: 0,
unstaged: 0, unstaged: 0,
@@ -117,18 +119,14 @@ function summarize(entries) {
return summary; return summary;
} }
function formatEntries(title, entries) { export function formatEntries(title, entries) {
if (entries.length === 0) { if (entries.length === 0) {
return `${title}: none`; return `${title}: none`;
} }
return `${title}:\n${entries.map((entry) => `- ${entry.xy} ${entry.path}`).join("\n")}`; return `${title}:\n${entries.map((entry) => `- ${entry.xy} ${entry.path}`).join("\n")}`;
} }
function main() { function renderReport({ root, branch, scopes, allowOutsideScope, failOutsideScope, failOnDirty, json, entries }) {
const { scopes, allowOutsideScope, failOutsideScope, failOnDirty, json } = parseArgs(process.argv.slice(2));
const root = runGit(["rev-parse", "--show-toplevel"]).trim();
const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
const entries = parsePorcelain(runGit(["status", "--short"]));
const inScope = scopes.length === 0 ? entries : entries.filter((entry) => matchesScope(entry.path, scopes)); const inScope = scopes.length === 0 ? entries : entries.filter((entry) => matchesScope(entry.path, scopes));
const outOfScope = scopes.length === 0 ? [] : entries.filter((entry) => !matchesScope(entry.path, scopes)); const outOfScope = scopes.length === 0 ? [] : entries.filter((entry) => !matchesScope(entry.path, scopes));
const status = { const status = {
@@ -143,44 +141,96 @@ function main() {
failOnDirty, failOnDirty,
}; };
const lines = [];
if (json) { if (json) {
console.log(JSON.stringify({ ...status, entries: { inScope, outOfScope } }, null, 2)); lines.push(JSON.stringify({ ...status, entries: { inScope, outOfScope } }, null, 2));
} else { } else {
console.log(`Repository: ${root}`); lines.push(`Repository: ${root}`);
console.log(`Branch: ${branch}`); lines.push(`Branch: ${branch}`);
console.log(`Dirty entries: ${entries.length}`); lines.push(`Dirty entries: ${entries.length}`);
console.log( lines.push(
`Summary: staged=${status.totals.staged}, unstaged=${status.totals.unstaged}, untracked=${status.totals.untracked}`, `Summary: staged=${status.totals.staged}, unstaged=${status.totals.unstaged}, untracked=${status.totals.untracked}`,
); );
if (scopes.length > 0) { if (scopes.length > 0) {
console.log(`Owned scope: ${scopes.join(", ")}`); lines.push(`Owned scope: ${scopes.join(", ")}`);
console.log(formatEntries("In scope", inScope)); lines.push(formatEntries("In scope", inScope));
console.log(formatEntries("Outside scope", outOfScope)); lines.push(formatEntries("Outside scope", outOfScope));
if (outOfScope.length > 0 && !allowOutsideScope) { if (outOfScope.length > 0 && !allowOutsideScope) {
console.log("\nResult: outside-scope changes detected."); lines.push("", "Result: outside-scope changes detected.");
} else if (outOfScope.length > 0) { } else if (outOfScope.length > 0) {
console.log("\nResult: outside-scope changes detected but tolerated by flag."); lines.push("", "Result: outside-scope changes detected but tolerated by flag.");
} else { } else {
console.log("\nResult: all dirty files are inside the declared scope."); lines.push("", "Result: all dirty files are inside the declared scope.");
} }
} else { } else {
console.log(formatEntries("Dirty files", entries)); lines.push(formatEntries("Dirty files", entries));
} }
} }
let exitCode = 0;
if (failOnDirty && entries.length > 0) { if (failOnDirty && entries.length > 0) {
process.exit(1); exitCode = 1;
} }
if (scopes.length > 0 && outOfScope.length > 0 && (failOutsideScope || !allowOutsideScope)) { if (scopes.length > 0 && outOfScope.length > 0 && (failOutsideScope || !allowOutsideScope)) {
process.exit(2); exitCode = 2;
}
return {
exitCode,
output: lines.join("\n"),
status,
entries: {
inScope,
outOfScope,
},
};
}
export function inspectWorktreeHygiene(argv, git = runGit) {
const { scopes, allowOutsideScope, failOutsideScope, failOnDirty, json, help } = parseArgs(argv);
if (help) {
return {
exitCode: 0,
output: renderUsage(),
status: null,
entries: {
inScope: [],
outOfScope: [],
},
};
}
const root = git(["rev-parse", "--show-toplevel"]).trim();
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
const entries = parsePorcelain(git(["status", "--short"]));
return renderReport({
root,
branch,
scopes,
allowOutsideScope,
failOutsideScope,
failOnDirty,
json,
entries,
});
}
function main() {
const result = inspectWorktreeHygiene(process.argv.slice(2));
console.log(result.output);
if (result.exitCode !== 0) {
process.exit(result.exitCode);
} }
} }
try { if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main(); try {
} catch (error) { main();
console.error(error instanceof Error ? error.message : String(error)); } catch (error) {
printUsage(); console.error(error instanceof Error ? error.message : String(error));
process.exit(1); console.error(renderUsage());
process.exit(1);
}
} }
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
inspectWorktreeHygiene,
parseArgs,
parsePorcelain,
} from "./worktree-hygiene.mjs";
function createGitStub(statusOutput) {
return (args) => {
if (args[0] === "rev-parse" && args[1] === "--show-toplevel") {
return "/tmp/capakraken\n";
}
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") {
return "main\n";
}
if (args[0] === "status" && args[1] === "--short") {
return statusOutput;
}
throw new Error(`Unexpected git args: ${args.join(" ")}`);
};
}
describe("worktree hygiene", () => {
it("fails when a scope value is missing", () => {
assert.throws(() => parseArgs(["--scope"]), /--scope requires a path value\./);
});
it("fails on unknown arguments", () => {
assert.throws(() => parseArgs(["--wat"]), /Unknown argument: --wat/);
});
it("normalizes rename targets in porcelain output", () => {
assert.deepEqual(parsePorcelain("R docs/old.md -> docs/new.md\n"), [
{
xy: "R ",
path: "docs/new.md",
},
]);
});
it("returns exit code 1 when fail-on-dirty is enabled", () => {
const result = inspectWorktreeHygiene(
["--fail-on-dirty"],
createGitStub(" M scripts/worktree-hygiene.mjs\n?? scripts/worktree-hygiene.test.mjs\n"),
);
assert.equal(result.exitCode, 1);
assert.match(result.output, /Dirty entries: 2/);
assert.equal(result.status?.totals.untracked, 1);
});
it("returns exit code 2 when dirty files escape the declared scope", () => {
const result = inspectWorktreeHygiene(
["--scope", "docs/", "--fail-outside-scope"],
createGitStub(" M docs/guide.md\n M scripts/worktree-hygiene.mjs\n"),
);
assert.equal(result.exitCode, 2);
assert.equal(result.entries.outOfScope.length, 1);
assert.match(result.output, /outside-scope changes detected\./);
});
it("keeps fail-outside-scope authoritative even when allow-outside-scope is set", () => {
const result = inspectWorktreeHygiene(
["--scope", ".\\docs", "--allow-outside-scope", "--fail-outside-scope", "--json"],
createGitStub(" M docs/guide.md\n M scripts/worktree-hygiene.mjs\n"),
);
assert.equal(result.exitCode, 2);
const parsed = JSON.parse(result.output);
assert.equal(parsed.scopes[0], "docs");
assert.equal(parsed.inScope, 1);
assert.equal(parsed.outOfScope, 1);
});
it("tolerates outside-scope files when explicitly allowed and emits json", () => {
const result = inspectWorktreeHygiene(
["--scope", "docs/", "--allow-outside-scope", "--json"],
createGitStub("R docs/old.md -> docs/new.md\n?? scripts/worktree-hygiene.test.mjs\n"),
);
assert.equal(result.exitCode, 0);
const parsed = JSON.parse(result.output);
assert.equal(parsed.inScope, 1);
assert.equal(parsed.outOfScope, 1);
assert.deepEqual(parsed.entries.inScope, [{ xy: "R ", path: "docs/new.md" }]);
assert.deepEqual(parsed.entries.outOfScope, [{ xy: "??", path: "scripts/worktree-hygiene.test.mjs" }]);
});
});