Skip to content
72 changes: 72 additions & 0 deletions .claude/hooks/snapshot-pre-bash.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# snapshot-pre-bash.sh — PreToolUse hook for Bash tool calls
# Snapshots `git status --porcelain` to a temp file before each Bash call so
# that track-bash-writes.sh (PostToolUse) can diff the before/after state and
# log files newly modified by the command to .claude/session-edits.log.
# Always exits 0 (informational only, never blocks).

set -euo pipefail

INPUT=$(cat)

# Extract the command from tool_input JSON
COMMAND=$(echo "$INPUT" | node -e "
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
const p=JSON.parse(d).tool_input?.command||'';
if(p)process.stdout.write(p);
});
" 2>/dev/null) || true

if [ -z "$COMMAND" ]; then
exit 0
fi

# Skip commands that can NEVER write files — reduces snapshot overhead for the
# most common read-only Bash calls. Only include commands that have no
# write-capable flags/modes at all. Notably absent:
# - echo, printf — write files via shell redirections (echo … > file)
# - find — can write via -exec sed -i, -exec cp, -delete, etc.
# - awk — can write via redirection or getline
# - node -e/-p — can write files via the Node.js fs module
# sed is intentionally NOT in this list because `sed -i` modifies files in-place.
if echo "$COMMAND" | grep -qE '^\s*(ls|cat|head|tail|grep|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)|pwd|which|npx\s+--version|wc|sort|uniq)\b'; then
exit 0
fi

# Resolve the project root (worktree-aware — each worktree has its own .claude/)
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"

# Key the snapshot file to (project root, command) so concurrent Bash calls
# within the same session don't overwrite each other's baseline.
# Claude Code can issue multiple Bash tool calls in parallel; using just the
# project hash would mean call B's pre-hook overwrites call A's snapshot before
# A's post-hook runs, silently dropping A's file writes from session-edits.log.
# Including a hash of the command makes each concurrent call use a distinct file.
PROJECT_HASH=$(echo "$PROJECT_DIR" | node -e "
const crypto = require('crypto');
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
process.stdout.write(crypto.createHash('sha1').update(d.trim()).digest('hex').slice(0,8));
});
" 2>/dev/null) || PROJECT_HASH="default"

CMD_HASH=$(echo "$COMMAND" | node -e "
const crypto = require('crypto');
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
process.stdout.write(crypto.createHash('sha1').update(d.trim()).digest('hex').slice(0,8));
});
" 2>/dev/null) || CMD_HASH="default"

SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_HASH}-${CMD_HASH}.txt"

# Capture current git status --porcelain.
# Lines look like: "XY filename" or "XY orig -> dest" (rename).
# We only care about the status marker and path — porcelain is stable across git versions.
git -C "$PROJECT_DIR" status --porcelain 2>/dev/null > "$SNAPSHOT_FILE" || true

exit 0
131 changes: 131 additions & 0 deletions .claude/hooks/track-bash-writes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# track-bash-writes.sh — PostToolUse hook for Bash tool calls
# Compares `git status --porcelain` against the snapshot taken by
# snapshot-pre-bash.sh (PreToolUse) to detect files newly modified or
# created by the Bash command, then appends them to .claude/session-edits.log
# so that guard-git.sh can validate commits correctly.
# Always exits 0 (informational only, never blocks).

set -euo pipefail

INPUT=$(cat)

# Extract the command from tool_input JSON
COMMAND=$(echo "$INPUT" | node -e "
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
const p=JSON.parse(d).tool_input?.command||'';
if(p)process.stdout.write(p);
});
" 2>/dev/null) || true

if [ -z "$COMMAND" ]; then
exit 0
fi

# Resolve the project root (worktree-aware — each worktree has its own .claude/)
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"

# Reproduce the same snapshot path used by snapshot-pre-bash.sh.
# The snapshot is keyed by (project root, command) so concurrent Bash calls
# within the same session each get a distinct file — preventing parallel calls
# from overwriting each other's baseline.
PROJECT_HASH=$(echo "$PROJECT_DIR" | node -e "
const crypto = require('crypto');
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
process.stdout.write(crypto.createHash('sha1').update(d.trim()).digest('hex').slice(0,8));
});
" 2>/dev/null) || PROJECT_HASH="default"

CMD_HASH=$(echo "$COMMAND" | node -e "
const crypto = require('crypto');
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
process.stdout.write(crypto.createHash('sha1').update(d.trim()).digest('hex').slice(0,8));
});
" 2>/dev/null) || CMD_HASH="default"

SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_HASH}-${CMD_HASH}.txt"

# If there is no snapshot (hook was not installed yet, or the pre-hook was
# skipped for a read-only command) we have no baseline — exit cleanly.
if [ ! -f "$SNAPSHOT_FILE" ]; then
exit 0
fi

# Capture current state after the command ran
AFTER=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null) || true

# Read the before-state
BEFORE=$(cat "$SNAPSHOT_FILE") || true

# Clean up the snapshot so it doesn't pollute the next command's pre-hook
rm -f "$SNAPSHOT_FILE"

# Build the set of paths that existed (as dirty) before the command ran.
# porcelain format: "XY path" or "XY original -> new" (rename).
# We extract every path token after the two-char status code.
parse_paths() {
local status_output="$1"
echo "$status_output" | awk '
/^[ MADRCU?!]{2} / {
# Drop the two-char status + space
rest = substr($0, 4)
# Handle rename: "old -> new"
if (index(rest, " -> ") > 0) {
n = split(rest, parts, " -> ")
for (i = 1; i <= n; i++) {
p = parts[i]
gsub(/^"/, "", p); gsub(/"$/, "", p)
if (p != "") print p
}
} else {
gsub(/^"/, "", rest); gsub(/"$/, "", rest)
if (rest != "") print rest
}
}
'
}

BEFORE_PATHS=$(parse_paths "$BEFORE" | sort)
AFTER_PATHS=$(parse_paths "$AFTER" | sort)

if [ -z "$AFTER_PATHS" ]; then
exit 0
fi

# Find paths present in AFTER but not in BEFORE — these were newly dirtied
# (modified, created, or renamed-to) by the Bash command.
NEW_PATHS=$(comm -13 <(echo "$BEFORE_PATHS") <(echo "$AFTER_PATHS")) || true

if [ -z "$NEW_PATHS" ]; then
exit 0
fi

# Also exclude paths that were already tracked by track-edits.sh or other hooks
# (i.e. already in the session-edits.log) so we don't double-log.
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
ALREADY_LOGGED=""
if [ -f "$LOG_FILE" ] && [ -s "$LOG_FILE" ]; then
ALREADY_LOGGED=$(awk '{print $2}' "$LOG_FILE" | sort -u)
fi

mkdir -p "$(dirname "$LOG_FILE")"
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)

while IFS= read -r rel_path; do
if [ -z "$rel_path" ]; then
continue
fi
# Skip if already in the log from a prior hook (Edit/Write/track-moves)
if [ -n "$ALREADY_LOGGED" ] && echo "$ALREADY_LOGGED" | grep -qxF "$rel_path"; then
continue
fi
echo "$TS $rel_path" >> "$LOG_FILE"
done <<< "$NEW_PATHS"

exit 0
10 changes: 10 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"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\"",
"timeout": 5
},
{
"type": "command",
"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\"",
Expand Down Expand Up @@ -79,6 +84,11 @@
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"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\"",
"timeout": 5
},
{
"type": "command",
"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\"",
Expand Down
Loading