|
| 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 |
0 commit comments