diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 781a5e1..3e44454 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Run repo script tests + run: pnpm test:scripts + - name: Check architecture guardrails run: pnpm check:architecture diff --git a/package.json b/package.json index 2d368bb..bf15332 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "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: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:exports": "node ./scripts/check-workspace-exports.mjs", "check:imports": "node ./scripts/check-workspace-imports.mjs", diff --git a/scripts/worktree-hygiene.mjs b/scripts/worktree-hygiene.mjs index 42cb0dd..44d1eb5 100644 --- a/scripts/worktree-hygiene.mjs +++ b/scripts/worktree-hygiene.mjs @@ -2,9 +2,10 @@ import { execFileSync } from "node:child_process"; import process from "node:process"; +import { pathToFileURL } from "node:url"; -function printUsage() { - console.log(`Usage: +export function renderUsage() { + return `Usage: node scripts/worktree-hygiene.mjs [--scope ]... [--allow-outside-scope] [--fail-outside-scope] [--fail-on-dirty] [--json] Examples: @@ -13,19 +14,20 @@ Examples: 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 --scope packages/api/src/router/ --allow-outside-scope -`); +`; } -function normalizeScope(scope) { +export function normalizeScope(scope) { return scope.replace(/\\/gu, "/").replace(/^\.\/+/u, ""); } -function parseArgs(argv) { +export function parseArgs(argv) { const scopes = []; let allowOutsideScope = false; let failOutsideScope = false; let failOnDirty = false; let json = false; + let help = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -58,23 +60,23 @@ function parseArgs(argv) { continue; } if (arg === "--help" || arg === "-h") { - printUsage(); - process.exit(0); + help = true; + continue; } 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, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } -function parsePorcelain(output) { +export function parsePorcelain(output) { return output .split("\n") .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}/`)); } -function summarize(entries) { +export function summarize(entries) { const summary = { staged: 0, unstaged: 0, @@ -117,18 +119,14 @@ function summarize(entries) { return summary; } -function formatEntries(title, entries) { +export function formatEntries(title, entries) { if (entries.length === 0) { return `${title}: none`; } return `${title}:\n${entries.map((entry) => `- ${entry.xy} ${entry.path}`).join("\n")}`; } -function main() { - 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"])); +function renderReport({ root, branch, scopes, allowOutsideScope, failOutsideScope, failOnDirty, json, entries }) { 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 status = { @@ -143,44 +141,96 @@ function main() { failOnDirty, }; + const lines = []; if (json) { - console.log(JSON.stringify({ ...status, entries: { inScope, outOfScope } }, null, 2)); + lines.push(JSON.stringify({ ...status, entries: { inScope, outOfScope } }, null, 2)); } else { - console.log(`Repository: ${root}`); - console.log(`Branch: ${branch}`); - console.log(`Dirty entries: ${entries.length}`); - console.log( + lines.push(`Repository: ${root}`); + lines.push(`Branch: ${branch}`); + lines.push(`Dirty entries: ${entries.length}`); + lines.push( `Summary: staged=${status.totals.staged}, unstaged=${status.totals.unstaged}, untracked=${status.totals.untracked}`, ); if (scopes.length > 0) { - console.log(`Owned scope: ${scopes.join(", ")}`); - console.log(formatEntries("In scope", inScope)); - console.log(formatEntries("Outside scope", outOfScope)); + lines.push(`Owned scope: ${scopes.join(", ")}`); + lines.push(formatEntries("In scope", inScope)); + lines.push(formatEntries("Outside scope", outOfScope)); if (outOfScope.length > 0 && !allowOutsideScope) { - console.log("\nResult: outside-scope changes detected."); + lines.push("", "Result: outside-scope changes detected."); } 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 { - console.log("\nResult: all dirty files are inside the declared scope."); + lines.push("", "Result: all dirty files are inside the declared scope."); } } else { - console.log(formatEntries("Dirty files", entries)); + lines.push(formatEntries("Dirty files", entries)); } } + let exitCode = 0; if (failOnDirty && entries.length > 0) { - process.exit(1); + exitCode = 1; } 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 { - main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - printUsage(); - process.exit(1); +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error(renderUsage()); + process.exit(1); + } } diff --git a/scripts/worktree-hygiene.test.mjs b/scripts/worktree-hygiene.test.mjs new file mode 100644 index 0000000..4419091 --- /dev/null +++ b/scripts/worktree-hygiene.test.mjs @@ -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" }]); + }); +});