#!/usr/bin/env node import { execFileSync } from "node:child_process"; import process from "node:process"; import { pathToFileURL } from "node:url"; export function renderUsage() { return `Usage: node scripts/worktree-hygiene.mjs [--scope ]... [--allow-outside-scope] [--fail-outside-scope] [--fail-on-dirty] [--json] Examples: node scripts/worktree-hygiene.mjs --scope docs/ --scope scripts/ node scripts/worktree-hygiene.mjs --scope apps/web/src/components/timeline/ --scope apps/web/e2e/timeline.spec.ts 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 `; } export function normalizeScope(scope) { return scope.replace(/\\/gu, "/").replace(/^\.\/+/u, ""); } 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]; if (arg === "--") { continue; } if (arg === "--scope") { const value = argv[index + 1]; if (!value) { throw new Error("--scope requires a path value."); } scopes.push(normalizeScope(value)); index += 1; continue; } if (arg === "--allow-outside-scope") { allowOutsideScope = true; continue; } if (arg === "--fail-outside-scope") { failOutsideScope = true; continue; } if (arg === "--fail-on-dirty") { failOnDirty = true; continue; } if (arg === "--json") { json = true; continue; } if (arg === "--help" || arg === "-h") { help = true; continue; } throw new Error(`Unknown argument: ${arg}`); } return { scopes, allowOutsideScope, failOutsideScope, failOnDirty, json, help }; } export function runGit(args) { return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } export function parsePorcelain(output) { return output .split("\n") .filter(Boolean) .map((line) => { const xy = line.slice(0, 2); const rawPath = line.slice(3); const renamed = xy.includes("R") || xy.includes("C"); const path = renamed ? rawPath.split(" -> ").at(-1) ?? rawPath : rawPath; return { xy, path: path.replace(/\\/gu, "/"), }; }); } export function matchesScope(path, scopes) { return scopes.some((scope) => path === scope || path.startsWith(scope.endsWith("/") ? scope : `${scope}/`)); } export function summarize(entries) { const summary = { staged: 0, unstaged: 0, untracked: 0, }; for (const entry of entries) { if (entry.xy === "??") { summary.untracked += 1; continue; } if (entry.xy[0] && entry.xy[0] !== " ") { summary.staged += 1; } if (entry.xy[1] && entry.xy[1] !== " ") { summary.unstaged += 1; } } return summary; } 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 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 = { root, branch, scopes, totals: summarize(entries), inScope: inScope.length, outOfScope: outOfScope.length, allowOutsideScope, failOutsideScope, failOnDirty, }; const lines = []; if (json) { lines.push(JSON.stringify({ ...status, entries: { inScope, outOfScope } }, null, 2)); } else { 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) { lines.push(`Owned scope: ${scopes.join(", ")}`); lines.push(formatEntries("In scope", inScope)); lines.push(formatEntries("Outside scope", outOfScope)); if (outOfScope.length > 0 && !allowOutsideScope) { lines.push("", "Result: outside-scope changes detected."); } else if (outOfScope.length > 0) { lines.push("", "Result: outside-scope changes detected but tolerated by flag."); } else { lines.push("", "Result: all dirty files are inside the declared scope."); } } else { lines.push(formatEntries("Dirty files", entries)); } } let exitCode = 0; if (failOnDirty && entries.length > 0) { exitCode = 1; } if (scopes.length > 0 && outOfScope.length > 0 && (failOutsideScope || !allowOutsideScope)) { 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); } } 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); } }