-
Notifications
You must be signed in to change notification settings - Fork 17
fix(hooks): track Bash file modifications to prevent false-positive commit blocks #1483
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
3892e7d
chore: gitignore napi-generated artifacts in crates/codegraph-core
carlos-alm ef8ea4f
chore(tests): remove unused biome suppression in visitor.test.ts
carlos-alm a372b82
fix(titan-run): sync --start-from enum and phase-timestamp list with …
carlos-alm 9a52c7c
fix(hooks): track Bash file modifications via before/after git status…
carlos-alm 12364b6
fix(hooks): remove echo/printf/find/awk from skip list; key snapshot …
carlos-alm 0107480
Merge branch 'main' into fix/hooks-bash-tracking-1457
carlos-alm c3d4722
fix(hooks): remove node -e/-p from read-only skip list (#1483)
carlos-alm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| #!/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 read-only commands that can never write files — reduces snapshot overhead | ||
| # for the most common Bash calls (ls, cat, grep, git log, git status, etc.). | ||
| # 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|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 | ||
| exit 0 | ||
| fi | ||
|
carlos-alm marked this conversation as resolved.
Outdated
|
||
|
|
||
| # 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 the project root so parallel worktrees don't collide. | ||
| # Use a simple hash of the path — just enough to be unique per worktree. | ||
| 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" | ||
|
|
||
| SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_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 | ||
|
carlos-alm marked this conversation as resolved.
Outdated
|
||
|
|
||
| exit 0 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| #!/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 project hash used by snapshot-pre-bash.sh | ||
| 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" | ||
|
|
||
| SNAPSHOT_FILE="/tmp/claude-bash-snapshot-${PROJECT_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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.