HookStack
Back to catalogue
WorkflowPreToolUse· Edit|Write

Worktree guardrail (Edit/Write)

Edits never leak back into your main repo

Blocks any Edit/Write whose absolute path targets the main repo instead of the active worktree. Suggests the corrected path. Detects the worktree via .claude/active-worktree or via git rev-parse.

What does the Worktree guardrail (Edit/Write) hook do?

Worktree guardrail (Edit/Write) is a Claude Code PreToolUse hook matching Edit|Write. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Edits never leak back into your main repo.

As a PreToolUse hook it runs before the action completes, so it can block or adjust what Claude is about to do. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Prevent accidental changes to the main repo in worktree mode
  • Parallel multi-agent workflows with per-worktree isolation

Tags

#worktree#multi-agent#isolation#workflow

settings.json fragment

{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.mjs",
            "type": "command"
          }
        ],
        "matcher": "Edit|Write"
      }
    ]
  }
}

Script · .claude/hooks/worktree-guard.mjs

#!/usr/bin/env node
// @hookstack pre-edit-worktree-guard
import { execSync } from "node:child_process";
// @hookstack pre-edit-worktree-guard
// Empêche l'édition de fichiers hors du worktree courant (PreToolUse Write|Edit)
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";

// Répertoires internes des agents — légitimes hors worktree
const AGENT_DIRS = [".claude", ".codex"];

function defaultExec(cmd) {
	return execSync(cmd, { encoding: "utf8", timeout: 5_000 }).trim();
}

export function run(input, { exec = defaultExec, home = homedir() } = {}) {
	const filePath = input.tool_input?.file_path ?? "";
	if (!filePath) return null;

	try {
		const worktreeRoot = exec("git rev-parse --show-toplevel");
		const worktreeLines = exec("git worktree list").split("\n");
		const mainRoot = worktreeLines[0]?.split(/\s+/)[0] ?? "";

		// N'applique le garde que dans un worktree non principal
		if (!mainRoot || worktreeRoot === mainRoot) return null;

		const absFile = resolve(filePath);

		// Autoriser les répertoires internes des agents (plans, mémoire, config…)
		if (AGENT_DIRS.some((d) => absFile.startsWith(`${resolve(home, d)}/`)))
			return null;

		if (!absFile.startsWith(`${worktreeRoot}/`)) {
			return {
				decision: "block",
				reason: `Écriture hors du worktree courant (${worktreeRoot}). Vérifiez le chemin cible.`,
			};
		}
	} catch {
		// git absent ou pas dans un repo — laisser passer
	}
	return null;
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result) process.stdout.write(JSON.stringify(result));
}

Learn more

Related hooks