Skip to content

Commit 2459cfc

Browse files
fix: hook resilience for git ops, regex bypass, and worktree isolation (#153)
Three fixes to Claude Code hooks: 1. New post-git-ops.sh hook (PostToolUse on Bash) detects git rebase, revert, cherry-pick, merge, pull and automatically rebuilds the codegraph, logs changed files to session-edits.log, and clears stale entries from codegraph-checked.log. 2. Fix regex bypass in guard-git.sh: blocking patterns used ^\s*git which only matched git at command start. Commands like `cd foo && git add .` bypassed all blocks. Now uses (^|\s|&&\s*)git to match chained commands consistently. 3. Fix worktree isolation: session-local state files (session-edits.log, codegraph-checked.log) now use `git rev-parse --show-toplevel` instead of CLAUDE_PROJECT_DIR. This gives each worktree its own edit log, preventing cross-session leakage where session A could commit files only edited by session B. Also adds docs/examples/claude-code-hooks/ with distributable hook examples, settings.json, and setup README. Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 550b3b5 commit 2459cfc

16 files changed

Lines changed: 796 additions & 15 deletions

.claude/hooks/guard-git.sh

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,34 +43,34 @@ deny() {
4343
# --- Block dangerous commands ---
4444

4545
# git add . / git add -A / git add --all (broad staging)
46-
if echo "$COMMAND" | grep -qE '^\s*git\s+add\s+(\.\s*$|-A|--all)'; then
46+
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+add\s+(\.\s*$|-A|--all)'; then
4747
deny "BLOCKED: 'git add .' / 'git add -A' stages ALL changes including other sessions' work. Stage specific files instead: git add <file1> <file2>"
4848
fi
4949

5050
# git reset (unstaging / hard reset)
51-
if echo "$COMMAND" | grep -qE '^\s*git\s+reset'; then
51+
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+reset'; then
5252
deny "BLOCKED: 'git reset' can unstage or destroy other sessions' work. To unstage your own files, use: git restore --staged <file>"
5353
fi
5454

5555
# git checkout -- <file> (reverting files)
56-
if echo "$COMMAND" | grep -qE '^\s*git\s+checkout\s+--'; then
56+
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+checkout\s+--'; then
5757
deny "BLOCKED: 'git checkout -- <file>' reverts working tree changes and may destroy other sessions' edits. If you need to discard your own changes, be explicit about which files."
5858
fi
5959

6060
# git restore (reverting) — EXCEPT git restore --staged (safe unstaging)
61-
if echo "$COMMAND" | grep -qE '^\s*git\s+restore'; then
62-
if ! echo "$COMMAND" | grep -qE '^\s*git\s+restore\s+--staged'; then
61+
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore'; then
62+
if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore\s+--staged'; then
6363
deny "BLOCKED: 'git restore <file>' reverts working tree changes and may destroy other sessions' edits. To unstage files safely, use: git restore --staged <file>"
6464
fi
6565
fi
6666

6767
# git clean (delete untracked files)
68-
if echo "$COMMAND" | grep -qE '^\s*git\s+clean'; then
68+
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+clean'; then
6969
deny "BLOCKED: 'git clean' deletes untracked files that may belong to other sessions."
7070
fi
7171

7272
# git stash (hides all changes)
73-
if echo "$COMMAND" | grep -qE '^\s*git\s+stash'; then
73+
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+stash'; then
7474
deny "BLOCKED: 'git stash' hides all working tree changes including other sessions' work. In worktree mode, commit your changes directly instead."
7575
fi
7676

@@ -115,7 +115,8 @@ fi
115115
# --- Commit validation against edit log ---
116116

117117
if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then
118-
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
118+
# Use git worktree root so each worktree session has its own edit log
119+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
119120
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
120121

121122
# If no edit log exists, allow (backward compat for sessions without tracking)

.claude/hooks/post-git-ops.sh

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env bash
2+
# post-git-ops.sh — PostToolUse hook for Bash tool calls
3+
# Detects git operations that change file state (rebase, revert, cherry-pick,
4+
# merge, pull) and:
5+
# 1. Rebuilds the codegraph incrementally (fixes stale dependency context)
6+
# 2. Logs changed files to session-edits.log (so commit validation works)
7+
# 3. Clears stale entries from codegraph-checked.log (so remind hook re-fires)
8+
# Always exits 0 (informational only, never blocks).
9+
10+
set -euo pipefail
11+
12+
INPUT=$(cat)
13+
14+
# Extract the command from tool_input JSON
15+
COMMAND=$(echo "$INPUT" | node -e "
16+
let d='';
17+
process.stdin.on('data',c=>d+=c);
18+
process.stdin.on('end',()=>{
19+
const p=JSON.parse(d).tool_input?.command||'';
20+
if(p)process.stdout.write(p);
21+
});
22+
" 2>/dev/null) || true
23+
24+
if [ -z "$COMMAND" ]; then
25+
exit 0
26+
fi
27+
28+
# Only act on git operations that change file content
29+
if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+(rebase|revert|cherry-pick|merge|pull)\b'; then
30+
exit 0
31+
fi
32+
33+
# Use git worktree root so each worktree session has its own state
34+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
35+
36+
# --- 1. Rebuild codegraph ---
37+
DB_PATH="$PROJECT_DIR/.codegraph/graph.db"
38+
if [ -f "$DB_PATH" ]; then
39+
if command -v codegraph &>/dev/null; then
40+
codegraph build "$PROJECT_DIR" -d "$DB_PATH" 2>/dev/null || true
41+
else
42+
node "${CLAUDE_PROJECT_DIR:-$PROJECT_DIR}/src/cli.js" build "$PROJECT_DIR" -d "$DB_PATH" 2>/dev/null || true
43+
fi
44+
fi
45+
46+
# --- 2. Log changed files to session-edits.log ---
47+
# ORIG_HEAD is set by rebase, revert, cherry-pick, merge, and pull.
48+
# If the operation failed (conflicts), ORIG_HEAD may be stale — the
49+
# diff will either fail or return nothing, which is safe.
50+
CHANGED_FILES=$(git diff --name-only ORIG_HEAD HEAD 2>/dev/null) || true
51+
52+
if [ -n "$CHANGED_FILES" ]; then
53+
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
54+
mkdir -p "$(dirname "$LOG_FILE")"
55+
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
56+
while IFS= read -r rel_path; do
57+
if [ -n "$rel_path" ]; then
58+
echo "$TS $rel_path" >> "$LOG_FILE"
59+
fi
60+
done <<< "$CHANGED_FILES"
61+
fi
62+
63+
# --- 3. Clear stale entries from codegraph-checked.log ---
64+
# After a git op that changes files, the remind-codegraph hook should
65+
# re-fire for those files so the agent re-checks context/impact.
66+
CHECKED_LOG="$PROJECT_DIR/.claude/codegraph-checked.log"
67+
if [ -n "$CHANGED_FILES" ] && [ -f "$CHECKED_LOG" ]; then
68+
PATTERNS_FILE=$(mktemp)
69+
echo "$CHANGED_FILES" > "$PATTERNS_FILE"
70+
grep -vFf "$PATTERNS_FILE" "$CHECKED_LOG" > "${CHECKED_LOG}.tmp" 2>/dev/null || true
71+
mv "${CHECKED_LOG}.tmp" "$CHECKED_LOG"
72+
rm -f "$PATTERNS_FILE"
73+
fi
74+
75+
exit 0

.claude/hooks/remind-codegraph.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ case "$REL_PATH" in
3939
*.md|*.json|*.yml|*.yaml|*.toml|*.txt|*.lock|*.log|*.env*) exit 0 ;;
4040
esac
4141

42-
# Check if we already reminded for this file
43-
CHECKED_LOG="${CLAUDE_PROJECT_DIR:-.}/.claude/codegraph-checked.log"
42+
# Use git worktree root so each worktree session has its own checked log
43+
WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}"
44+
CHECKED_LOG="$WORK_ROOT/.claude/codegraph-checked.log"
4445
if [ -f "$CHECKED_LOG" ] && grep -qF "$REL_PATH" "$CHECKED_LOG" 2>/dev/null; then
4546
exit 0
4647
fi
@@ -50,7 +51,7 @@ mkdir -p "$(dirname "$CHECKED_LOG")"
5051
echo "$REL_PATH" >> "$CHECKED_LOG"
5152

5253
# Check if graph exists
53-
if [ ! -f "${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db" ]; then
54+
if [ ! -f "$WORK_ROOT/.codegraph/graph.db" ]; then
5455
exit 0
5556
fi
5657

.claude/hooks/track-edits.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ if [ -z "$FILE_PATH" ]; then
2323
exit 0
2424
fi
2525

26-
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
26+
# Use git worktree root so each worktree session has its own edit log
27+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
2728
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
2829

2930
# Normalize to relative path with forward slashes

.claude/hooks/track-moves.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)(mv|git\s+mv|cp)\s+'; then
2828
exit 0
2929
fi
3030

31-
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
31+
# Use git worktree root so each worktree session has its own edit log
32+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
3233
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
3334

3435
# Use node to parse the command and extract all file paths involved

.claude/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@
7070
"type": "command",
7171
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/track-moves.sh\"",
7272
"timeout": 5
73+
},
74+
{
75+
"type": "command",
76+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-git-ops.sh\"",
77+
"timeout": 30
7378
}
7479
]
7580
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Claude Code Hooks for Codegraph
2+
3+
Ready-to-use [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) that keep the codegraph database fresh and provide automatic dependency context as Claude edits your codebase.
4+
5+
## Quick setup
6+
7+
```bash
8+
# 1. Copy hooks into your project
9+
mkdir -p .claude/hooks
10+
cp docs/examples/claude-code-hooks/*.sh .claude/hooks/
11+
chmod +x .claude/hooks/*.sh
12+
13+
# 2. Copy settings (or merge into your existing .claude/settings.json)
14+
cp docs/examples/claude-code-hooks/settings.json .claude/settings.json
15+
16+
# 3. Add session logs to .gitignore
17+
echo ".claude/session-edits.log" >> .gitignore
18+
echo ".claude/codegraph-checked.log" >> .gitignore
19+
```
20+
21+
## Hooks
22+
23+
### Core hooks (recommended for all projects)
24+
25+
| Hook | Trigger | What it does |
26+
|------|---------|-------------|
27+
| `enrich-context.sh` | PreToolUse on Read/Grep | Injects `codegraph deps` output (imports, importers, definitions) into Claude's context when it reads a file |
28+
| `remind-codegraph.sh` | PreToolUse on Edit/Write | Reminds Claude to run `codegraph where`, `explain`, `context`, and `fn-impact` before editing a file. Fires once per file per session |
29+
| `update-graph.sh` | PostToolUse on Edit/Write | Runs `codegraph build` incrementally after each source file edit to keep the graph fresh |
30+
| `post-git-ops.sh` | PostToolUse on Bash | Detects `git rebase/revert/cherry-pick/merge/pull` and rebuilds the graph, logs changed files, and resets the remind tracker |
31+
32+
### Parallel session safety hooks (recommended for multi-agent workflows)
33+
34+
| Hook | Trigger | What it does |
35+
|------|---------|-------------|
36+
| `guard-git.sh` | PreToolUse on Bash | Blocks `git add .`, `git reset`, `git restore`, `git clean`, `git stash`; validates commits only include files the session actually edited |
37+
| `track-edits.sh` | PostToolUse on Edit/Write | Logs every file edited via Edit/Write to `.claude/session-edits.log` |
38+
| `track-moves.sh` | PostToolUse on Bash | Logs files affected by `mv`/`git mv`/`cp` commands to `.claude/session-edits.log` |
39+
40+
## Git operation resilience
41+
42+
Git operations like `rebase`, `revert`, `cherry-pick`, `merge`, and `pull` change file contents without going through Edit/Write tools. Without `post-git-ops.sh`, this causes three problems:
43+
44+
1. **Stale graph**`enrich-context.sh` provides outdated dependency info
45+
2. **Blocked commits**`guard-git.sh` rejects commits with rebase-modified files not in the edit log
46+
3. **Stale reminders**`remind-codegraph.sh` won't re-fire for files changed by the git operation
47+
48+
`post-git-ops.sh` fixes all three by detecting these git commands after they run and:
49+
- Rebuilding the codegraph (`codegraph build`)
50+
- Appending changed files (via `git diff --name-only ORIG_HEAD HEAD`) to the session edit log
51+
- Removing changed files from the remind tracker so the agent re-checks context
52+
53+
## Worktree isolation
54+
55+
All session-local state files (`session-edits.log`, `codegraph-checked.log`) use `git rev-parse --show-toplevel` to resolve the working tree root, rather than `CLAUDE_PROJECT_DIR`. This ensures each git worktree gets its own isolated state — session A's edit log doesn't leak into session B's commit validation.
56+
57+
Without this fix, `CLAUDE_PROJECT_DIR` (which always points to the main project root) causes all worktree sessions to share a single edit log, defeating the parallel session safety model.
58+
59+
## Customization
60+
61+
**Subset installation:** You don't need all hooks. The core hooks work independently of the parallel session hooks. Pick what fits your workflow:
62+
63+
- **Solo developer:** `enrich-context.sh` + `update-graph.sh` + `post-git-ops.sh`
64+
- **With reminders:** Add `remind-codegraph.sh`
65+
- **Multi-agent / worktrees:** Add `guard-git.sh` + `track-edits.sh` + `track-moves.sh`
66+
67+
**Branch name validation:** The `guard-git.sh` in this repo's `.claude/hooks/` validates branch names against conventional prefixes (`feat/`, `fix/`, etc.). The example version omits this — add your own validation if needed.
68+
69+
## Requirements
70+
71+
- Node.js >= 20
72+
- `codegraph` installed globally or available via `npx`
73+
- Graph built at least once (`codegraph build`)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env bash
2+
# enrich-context.sh — PreToolUse hook for Read and Grep tools
3+
# Provides dependency context from codegraph when reading/searching files.
4+
# Always exits 0 (informational only, never blocks).
5+
6+
set -euo pipefail
7+
8+
# Read the tool input from stdin
9+
INPUT=$(cat)
10+
11+
# Extract file path and convert to relative — all in node to avoid
12+
# bash backslash issues on Windows/Git Bash
13+
REL_PATH=$(printf '%s' "$INPUT" | CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" node -e "
14+
let d='';
15+
process.stdin.on('data',c=>d+=c);
16+
process.stdin.on('end',()=>{
17+
const o=JSON.parse(d).tool_input||{};
18+
let p=(o.file_path||o.path||'').replace(/\\\\/g,'/');
19+
if(!p)return;
20+
let dir=(process.env.CLAUDE_PROJECT_DIR||'.').replace(/\\\\/g,'/');
21+
if(p.startsWith(dir))p=p.slice(dir.length+1);
22+
process.stdout.write(p);
23+
});
24+
" 2>/dev/null) || true
25+
26+
# Guard: no file path found
27+
if [ -z "$REL_PATH" ]; then
28+
exit 0
29+
fi
30+
31+
# Guard: codegraph DB must exist
32+
DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db"
33+
if [ ! -f "$DB_PATH" ]; then
34+
exit 0
35+
fi
36+
37+
# Run codegraph deps and capture output
38+
DEPS=""
39+
if command -v codegraph &>/dev/null; then
40+
DEPS=$(codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
41+
else
42+
DEPS=$(npx --yes @optave/codegraph deps "$REL_PATH" --json -d "$DB_PATH" 2>/dev/null) || true
43+
fi
44+
45+
# Guard: no output or error
46+
if [ -z "$DEPS" ] || [ "$DEPS" = "null" ]; then
47+
exit 0
48+
fi
49+
50+
# Output as additionalContext so it surfaces in Claude's context
51+
printf '%s' "$DEPS" | node -e "
52+
let d='';
53+
process.stdin.on('data',c=>d+=c);
54+
process.stdin.on('end',()=>{
55+
try {
56+
const o=JSON.parse(d);
57+
const r=o.results?.[0]||{};
58+
const imports=(r.imports||[]).map(i=>i.file).join(', ');
59+
const importedBy=(r.importedBy||[]).map(i=>i.file).join(', ');
60+
const defs=(r.definitions||[]).map(d=>d.kind+' '+d.name).join(', ');
61+
const file=o.file||'unknown';
62+
let ctx='[codegraph] '+file;
63+
if(imports)ctx+='\n Imports: '+imports;
64+
if(importedBy)ctx+='\n Imported by: '+importedBy;
65+
if(defs)ctx+='\n Defines: '+defs;
66+
console.log(JSON.stringify({
67+
hookSpecificOutput: {
68+
hookEventName: 'PreToolUse',
69+
permissionDecision: 'allow',
70+
additionalContext: ctx
71+
}
72+
}));
73+
} catch(e) {}
74+
});
75+
" 2>/dev/null || true
76+
77+
exit 0

0 commit comments

Comments
 (0)