Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .claude/hooks/track-edits.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +35 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Nonportable Dirname Option

When this hook runs on a shell environment whose dirname does not accept the non-POSIX -- marker, set -e makes the script exit at the first path split. The edit is not written to .claude/session-edits.log, so a later guarded commit can reject a file that was actually edited in the session.

Suggested change
SEARCH_DIR=$(dirname -- "$FILE_PATH")
while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
SEARCH_DIR=$(dirname -- "$SEARCH_DIR")
done
SEARCH_DIR=$(dirname "$FILE_PATH")
while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
SEARCH_DIR=$(dirname "$SEARCH_DIR")
done

Fix in Claude Code


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
Expand Down
23 changes: 21 additions & 2 deletions docs/examples/claude-code-hooks/track-edits.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +35 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Example Uses Nonportable Dirname

The documented hook copy now depends on dirname --, while the hook docs do not require a GNU-compatible dirname. Repos that copy this example onto an environment without that option will fail before logging the edit, leaving the commit guard unable to match the edited file.

Suggested change
SEARCH_DIR=$(dirname -- "$FILE_PATH")
while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
SEARCH_DIR=$(dirname -- "$SEARCH_DIR")
done
SEARCH_DIR=$(dirname "$FILE_PATH")
while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
SEARCH_DIR=$(dirname "$SEARCH_DIR")
done

Fix in Claude Code


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
Expand Down
88 changes: 88 additions & 0 deletions tests/unit/hook-track-edits-worktree.test.ts
Original file line number Diff line number Diff line change
@@ -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 "<dir containing file_path>" rev-parse --show-toplevel`) instead
* of the ambient cwd — mirroring the `-C "$WORK_DIR"` pattern guard-git.sh
* already uses on the read side of this same check.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const HOOK_PATH = path.join(REPO_ROOT, '.claude', 'hooks', 'track-edits.sh');

function initRepo(dir: string): void {
fs.mkdirSync(dir, { recursive: true });
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
}

function runHook(filePath: string, cwd: string): void {
const toolInput = JSON.stringify({ tool_input: { file_path: filePath } });
execFileSync('bash', [HOOK_PATH], { cwd, input: toolInput });
}

describe("track-edits.sh resolves the log to the edited file's own worktree", () => {
let tmpRoot: string;

beforeEach(() => {
tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'track-edits-test-')));
});

afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});

it('logs to the target worktree even when the hook process cwd is a different worktree', () => {
const ambientRepo = path.join(tmpRoot, 'ambient-repo');
const targetRepo = path.join(tmpRoot, 'target-worktree');
initRepo(ambientRepo);
initRepo(targetRepo);

const targetFile = path.join(targetRepo, 'src', 'thing.ts');
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
fs.writeFileSync(targetFile, '// content\n');

// The hook process's ambient cwd is a DIFFERENT git worktree than the one
// that owns the edited file — this is the racy condition from #1838.
runHook(targetFile, ambientRepo);

const targetLog = path.join(targetRepo, '.claude', 'session-edits.log');
const ambientLog = path.join(ambientRepo, '.claude', 'session-edits.log');

expect(fs.existsSync(targetLog)).toBe(true);
expect(fs.readFileSync(targetLog, 'utf8')).toMatch(/ src\/thing\.ts$/m);

Check failure on line 71 in tests/unit/hook-track-edits-worktree.test.ts

View workflow job for this annotation

GitHub Actions / Test Node 22 (windows-2022)

tests/unit/hook-track-edits-worktree.test.ts > track-edits.sh resolves the log to the edited file's own worktree > logs to the target worktree even when the hook process cwd is a different worktree

AssertionError: expected '2026-07-07T22:54:07Z ../../../../../.…' to match / src\/thing\.ts$/thing\.ts$ - Expected: / src\/thing\.ts$/m + Received: "2026-07-07T22:54:07Z ../../../../../../RUNNER~1/AppData/Local/Temp/track-edits-test-cTWoi7/target-worktree/src/thing.ts " ❯ tests/unit/hook-track-edits-worktree.test.ts:71:48
expect(fs.existsSync(ambientLog)).toBe(false);
});

it('walks up to the nearest existing ancestor when Write targets a not-yet-created nested directory', () => {
const targetRepo = path.join(tmpRoot, 'target-worktree-2');
initRepo(targetRepo);
// a/b/c does not exist yet — simulates the Write tool creating new nested dirs.
const nestedFile = path.join(targetRepo, 'a', 'b', 'c', 'new-file.ts');

// Ambient cwd is entirely outside any git repo.
runHook(nestedFile, tmpRoot);

const targetLog = path.join(targetRepo, '.claude', 'session-edits.log');
expect(fs.existsSync(targetLog)).toBe(true);
expect(fs.readFileSync(targetLog, 'utf8')).toMatch(/ a\/b\/c\/new-file\.ts$/m);

Check failure on line 86 in tests/unit/hook-track-edits-worktree.test.ts

View workflow job for this annotation

GitHub Actions / Test Node 22 (windows-2022)

tests/unit/hook-track-edits-worktree.test.ts > track-edits.sh resolves the log to the edited file's own worktree > walks up to the nearest existing ancestor when Write targets a not-yet-created nested directory

AssertionError: expected '2026-07-07T22:54:08Z ../../../../../.…' to match / a\/b\/c\/new-file\.ts$/b\ - Expected: / a\/b\/c\/new-file\.ts$/m + Received: "2026-07-07T22:54:08Z ../../../../../../RUNNER~1/AppData/Local/Temp/track-edits-test-2nsAkX/target-worktree-2/a/b/c/new-file.ts " ❯ tests/unit/hook-track-edits-worktree.test.ts:86:48
});
});
Loading