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:
2026-07-28 13:07:00 -05:00
parent 58851c8a8c
commit d3258b224a
4 changed files with 99 additions and 6 deletions
+2 -2
View File
@@ -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"
}
+83 -4
View File
@@ -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` +
+10
View File
@@ -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": [
+4
View File
@@ -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}")