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",
|
"name": "guards",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"description": "Personal enforcement hooks: jj-only version control, no Claude attribution, build+test gate on push, and secret scrubbing on file writes.",
|
"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": {
|
"author": {
|
||||||
"name": "Malcolm Roberts"
|
"name": "Malcolm Roberts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ const GIT_TO_JJ = {
|
|||||||
clean: 'jj restore (or delete the untracked files directly)',
|
clean: 'jj restore (or delete the untracked files directly)',
|
||||||
apply: 'apply the patch to the working copy, then `jj describe`',
|
apply: 'apply the patch to the working copy, then `jj describe`',
|
||||||
am: '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) {
|
for (const seg of segs) {
|
||||||
@@ -204,6 +207,28 @@ function findProjects(root, depth = 0) {
|
|||||||
return found;
|
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) {
|
function npmScripts(dir) {
|
||||||
try { return Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts ?? {}); }
|
try { return Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts ?? {}); }
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
@@ -253,8 +278,11 @@ function pyModuleAvailable(tool) {
|
|||||||
// `-warnaserror` / `-D warnings` are deliberate: CLAUDE.md treats warnings as errors.
|
// `-warnaserror` / `-D warnings` are deliberate: CLAUDE.md treats warnings as errors.
|
||||||
function checksFor(p) {
|
function checksFor(p) {
|
||||||
switch (p.kind) {
|
switch (p.kind) {
|
||||||
case 'dotnet':
|
case 'dotnet': {
|
||||||
return [['dotnet', ['build', '-warnaserror']], ['dotnet', ['test', '--no-build']]];
|
const checks = [['dotnet', ['build', '-warnaserror']]];
|
||||||
|
if (hasTestProject(p.dir)) checks.push(['dotnet', ['test', '--no-build']]);
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
case 'node': {
|
case 'node': {
|
||||||
const s = npmScripts(p.dir);
|
const s = npmScripts(p.dir);
|
||||||
return [
|
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 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.
|
// Advisory findings: reported to the model without blocking the call.
|
||||||
const notes = [];
|
const notes = [];
|
||||||
|
|
||||||
@@ -321,7 +398,9 @@ for (const s of skipped) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Secrets: assume every repo is public.
|
// 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) {
|
if (leaks !== null) {
|
||||||
deny(
|
deny(
|
||||||
`${action} BLOCKED: gitleaks found candidate secrets.\n\n${leaks}\n\n` +
|
`${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..."
|
"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": [
|
"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("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}")
|
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"))
|
r = run_guard(bash("git status"))
|
||||||
check("read-only git is allowed", decision(r) != "deny", f"got {decision(r)!r}")
|
check("read-only git is allowed", decision(r) != "deny", f"got {decision(r)!r}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user