diff --git a/.claude/hooks/track-edits.sh b/.claude/hooks/track-edits.sh index 758db1328..f73652e8f 100644 --- a/.claude/hooks/track-edits.sh +++ b/.claude/hooks/track-edits.sh @@ -23,8 +23,27 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi -# Use git worktree root so each worktree session has its own edit log -PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +# Resolve the git worktree that actually owns the edited file, rather than +# the hook process's own ambient cwd. Edit/Write tool calls carry only an +# absolute file_path with no associated "current directory" state, so the +# hook's ambient cwd is not guaranteed to match the worktree the file lives +# in (see issue #1838) — this mirrors the `-C "$WORK_DIR"` pattern +# guard-git.sh already uses on the read side of this same check. +# +# Walk up to the nearest existing ancestor directory first: Write can target +# a not-yet-created nested directory, and `git -C` requires an existing path. +SEARCH_DIR=$(dirname -- "$FILE_PATH") +while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do + SEARCH_DIR=$(dirname -- "$SEARCH_DIR") +done + +PROJECT_DIR="" +if [ -n "$SEARCH_DIR" ] && [ -d "$SEARCH_DIR" ]; then + PROJECT_DIR=$(git -C "$SEARCH_DIR" rev-parse --show-toplevel 2>/dev/null) || true +fi +if [ -z "$PROJECT_DIR" ]; then + PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +fi LOG_FILE="$PROJECT_DIR/.claude/session-edits.log" # Normalize to relative path with forward slashes diff --git a/docs/examples/claude-code-hooks/track-edits.sh b/docs/examples/claude-code-hooks/track-edits.sh index 758db1328..f73652e8f 100644 --- a/docs/examples/claude-code-hooks/track-edits.sh +++ b/docs/examples/claude-code-hooks/track-edits.sh @@ -23,8 +23,27 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi -# Use git worktree root so each worktree session has its own edit log -PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +# Resolve the git worktree that actually owns the edited file, rather than +# the hook process's own ambient cwd. Edit/Write tool calls carry only an +# absolute file_path with no associated "current directory" state, so the +# hook's ambient cwd is not guaranteed to match the worktree the file lives +# in (see issue #1838) — this mirrors the `-C "$WORK_DIR"` pattern +# guard-git.sh already uses on the read side of this same check. +# +# Walk up to the nearest existing ancestor directory first: Write can target +# a not-yet-created nested directory, and `git -C` requires an existing path. +SEARCH_DIR=$(dirname -- "$FILE_PATH") +while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do + SEARCH_DIR=$(dirname -- "$SEARCH_DIR") +done + +PROJECT_DIR="" +if [ -n "$SEARCH_DIR" ] && [ -d "$SEARCH_DIR" ]; then + PROJECT_DIR=$(git -C "$SEARCH_DIR" rev-parse --show-toplevel 2>/dev/null) || true +fi +if [ -z "$PROJECT_DIR" ]; then + PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +fi LOG_FILE="$PROJECT_DIR/.claude/session-edits.log" # Normalize to relative path with forward slashes diff --git a/tests/unit/hook-track-edits-worktree.test.ts b/tests/unit/hook-track-edits-worktree.test.ts new file mode 100644 index 000000000..1be1b7ff2 --- /dev/null +++ b/tests/unit/hook-track-edits-worktree.test.ts @@ -0,0 +1,88 @@ +/** + * Regression guard for issue #1838: .claude/hooks/track-edits.sh (PostToolUse + * hook for Edit/Write) resolved PROJECT_DIR from the hook process's own + * ambient cwd (`git rev-parse --show-toplevel` with no `-C`). Edit/Write tool + * calls carry only an absolute `file_path` with no associated "current + * directory" state, so the hook's ambient cwd is not guaranteed to match the + * worktree that actually owns the edited file — especially in worktree + * sessions where the ambient cwd can lag behind interleaved Bash `cd` calls. + * When it doesn't match, the log entry lands in the wrong worktree's + * `.claude/session-edits.log` (or is silently dropped as a `..`-relative + * path), and guard-git.sh later blocks a legitimate commit claiming the file + * was "NOT edited in this session". + * + * The fix derives PROJECT_DIR from the edited file's own path + * (`git -C "