Skip to content

Commit 9a52c7c

Browse files
committed
fix(hooks): track Bash file modifications via before/after git status diff
Adds snapshot-pre-bash.sh (PreToolUse Bash) + track-bash-writes.sh (PostToolUse Bash): the pre-hook captures git status --porcelain to a per-worktree temp file before each Bash call; the post-hook diffs the before/after state and appends newly modified or created files to .claude/session-edits.log. This closes the gap where files written by sed -i, printf redirects, tee, heredocs, or build tools (Cargo.lock, lockfiles) were never recorded, causing guard-git.sh to emit false-positive BLOCKED errors. Closes #1457
1 parent a372b82 commit 9a52c7c

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

.claude/hooks/snapshot-pre-bash.sh

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env bash
2+
# snapshot-pre-bash.sh — PreToolUse hook for Bash tool calls
3+
# Snapshots `git status --porcelain` to a temp file before each Bash call so
4+
# that track-bash-writes.sh (PostToolUse) can diff the before/after state and
5+
# log files newly modified by the command to .claude/session-edits.log.
6+
# Always exits 0 (informational only, never blocks).
7+
8+
set -euo pipefail
9+
10+
INPUT=$(cat)
11+
12+
# Extract the command from tool_input JSON
13+
COMMAND=$(echo "$INPUT" | node -e "
14+
let d='';
15+
process.stdin.on('data',c=>d+=c);
16+
process.stdin.on('end',()=>{
17+
const p=JSON.parse(d).tool_input?.command||'';
18+
if(p)process.stdout.write(p);
19+
});
20+
" 2>/dev/null) || true
21+
22+
if [ -z "$COMMAND" ]; then
23+
exit 0
24+
fi
25+
26+
# Skip read-only commands that can never write files — reduces snapshot overhead
27+
# for the most common Bash calls (ls, cat, grep, git log, git status, etc.).
28+
# sed is intentionally NOT in this list because `sed -i` modifies files in-place.
29+
if echo "$COMMAND" | grep -qE '^\s*(ls|cat|head|tail|grep|find|git\s+(log|status|diff|show|branch|remote|fetch|rev-parse|stash\s+list|ls-files|blame|describe|tag|config\s+--get)|gh\s+(pr|issue|repo)\s+(view|list|status)|echo|printf|pwd|which|node\s+-e|node\s+-p|npx\s+--version|wc|sort|uniq|awk)\b'; then
30+
exit 0
31+
fi
32+
33+
# Resolve the project root (worktree-aware — each worktree has its own .claude/)
34+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
35+
36+
# Key the snapshot file to the project root so parallel worktrees don't collide.
37+
# Use a simple hash of the path — just enough to be unique per worktree.
38+
PROJECT_HASH=$(echo "$PROJECT_DIR" | node -e "
39+
const crypto = require('crypto');
40+
let d='';
41+
process.stdin.on('data',c=>d+=c);
42+
process.stdin.on('end',()=>{
43+
process.stdout.write(crypto.createHash('sha1').update(d.trim()).digest('hex').slice(0,8));
44+
});
45+
" 2>/dev/null) || PROJECT_HASH="default"
46+
47+
SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_HASH}.txt"
48+
49+
# Capture current git status --porcelain.
50+
# Lines look like: "XY filename" or "XY orig -> dest" (rename).
51+
# We only care about the status marker and path — porcelain is stable across git versions.
52+
git -C "$PROJECT_DIR" status --porcelain 2>/dev/null > "$SNAPSHOT_FILE" || true
53+
54+
exit 0

.claude/hooks/track-bash-writes.sh

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env bash
2+
# track-bash-writes.sh — PostToolUse hook for Bash tool calls
3+
# Compares `git status --porcelain` against the snapshot taken by
4+
# snapshot-pre-bash.sh (PreToolUse) to detect files newly modified or
5+
# created by the Bash command, then appends them to .claude/session-edits.log
6+
# so that guard-git.sh can validate commits correctly.
7+
# Always exits 0 (informational only, never blocks).
8+
9+
set -euo pipefail
10+
11+
INPUT=$(cat)
12+
13+
# Extract the command from tool_input JSON
14+
COMMAND=$(echo "$INPUT" | node -e "
15+
let d='';
16+
process.stdin.on('data',c=>d+=c);
17+
process.stdin.on('end',()=>{
18+
const p=JSON.parse(d).tool_input?.command||'';
19+
if(p)process.stdout.write(p);
20+
});
21+
" 2>/dev/null) || true
22+
23+
if [ -z "$COMMAND" ]; then
24+
exit 0
25+
fi
26+
27+
# Resolve the project root (worktree-aware — each worktree has its own .claude/)
28+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
29+
30+
# Reproduce the same project hash used by snapshot-pre-bash.sh
31+
PROJECT_HASH=$(echo "$PROJECT_DIR" | node -e "
32+
const crypto = require('crypto');
33+
let d='';
34+
process.stdin.on('data',c=>d+=c);
35+
process.stdin.on('end',()=>{
36+
process.stdout.write(crypto.createHash('sha1').update(d.trim()).digest('hex').slice(0,8));
37+
});
38+
" 2>/dev/null) || PROJECT_HASH="default"
39+
40+
SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_HASH}.txt"
41+
42+
# If there is no snapshot (hook was not installed yet, or the pre-hook was
43+
# skipped for a read-only command) we have no baseline — exit cleanly.
44+
if [ ! -f "$SNAPSHOT_FILE" ]; then
45+
exit 0
46+
fi
47+
48+
# Capture current state after the command ran
49+
AFTER=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null) || true
50+
51+
# Read the before-state
52+
BEFORE=$(cat "$SNAPSHOT_FILE") || true
53+
54+
# Clean up the snapshot so it doesn't pollute the next command's pre-hook
55+
rm -f "$SNAPSHOT_FILE"
56+
57+
# Build the set of paths that existed (as dirty) before the command ran.
58+
# porcelain format: "XY path" or "XY original -> new" (rename).
59+
# We extract every path token after the two-char status code.
60+
parse_paths() {
61+
local status_output="$1"
62+
echo "$status_output" | awk '
63+
/^[ MADRCU?!]{2} / {
64+
# Drop the two-char status + space
65+
rest = substr($0, 4)
66+
# Handle rename: "old -> new"
67+
if (index(rest, " -> ") > 0) {
68+
n = split(rest, parts, " -> ")
69+
for (i = 1; i <= n; i++) {
70+
p = parts[i]
71+
gsub(/^"/, "", p); gsub(/"$/, "", p)
72+
if (p != "") print p
73+
}
74+
} else {
75+
gsub(/^"/, "", rest); gsub(/"$/, "", rest)
76+
if (rest != "") print rest
77+
}
78+
}
79+
'
80+
}
81+
82+
BEFORE_PATHS=$(parse_paths "$BEFORE" | sort)
83+
AFTER_PATHS=$(parse_paths "$AFTER" | sort)
84+
85+
if [ -z "$AFTER_PATHS" ]; then
86+
exit 0
87+
fi
88+
89+
# Find paths present in AFTER but not in BEFORE — these were newly dirtied
90+
# (modified, created, or renamed-to) by the Bash command.
91+
NEW_PATHS=$(comm -13 <(echo "$BEFORE_PATHS") <(echo "$AFTER_PATHS")) || true
92+
93+
if [ -z "$NEW_PATHS" ]; then
94+
exit 0
95+
fi
96+
97+
# Also exclude paths that were already tracked by track-edits.sh or other hooks
98+
# (i.e. already in the session-edits.log) so we don't double-log.
99+
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
100+
ALREADY_LOGGED=""
101+
if [ -f "$LOG_FILE" ] && [ -s "$LOG_FILE" ]; then
102+
ALREADY_LOGGED=$(awk '{print $2}' "$LOG_FILE" | sort -u)
103+
fi
104+
105+
mkdir -p "$(dirname "$LOG_FILE")"
106+
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
107+
108+
while IFS= read -r rel_path; do
109+
if [ -z "$rel_path" ]; then
110+
continue
111+
fi
112+
# Skip if already in the log from a prior hook (Edit/Write/track-moves)
113+
if [ -n "$ALREADY_LOGGED" ] && echo "$ALREADY_LOGGED" | grep -qxF "$rel_path"; then
114+
continue
115+
fi
116+
echo "$TS $rel_path" >> "$LOG_FILE"
117+
done <<< "$NEW_PATHS"
118+
119+
exit 0

.claude/settings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
{
88
"matcher": "Bash",
99
"hooks": [
10+
{
11+
"type": "command",
12+
"command": "p=\"${CLAUDE_PROJECT_DIR}\"; [ -d \"$p/.claude/hooks\" ] || p=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -d \"$p/.claude/hooks\" ] || exit 0; bash \"$p/.claude/hooks/snapshot-pre-bash.sh\"",
13+
"timeout": 5
14+
},
1015
{
1116
"type": "command",
1217
"command": "p=\"${CLAUDE_PROJECT_DIR}\"; [ -d \"$p/.claude/hooks\" ] || p=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -d \"$p/.claude/hooks\" ] || exit 0; bash \"$p/.claude/hooks/check-readme.sh\"",
@@ -79,6 +84,11 @@
7984
{
8085
"matcher": "Bash",
8186
"hooks": [
87+
{
88+
"type": "command",
89+
"command": "p=\"${CLAUDE_PROJECT_DIR}\"; [ -d \"$p/.claude/hooks\" ] || p=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -d \"$p/.claude/hooks\" ] || exit 0; bash \"$p/.claude/hooks/track-bash-writes.sh\"",
90+
"timeout": 5
91+
},
8292
{
8393
"type": "command",
8494
"command": "p=\"${CLAUDE_PROJECT_DIR}\"; [ -d \"$p/.claude/hooks\" ] || p=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -d \"$p/.claude/hooks\" ] || exit 0; bash \"$p/.claude/hooks/track-moves.sh\"",

0 commit comments

Comments
 (0)