Files
CapaKraken/scripts/worktree-hygiene.mjs

237 lines
6.2 KiB
JavaScript

#!/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 <path>]... [--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);
}
}