|
| 1 | +#!/usr/bin/env bash |
| 2 | +# track-moves.sh — PostToolUse hook for Bash tool calls |
| 3 | +# Detects mv/git mv/cp commands and logs all affected paths |
| 4 | +# (both source and destination) to .claude/session-edits.log so that |
| 5 | +# guard-git.sh can validate commits that include moved/copied files. |
| 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 | +# Only care about mv / git mv / cp commands |
| 27 | +if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)(mv|git\s+mv|cp)\s+'; then |
| 28 | + exit 0 |
| 29 | +fi |
| 30 | + |
| 31 | +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" |
| 32 | +LOG_FILE="$PROJECT_DIR/.claude/session-edits.log" |
| 33 | + |
| 34 | +# Use node to parse the command and extract all file paths involved |
| 35 | +PATHS=$(echo "$COMMAND" | node -e " |
| 36 | + const path = require('path'); |
| 37 | + let d = ''; |
| 38 | + process.stdin.on('data', c => d += c); |
| 39 | + process.stdin.on('end', () => { |
| 40 | + const base = path.resolve(process.argv[1]); |
| 41 | + const results = new Set(); |
| 42 | +
|
| 43 | + // Split on && or ; to handle chained commands |
| 44 | + const parts = d.split(/\s*(?:&&|;)\s*/); |
| 45 | +
|
| 46 | + for (const part of parts) { |
| 47 | + // Match: mv / cp / git mv followed by arguments |
| 48 | + const m = part.match(/(?:git\s+mv|mv|cp)\s+(.+)/); |
| 49 | + if (!m) continue; |
| 50 | +
|
| 51 | + // Simple arg splitting that respects quotes |
| 52 | + const raw = m[1]; |
| 53 | + const args = []; |
| 54 | + let cur = ''; |
| 55 | + let q = null; |
| 56 | + for (let i = 0; i < raw.length; i++) { |
| 57 | + const c = raw[i]; |
| 58 | + if (q) { if (c === q) q = null; else cur += c; } |
| 59 | + else if (c === '\"' || c === \"'\") { q = c; } |
| 60 | + else if (c === ' ' || c === '\\t') { if (cur) { args.push(cur); cur = ''; } } |
| 61 | + else { cur += c; } |
| 62 | + } |
| 63 | + if (cur) args.push(cur); |
| 64 | +
|
| 65 | + // Filter out flags (-f, -v, --force, etc.) |
| 66 | + const paths = args.filter(a => !a.startsWith('-')); |
| 67 | +
|
| 68 | + // Resolve each path relative to project root |
| 69 | + for (const p of paths) { |
| 70 | + const abs = path.resolve(p); |
| 71 | + const rel = path.relative(base, abs).split(path.sep).join('/'); |
| 72 | + if (!rel.startsWith('..')) results.add(rel); |
| 73 | + } |
| 74 | + } |
| 75 | +
|
| 76 | + process.stdout.write([...results].join('\\n')); |
| 77 | + }); |
| 78 | +" "$PROJECT_DIR" 2>/dev/null) || true |
| 79 | + |
| 80 | +if [ -z "$PATHS" ]; then |
| 81 | + exit 0 |
| 82 | +fi |
| 83 | + |
| 84 | +# Append timestamped entries for every affected path |
| 85 | +mkdir -p "$(dirname "$LOG_FILE")" |
| 86 | +TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) |
| 87 | +while IFS= read -r rel_path; do |
| 88 | + if [ -n "$rel_path" ]; then |
| 89 | + echo "$TS $rel_path" >> "$LOG_FILE" |
| 90 | + fi |
| 91 | +done <<< "$PATHS" |
| 92 | + |
| 93 | +exit 0 |
0 commit comments