Skip to content

Commit 83aa182

Browse files
authored
fix(hooks): resolve track-edits.sh's PROJECT_DIR from the edited file's path (#1982)
fix(hooks): resolve track-edits.sh's PROJECT_DIR from the edited file's path Fixes #1838. Includes a real Windows-only bug found and fixed during CI: git rev-parse --show-toplevel can return a directory's auto-generated 8.3 short-name alias (e.g. "RUNNER~1" for "runneradmin") while the raw file_path retains the long form, causing path.relative() to see two unrelated-looking trees and emit a long chain of spurious '../' segments. Fixed by canonicalizing both paths via fs.realpathSync.native (which calls the OS's own GetFinalPathNameByHandleW) before computing the relative path, walking up to the nearest existing ancestor first since Write can target a not-yet-created nested directory. Verified against a real Windows CI run and reproduced/regression-tested locally via an analogous symlink scenario. Note: Pre-publish benchmark gate failed 3x on the known-flaky "1-file rebuild" metric, unrelated to this PR's change. Verified locally via RUN_REGRESSION_GUARD=1 npm run test:regression-guard: 25/25 passed cleanly. Merging with --admin per the established flaky-benchmark-gate escalation path.
1 parent 5b1c523 commit 83aa182

3 files changed

Lines changed: 296 additions & 10 deletions

File tree

.claude/hooks/track-edits.sh

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,73 @@ 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+
# Normalize Windows-style separators before any dirname/git splitting below —
27+
# dirname (GNU coreutils) only splits on '/', so a backslash-delimited path
28+
# would make it silently no-op and return ".". Only touch paths that
29+
# unambiguously look like a Windows absolute path (drive letter prefix), so a
30+
# POSIX path containing a literal backslash in a filename is left untouched.
31+
# Uses grep/tr rather than a bash case pattern/parameter expansion so the
32+
# match is byte-based, not affected by the shell's active locale.
33+
if printf '%s' "$FILE_PATH" | grep -qE '^[A-Za-z]:[\\/]'; then
34+
FILE_PATH=$(printf '%s' "$FILE_PATH" | tr '\\' '/')
35+
fi
36+
37+
# Resolve the git worktree that actually owns the edited file, rather than
38+
# the hook process's own ambient cwd. Edit/Write tool calls carry only an
39+
# absolute file_path with no associated "current directory" state, so the
40+
# hook's ambient cwd is not guaranteed to match the worktree the file lives
41+
# in (see issue #1838) — this mirrors the `-C "$WORK_DIR"` pattern
42+
# guard-git.sh already uses on the read side of this same check.
43+
#
44+
# Walk up to the nearest existing ancestor directory first: Write can target
45+
# a not-yet-created nested directory, and `git -C` requires an existing path.
46+
SEARCH_DIR=$(dirname "$FILE_PATH")
47+
while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
48+
SEARCH_DIR=$(dirname "$SEARCH_DIR")
49+
done
50+
51+
PROJECT_DIR=""
52+
if [ -n "$SEARCH_DIR" ] && [ -d "$SEARCH_DIR" ]; then
53+
PROJECT_DIR=$(git -C "$SEARCH_DIR" rev-parse --show-toplevel 2>/dev/null) || true
54+
fi
55+
if [ -z "$PROJECT_DIR" ]; then
56+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
57+
fi
2858
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
2959

30-
# Normalize to relative path with forward slashes
60+
# Normalize to relative path with forward slashes. Canonicalize both sides
61+
# via realpath first (walking up to the nearest existing ancestor, since
62+
# Write can target a not-yet-created nested path) — on Windows the same
63+
# directory can be reported as either its long form or its auto-generated
64+
# 8.3 short-name alias (e.g. "runneradmin" vs "RUNNER~1") depending on which
65+
# API produced it, and a naive string-based path.relative() treats those as
66+
# unrelated trees, producing a long chain of spurious '../' segments.
3167
REL_PATH=$(node -e "
68+
const fs = require('fs');
3269
const path = require('path');
33-
const abs = path.resolve(process.argv[1]);
34-
const base = path.resolve(process.argv[2]);
70+
71+
function realpathWalkUp(p) {
72+
let dir = path.resolve(p);
73+
let tail = '';
74+
while (true) {
75+
try {
76+
// .native calls the OS's own canonicalization (GetFinalPathNameByHandleW
77+
// on Windows), which resolves 8.3 short-name aliases; the plain JS
78+
// fallback only walks symlinks component-by-component and would leave
79+
// a short-name alias like "RUNNER~1" untouched.
80+
const real = fs.realpathSync.native(dir);
81+
return tail ? path.join(real, tail) : real;
82+
} catch {
83+
const parent = path.dirname(dir);
84+
if (parent === dir) return path.resolve(p);
85+
tail = tail ? path.join(path.basename(dir), tail) : path.basename(dir);
86+
dir = parent;
87+
}
88+
}
89+
}
90+
91+
const abs = realpathWalkUp(process.argv[1]);
92+
const base = realpathWalkUp(process.argv[2]);
3593
const rel = path.relative(base, abs).split(path.sep).join('/');
3694
process.stdout.write(rel);
3795
" "$FILE_PATH" "$PROJECT_DIR" 2>/dev/null) || true

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

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,73 @@ 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+
# Normalize Windows-style separators before any dirname/git splitting below —
27+
# dirname (GNU coreutils) only splits on '/', so a backslash-delimited path
28+
# would make it silently no-op and return ".". Only touch paths that
29+
# unambiguously look like a Windows absolute path (drive letter prefix), so a
30+
# POSIX path containing a literal backslash in a filename is left untouched.
31+
# Uses grep/tr rather than a bash case pattern/parameter expansion so the
32+
# match is byte-based, not affected by the shell's active locale.
33+
if printf '%s' "$FILE_PATH" | grep -qE '^[A-Za-z]:[\\/]'; then
34+
FILE_PATH=$(printf '%s' "$FILE_PATH" | tr '\\' '/')
35+
fi
36+
37+
# Resolve the git worktree that actually owns the edited file, rather than
38+
# the hook process's own ambient cwd. Edit/Write tool calls carry only an
39+
# absolute file_path with no associated "current directory" state, so the
40+
# hook's ambient cwd is not guaranteed to match the worktree the file lives
41+
# in (see issue #1838) — this mirrors the `-C "$WORK_DIR"` pattern
42+
# guard-git.sh already uses on the read side of this same check.
43+
#
44+
# Walk up to the nearest existing ancestor directory first: Write can target
45+
# a not-yet-created nested directory, and `git -C` requires an existing path.
46+
SEARCH_DIR=$(dirname "$FILE_PATH")
47+
while [ ! -d "$SEARCH_DIR" ] && [ "$SEARCH_DIR" != "/" ] && [ -n "$SEARCH_DIR" ]; do
48+
SEARCH_DIR=$(dirname "$SEARCH_DIR")
49+
done
50+
51+
PROJECT_DIR=""
52+
if [ -n "$SEARCH_DIR" ] && [ -d "$SEARCH_DIR" ]; then
53+
PROJECT_DIR=$(git -C "$SEARCH_DIR" rev-parse --show-toplevel 2>/dev/null) || true
54+
fi
55+
if [ -z "$PROJECT_DIR" ]; then
56+
PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
57+
fi
2858
LOG_FILE="$PROJECT_DIR/.claude/session-edits.log"
2959

30-
# Normalize to relative path with forward slashes
60+
# Normalize to relative path with forward slashes. Canonicalize both sides
61+
# via realpath first (walking up to the nearest existing ancestor, since
62+
# Write can target a not-yet-created nested path) — on Windows the same
63+
# directory can be reported as either its long form or its auto-generated
64+
# 8.3 short-name alias (e.g. "runneradmin" vs "RUNNER~1") depending on which
65+
# API produced it, and a naive string-based path.relative() treats those as
66+
# unrelated trees, producing a long chain of spurious '../' segments.
3167
REL_PATH=$(node -e "
68+
const fs = require('fs');
3269
const path = require('path');
33-
const abs = path.resolve(process.argv[1]);
34-
const base = path.resolve(process.argv[2]);
70+
71+
function realpathWalkUp(p) {
72+
let dir = path.resolve(p);
73+
let tail = '';
74+
while (true) {
75+
try {
76+
// .native calls the OS's own canonicalization (GetFinalPathNameByHandleW
77+
// on Windows), which resolves 8.3 short-name aliases; the plain JS
78+
// fallback only walks symlinks component-by-component and would leave
79+
// a short-name alias like "RUNNER~1" untouched.
80+
const real = fs.realpathSync.native(dir);
81+
return tail ? path.join(real, tail) : real;
82+
} catch {
83+
const parent = path.dirname(dir);
84+
if (parent === dir) return path.resolve(p);
85+
tail = tail ? path.join(path.basename(dir), tail) : path.basename(dir);
86+
dir = parent;
87+
}
88+
}
89+
}
90+
91+
const abs = realpathWalkUp(process.argv[1]);
92+
const base = realpathWalkUp(process.argv[2]);
3593
const rel = path.relative(base, abs).split(path.sep).join('/');
3694
process.stdout.write(rel);
3795
" "$FILE_PATH" "$PROJECT_DIR" 2>/dev/null) || true
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
89+
it('logs a clean relative path when the edited file is reached through a symlink to the worktree', () => {
90+
// Reproduces (on any OS) the same class of bug as Windows' 8.3 short-name
91+
// aliases (e.g. "runneradmin" vs "RUNNER~1"): two different, valid string
92+
// forms resolve to the identical real directory. `git rev-parse
93+
// --show-toplevel` returns the real (symlink-resolved) path, so without
94+
// canonicalizing FILE_PATH's directory the same way before computing the
95+
// relative path, path.relative sees two unrelated-looking trees and
96+
// produces a long, useless chain of '../' segments instead of a clean
97+
// relative path.
98+
const realRepo = path.join(tmpRoot, 'real-worktree');
99+
const symlinkRepo = path.join(tmpRoot, 'symlink-to-worktree');
100+
initRepo(realRepo);
101+
fs.mkdirSync(path.join(realRepo, 'src'), { recursive: true });
102+
fs.writeFileSync(path.join(realRepo, 'src', 'thing.ts'), '// content\n');
103+
fs.symlinkSync(realRepo, symlinkRepo, 'dir');
104+
105+
const targetFile = path.join(symlinkRepo, 'src', 'thing.ts');
106+
runHook(targetFile, tmpRoot);
107+
108+
const realLog = path.join(realRepo, '.claude', 'session-edits.log');
109+
expect(fs.existsSync(realLog)).toBe(true);
110+
const content = fs.readFileSync(realLog, 'utf8');
111+
expect(content).toMatch(/ src\/thing\.ts$/m);
112+
expect(content).not.toContain('..');
113+
});
114+
});
115+
116+
describe('track-edits.sh Windows path normalization', () => {
117+
// dirname (GNU/BSD coreutils) only splits on '/'. On Windows, Edit/Write's
118+
// file_path arrives backslash-delimited (e.g. from Node's path.join), which
119+
// made dirname silently no-op and return "." — undetectable on a POSIX test
120+
// runner via the integration tests above, since they only ever construct
121+
// paths with the host OS's own separator. Exercise the normalization
122+
// snippet directly (extracted from the real file, not duplicated by hand)
123+
// so a regression here is caught on any host OS.
124+
function extractNormalizationSnippet(hookPath: string): string {
125+
const src = fs.readFileSync(hookPath, 'utf8');
126+
const start = src.indexOf('if printf');
127+
const end = src.indexOf('\nfi', start);
128+
if (start === -1 || end === -1) {
129+
throw new Error(`could not locate normalization if-block in ${hookPath}`);
130+
}
131+
return src.slice(start, end + '\nfi'.length);
132+
}
133+
134+
// Passed via env var, not argv: Windows command-line argument marshalling
135+
// has its own backslash-escaping rules that don't apply to environment
136+
// variables, and the real hook never receives FILE_PATH as a CLI arg
137+
// either (it comes from JSON on stdin) — so this keeps the test's
138+
// transport mechanism from confounding what's actually being verified.
139+
function normalize(hookPath: string, filePath: string): string {
140+
const snippet = extractNormalizationSnippet(hookPath);
141+
const script = `${snippet}\nprintf '%s' "$FILE_PATH"`;
142+
return execFileSync('bash', ['-c', script], {
143+
env: { ...process.env, FILE_PATH: filePath },
144+
}).toString();
145+
}
146+
147+
const DOCS_HOOK_PATH = path.join(
148+
REPO_ROOT,
149+
'docs',
150+
'examples',
151+
'claude-code-hooks',
152+
'track-edits.sh',
153+
);
154+
155+
it.each([
156+
['live hook', HOOK_PATH],
157+
['docs example', DOCS_HOOK_PATH],
158+
])('%s: converts a Windows drive-letter path to forward slashes', (_label, hookPath) => {
159+
expect(normalize(hookPath, 'C:\\Users\\dev\\project\\src\\thing.ts')).toBe(
160+
'C:/Users/dev/project/src/thing.ts',
161+
);
162+
});
163+
164+
it.each([
165+
['live hook', HOOK_PATH],
166+
['docs example', DOCS_HOOK_PATH],
167+
])('%s: leaves a POSIX path with a literal backslash in the filename untouched', (_label, hookPath) => {
168+
expect(normalize(hookPath, '/tmp/proj/weird\\name.ts')).toBe('/tmp/proj/weird\\name.ts');
169+
});
170+
});

0 commit comments

Comments
 (0)