Skip to content

Commit 730b49b

Browse files
committed
fix(hooks): resolve track-edits.sh's PROJECT_DIR from the edited file's path
track-edits.sh resolved the session-edits.log location via a bare `git rev-parse --show-toplevel`, relying on the hook process's own ambient cwd. Edit/Write tool calls carry only an absolute file_path with no associated "current directory" state, so that ambient cwd is not guaranteed to match the worktree that actually owns the edited file — causing edits to be logged to the wrong worktree (or dropped) and guard-git.sh to later block legitimate commits with "NOT edited in this session". Derive PROJECT_DIR from the edited file's own path instead (`git -C "<dir containing file_path>" rev-parse --show-toplevel`, walking up to the nearest existing ancestor for not-yet-created Write targets), mirroring the `-C "$WORK_DIR"` pattern guard-git.sh already uses on the read side of this same check. Applied to both the live hook and its docs/examples mirror. Fixes #1838
1 parent e0ab9dc commit 730b49b

3 files changed

Lines changed: 130 additions & 4 deletions

File tree

.claude/hooks/track-edits.sh

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,27 @@ if [ -z "$FILE_PATH" ]; then
2323
exit 0
2424
fi
2525

26-
# Use git worktree root so each worktree session has its own edit log
27-
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
26+
# Resolve the git worktree that actually owns the edited file, rather than
27+
# the hook process's own ambient cwd. Edit/Write tool calls carry only an
28+
# absolute file_path with no associated "current directory" state, so the
29+
# hook's ambient cwd is not guaranteed to match the worktree the file lives
30+
# in (see issue #1838) — this mirrors the `-C "$WORK_DIR"` pattern
31+
# guard-git.sh already uses on the read side of this same check.
32+
#
33+
# Walk up to the nearest existing ancestor directory first: Write can target
34+
# a not-yet-created nested directory, and `git -C` requires an existing path.
35+
SEARCH_DIR=$(dirname -- "$FILE_PATH")
36+
while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
37+
SEARCH_DIR=$(dirname -- "$SEARCH_DIR")
38+
done
39+
40+
PROJECT_DIR=""
41+
if [ -n "$SEARCH_DIR" ] && [ -d "$SEARCH_DIR" ]; then
42+
PROJECT_DIR=$(git -C "$SEARCH_DIR" rev-parse --show-toplevel 2>/dev/null) || true
43+
fi
44+
if [ -z "$PROJECT_DIR" ]; then
45+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
46+
fi
2847
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
2948

3049
# Normalize to relative path with forward slashes

docs/examples/claude-code-hooks/track-edits.sh

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,27 @@ if [ -z "$FILE_PATH" ]; then
2323
exit 0
2424
fi
2525

26-
# Use git worktree root so each worktree session has its own edit log
27-
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
26+
# Resolve the git worktree that actually owns the edited file, rather than
27+
# the hook process's own ambient cwd. Edit/Write tool calls carry only an
28+
# absolute file_path with no associated "current directory" state, so the
29+
# hook's ambient cwd is not guaranteed to match the worktree the file lives
30+
# in (see issue #1838) — this mirrors the `-C "$WORK_DIR"` pattern
31+
# guard-git.sh already uses on the read side of this same check.
32+
#
33+
# Walk up to the nearest existing ancestor directory first: Write can target
34+
# a not-yet-created nested directory, and `git -C` requires an existing path.
35+
SEARCH_DIR=$(dirname -- "$FILE_PATH")
36+
while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
37+
SEARCH_DIR=$(dirname -- "$SEARCH_DIR")
38+
done
39+
40+
PROJECT_DIR=""
41+
if [ -n "$SEARCH_DIR" ] && [ -d "$SEARCH_DIR" ]; then
42+
PROJECT_DIR=$(git -C "$SEARCH_DIR" rev-parse --show-toplevel 2>/dev/null) || true
43+
fi
44+
if [ -z "$PROJECT_DIR" ]; then
45+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
46+
fi
2847
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
2948

3049
# Normalize to relative path with forward slashes
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Regression guard for issue #1838: .claude/hooks/track-edits.sh (PostToolUse
3+
* hook for Edit/Write) resolved PROJECT_DIR from the hook process's own
4+
* ambient cwd (`git rev-parse --show-toplevel` with no `-C`). Edit/Write tool
5+
* calls carry only an absolute `file_path` with no associated "current
6+
* directory" state, so the hook's ambient cwd is not guaranteed to match the
7+
* worktree that actually owns the edited file — especially in worktree
8+
* sessions where the ambient cwd can lag behind interleaved Bash `cd` calls.
9+
* When it doesn't match, the log entry lands in the wrong worktree's
10+
* `.claude/session-edits.log` (or is silently dropped as a `..`-relative
11+
* path), and guard-git.sh later blocks a legitimate commit claiming the file
12+
* was "NOT edited in this session".
13+
*
14+
* The fix derives PROJECT_DIR from the edited file's own path
15+
* (`git -C "<dir containing file_path>" rev-parse --show-toplevel`) instead
16+
* of the ambient cwd — mirroring the `-C "$WORK_DIR"` pattern guard-git.sh
17+
* already uses on the read side of this same check.
18+
*/
19+
import { execFileSync } from 'node:child_process';
20+
import fs from 'node:fs';
21+
import os from 'node:os';
22+
import path from 'node:path';
23+
import { fileURLToPath } from 'node:url';
24+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
25+
26+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
27+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
28+
const HOOK_PATH = path.join(REPO_ROOT, '.claude', 'hooks', 'track-edits.sh');
29+
30+
function initRepo(dir: string): void {
31+
fs.mkdirSync(dir, { recursive: true });
32+
execFileSync('git', ['init', '-q'], { cwd: dir });
33+
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
34+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
35+
}
36+
37+
function runHook(filePath: string, cwd: string): void {
38+
const toolInput = JSON.stringify({ tool_input: { file_path: filePath } });
39+
execFileSync('bash', [HOOK_PATH], { cwd, input: toolInput });
40+
}
41+
42+
describe("track-edits.sh resolves the log to the edited file's own worktree", () => {
43+
let tmpRoot: string;
44+
45+
beforeEach(() => {
46+
tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'track-edits-test-')));
47+
});
48+
49+
afterEach(() => {
50+
fs.rmSync(tmpRoot, { recursive: true, force: true });
51+
});
52+
53+
it('logs to the target worktree even when the hook process cwd is a different worktree', () => {
54+
const ambientRepo = path.join(tmpRoot, 'ambient-repo');
55+
const targetRepo = path.join(tmpRoot, 'target-worktree');
56+
initRepo(ambientRepo);
57+
initRepo(targetRepo);
58+
59+
const targetFile = path.join(targetRepo, 'src', 'thing.ts');
60+
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
61+
fs.writeFileSync(targetFile, '// content\n');
62+
63+
// The hook process's ambient cwd is a DIFFERENT git worktree than the one
64+
// that owns the edited file — this is the racy condition from #1838.
65+
runHook(targetFile, ambientRepo);
66+
67+
const targetLog = path.join(targetRepo, '.claude', 'session-edits.log');
68+
const ambientLog = path.join(ambientRepo, '.claude', 'session-edits.log');
69+
70+
expect(fs.existsSync(targetLog)).toBe(true);
71+
expect(fs.readFileSync(targetLog, 'utf8')).toMatch(/ src\/thing\.ts$/m);
72+
expect(fs.existsSync(ambientLog)).toBe(false);
73+
});
74+
75+
it('walks up to the nearest existing ancestor when Write targets a not-yet-created nested directory', () => {
76+
const targetRepo = path.join(tmpRoot, 'target-worktree-2');
77+
initRepo(targetRepo);
78+
// a/b/c does not exist yet — simulates the Write tool creating new nested dirs.
79+
const nestedFile = path.join(targetRepo, 'a', 'b', 'c', 'new-file.ts');
80+
81+
// Ambient cwd is entirely outside any git repo.
82+
runHook(nestedFile, tmpRoot);
83+
84+
const targetLog = path.join(targetRepo, '.claude', 'session-edits.log');
85+
expect(fs.existsSync(targetLog)).toBe(true);
86+
expect(fs.readFileSync(targetLog, 'utf8')).toMatch(/ a\/b\/c\/new-file\.ts$/m);
87+
});
88+
});

0 commit comments

Comments
 (0)