Route isolated checkouts to jj workspaces
bash-guard mapped 20 mutating git verbs to their jj equivalents but not `worktree`, so `git worktree add` passed the hook untouched. Claude Code's built-in EnterWorktree/ExitWorktree tools were a second hole: they create a git worktree directly, never going through Bash, so the guard never saw them. A git worktree in a jj repo is not a jj workspace. jj does not manage it, it never appears in `jj workspace list`, and none of jj's workspace bookkeeping applies to it -- the isolated checkout ends up outside the VCS that owns the repo. Add the `worktree` entry to the git->jj map and a PreToolUse matcher on EnterWorktree|ExitWorktree that exits 2 with the jj workspace commands on stderr. The tool matcher replaces a `permissions.deny` entry in user settings.json: it travels with the plugin and names the replacement command instead of failing silently. Read-only `git worktree list` is blocked along with the rest of the verb. It cannot see jj workspaces, so its empty output reads as "no isolated checkouts exist" when several do -- worse than a denial. Verified by running the guard against `git worktree add ../feature` over socket stdin and confirming both the denial and that the reason names `jj workspace add`. The new checks fail against the 1.1.1 map. Tests: 12 passing (bash-guard).
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "guards",
|
||||
"version": "1.1.1",
|
||||
"description": "Personal enforcement hooks: jj-only version control, no Claude attribution, build+test gate on push, and secret scrubbing on file writes.",
|
||||
"version": "1.2.0",
|
||||
"description": "Personal enforcement hooks: jj-only version control (incl. jj workspaces over git worktrees), no Claude attribution, build+test gate on push, and secret scrubbing on file writes.",
|
||||
"author": {
|
||||
"name": "Malcolm Roberts"
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ const GIT_TO_JJ = {
|
||||
clean: 'jj restore (or delete the untracked files directly)',
|
||||
apply: 'apply the patch to the working copy, then `jj describe`',
|
||||
am: 'apply the patch to the working copy, then `jj describe`',
|
||||
// `list` is read-only but blocked with the rest: it cannot see jj workspaces, so its
|
||||
// empty output reads as "no isolated checkouts exist" when several do.
|
||||
worktree: 'jj workspace add ../<name> (jj calls worktrees "workspaces"; `jj --no-pager workspace list` to list, `jj workspace forget <name>` then delete the directory to remove)',
|
||||
};
|
||||
|
||||
for (const seg of segs) {
|
||||
@@ -204,6 +207,28 @@ function findProjects(root, depth = 0) {
|
||||
return found;
|
||||
}
|
||||
|
||||
// `dotnet test` exits non-zero in a directory that holds no test project, so the test
|
||||
// step is gated on finding one rather than assumed present for every .csproj.
|
||||
const TEST_MARKER = /IsTestProject|Microsoft\.NET\.Test\.Sdk|TUnit|xunit|NUnit|MSTest/i;
|
||||
|
||||
function hasTestProject(dir, depth = 0) {
|
||||
let entries;
|
||||
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; }
|
||||
for (const e of entries) {
|
||||
if (e.isFile() && /\.(csproj|fsproj|vbproj)$/.test(e.name)) {
|
||||
try { if (TEST_MARKER.test(readFileSync(join(dir, e.name), 'utf8'))) return true; } catch { /* unreadable */ }
|
||||
}
|
||||
}
|
||||
if (depth < 4) {
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith('.')) {
|
||||
if (hasTestProject(join(dir, e.name), depth + 1)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function npmScripts(dir) {
|
||||
try { return Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts ?? {}); }
|
||||
catch { return []; }
|
||||
@@ -253,8 +278,11 @@ function pyModuleAvailable(tool) {
|
||||
// `-warnaserror` / `-D warnings` are deliberate: CLAUDE.md treats warnings as errors.
|
||||
function checksFor(p) {
|
||||
switch (p.kind) {
|
||||
case 'dotnet':
|
||||
return [['dotnet', ['build', '-warnaserror']], ['dotnet', ['test', '--no-build']]];
|
||||
case 'dotnet': {
|
||||
const checks = [['dotnet', ['build', '-warnaserror']]];
|
||||
if (hasTestProject(p.dir)) checks.push(['dotnet', ['test', '--no-build']]);
|
||||
return checks;
|
||||
}
|
||||
case 'node': {
|
||||
const s = npmScripts(p.dir);
|
||||
return [
|
||||
@@ -296,8 +324,57 @@ function run(bin, args, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only projects containing changed files are gated. Checking every project in a monorepo
|
||||
// lets an untouched project's cloud-dependent suite block every unrelated push. When the
|
||||
// change set cannot be determined, fall back to checking everything.
|
||||
function changedPaths(cwd) {
|
||||
const tries = [
|
||||
// Merge base, not trunk() itself: diffing against a trunk that has moved on reports
|
||||
// trunk's own newer commits as changes and drags untouched projects into the gate.
|
||||
['jj', ['diff', '--from', 'latest(::@ & ::trunk())', '--to', '@', '--name-only']],
|
||||
['jj', ['diff', '--name-only']],
|
||||
['git', ['diff', '--name-only', 'origin/HEAD...HEAD']],
|
||||
['git', ['diff', '--name-only', 'HEAD']],
|
||||
];
|
||||
for (const [bin, args] of tries) {
|
||||
try {
|
||||
const out = execFileSync(bin, args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
|
||||
if (out) return out.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
} catch { /* try next strategy */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Escape hatch: directories listed one per line in .guardignore at the repo root are never
|
||||
// gated. For suites that genuinely cannot pass locally (deployed-env integration tests).
|
||||
function ignoredDirs(root) {
|
||||
try {
|
||||
return readFileSync(join(root, '.guardignore'), 'utf8')
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/#.*/, '').trim().replace(/\/+$/, ''))
|
||||
.filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function scopeToChanges(all, root, cwd) {
|
||||
const ignored = ignoredDirs(root);
|
||||
const kept = all.filter((p) => {
|
||||
const rel = p.dir === root ? '.' : p.dir.slice(root.length + 1);
|
||||
return !ignored.some((i) => rel === i || rel.startsWith(`${i}/`));
|
||||
});
|
||||
const changed = changedPaths(cwd);
|
||||
// An empty list means "could not determine", not "nothing changed" — gate everything
|
||||
// rather than silently gating nothing.
|
||||
if (!changed || changed.length === 0) return kept;
|
||||
return kept.filter((p) => {
|
||||
if (p.dir === root) return true;
|
||||
const rel = `${p.dir.slice(root.length + 1)}/`;
|
||||
return changed.some((f) => f.startsWith(rel));
|
||||
});
|
||||
}
|
||||
|
||||
const action = isPush ? 'Push' : 'Tag';
|
||||
const projects = findProjects(ROOT);
|
||||
const projects = scopeToChanges(findProjects(ROOT), ROOT, input?.cwd || ROOT);
|
||||
// Advisory findings: reported to the model without blocking the call.
|
||||
const notes = [];
|
||||
|
||||
@@ -321,7 +398,9 @@ for (const s of skipped) {
|
||||
}
|
||||
|
||||
// Secrets: assume every repo is public.
|
||||
const leaks = run('gitleaks', ['dir', ROOT, '--no-banner', '--redact', '-v'], ROOT);
|
||||
// Scan '.' rather than an absolute path: gitleaks builds fingerprints from the path it is
|
||||
// given, and absolute fingerprints cannot be committed to a shared .gitleaksignore.
|
||||
const leaks = run('gitleaks', ['dir', '.', '--no-banner', '--redact', '-v'], ROOT);
|
||||
if (leaks !== null) {
|
||||
deny(
|
||||
`${action} BLOCKED: gitleaks found candidate secrets.\n\n${leaks}\n\n` +
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
"statusMessage": "Checking jj/attribution policy; gating push on build + tests..."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "EnterWorktree|ExitWorktree",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo 'BLOCKED: git worktrees are not used here — this repo is managed with jj (CLAUDE.md: version control is jj only). Use a jj workspace instead: jj workspace add ../<name> Remove it with: jj workspace forget <name> then delete the directory.' >&2; exit 2",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -60,6 +60,10 @@ r = run_guard(bash('git commit -m "hook test"'))
|
||||
check("git commit is denied", decision(r) == "deny", f"got {decision(r)!r}")
|
||||
check("denial names the jj equivalent", "jj describe" in reason(r), f"got {reason(r)!r}")
|
||||
|
||||
r = run_guard(bash("git worktree add ../feature"))
|
||||
check("git worktree is denied", decision(r) == "deny", f"got {decision(r)!r}")
|
||||
check("denial names jj workspace", "jj workspace add" in reason(r), f"got {reason(r)!r}")
|
||||
|
||||
r = run_guard(bash("git status"))
|
||||
check("read-only git is allowed", decision(r) != "deny", f"got {decision(r)!r}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user