-
Notifications
You must be signed in to change notification settings - Fork 15
fix(hooks): resolve track-edits.sh's PROJECT_DIR from the edited file's path #1982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: fix/issue-1836-update-graph-sh-hook-fallback-build-path
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The documented hook copy now depends on
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
| 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 | ||||||||||||||||||
|
|
||||||||||||||||||
| 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
|
||
| 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
|
||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this hook runs on a shell environment whose
dirnamedoes not accept the non-POSIX--marker,set -emakes 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.