test(repo): cover worktree hygiene guardrails
This commit is contained in:
@@ -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 <path>]... [--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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user