Skip to content

Commit 4641808

Browse files
authored
fix(cli): record AI sessions for edits in git worktrees (#3469)
1 parent 8b9278b commit 4641808

14 files changed

Lines changed: 397 additions & 25 deletions

File tree

‎app/cli/internal/trace/claude/hooks_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ func TestReadHookInput(t *testing.T) {
221221
assert.Equal(t, "abc-123", input.SessionID)
222222
assert.Equal(t, "SessionStart", input.HookEventName)
223223
assert.Equal(t, "Edit", input.ToolName)
224+
assert.Equal(t, "/some/path", input.Cwd)
224225
})
225226

226227
t.Run("extracts tool metadata", func(t *testing.T) {

‎app/cli/internal/trace/claude/provider.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ func (p *Provider) SessionDirForRepo(repoRoot string) string {
7878
// CopySessionData copies the Claude Code JSONL (and subagent files) from the
7979
// Claude project directory into the store's raw/ directory so pre-push can
8080
// parse them even if Claude rotates its own storage later.
81-
func (p *Provider) CopySessionData(store *state.Store, repoRoot, sessionID string) error {
82-
sourceDir := p.SessionDirForRepo(repoRoot)
81+
func (p *Provider) CopySessionData(store *state.Store, agentCwd, sessionID string) error {
82+
sourceDir := p.SessionDirForRepo(agentCwd)
8383
if sourceDir == "" {
8484
return nil
8585
}

‎app/cli/internal/trace/cursor/hooks.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ type cursorHookInput struct {
172172
HookEventName string `json:"hook_event_name"`
173173
CursorVersion string `json:"cursor_version"`
174174
Model string `json:"model"`
175+
WorkspaceRoots []string `json:"workspace_roots"`
175176
FilePath string `json:"file_path"`
176177
Edits []cursorHookEdit `json:"edits"`
177178
}
@@ -212,6 +213,13 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
212213
Model: raw.Model,
213214
}
214215

216+
// Cursor files transcripts under the workspace it was opened on. A
217+
// multi-root workspace has no single answer; the first root is the one
218+
// Cursor lists first and the best guess available.
219+
if len(raw.WorkspaceRoots) > 0 {
220+
input.Cwd = raw.WorkspaceRoots[0]
221+
}
222+
215223
if raw.HookEventName == eventAfterFileEdit {
216224
input.ToolName = syntheticEditToolName
217225
}

‎app/cli/internal/trace/cursor/hooks_test.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ func TestReadHookInputAfterFileEdit(t *testing.T) {
164164
"hook_event_name": "afterFileEdit",
165165
"model": "claude-4",
166166
"cursor_version": "0.48.0",
167+
"workspace_roots": ["/abs/path", "/abs/other"],
167168
"file_path": "/abs/path/file.go",
168169
"edits": [
169170
{"old_string": "foo", "new_string": "bar"},
@@ -178,6 +179,7 @@ func TestReadHookInputAfterFileEdit(t *testing.T) {
178179
assert.Equal(t, "afterFileEdit", in.HookEventName, "HookEventName")
179180
assert.Equal(t, syntheticEditToolName, in.ToolName, "ToolName")
180181
assert.Equal(t, "/abs/path/file.go", in.FilePath, "FilePath")
182+
assert.Equal(t, "/abs/path", in.Cwd, "Cwd is the first workspace root")
181183
require.Len(t, in.Edits, 2, "Edits len")
182184
assert.Equal(t, "foo", in.Edits[0].OldString, "first edit OldString")
183185
assert.Equal(t, "bar", in.Edits[0].NewString, "first edit NewString")

‎app/cli/internal/trace/cursor/provider.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ func (p *Provider) CleanupAfterEdit(_ *state.Store, _ *trace.HookInput) {}
138138
// the store's raw/<sessionID>.jsonl. Handles both flat and nested
139139
// source layouts; the destination is always flat so downstream consumers
140140
// (parse, pre-push) don't need to re-resolve.
141-
func (p *Provider) CopySessionData(store *state.Store, repoRoot, sessionID string) error {
142-
sourceDir := p.SessionDirForRepo(repoRoot)
141+
func (p *Provider) CopySessionData(store *state.Store, agentCwd, sessionID string) error {
142+
sourceDir := p.SessionDirForRepo(agentCwd)
143143
if sourceDir == "" {
144144
return nil
145145
}

‎app/cli/internal/trace/git/repo.go‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ func FindGitDirAndRoot() (gitDir, repoRoot string, err error) {
4646
return findGitDirAndRoot()
4747
}
4848

49+
// FindGitDirAndRootFrom is FindGitDirAndRoot starting from dir instead of
50+
// cwd. dir need not exist yet: the walk only probes for .git on the way up.
51+
func FindGitDirAndRootFrom(dir string) (gitDir, repoRoot string, err error) {
52+
abs, err := filepath.Abs(dir)
53+
if err != nil {
54+
return "", "", fmt.Errorf("resolve %q: %w", dir, err)
55+
}
56+
57+
return findGitDirAndRootFrom(abs)
58+
}
59+
4960
// findGitDirAndRoot walks up from cwd to locate .git, avoiding go-git's strict
5061
// config validation which fails on repos with invalid branch config.
5162
func findGitDirAndRoot() (string, string, error) {
@@ -54,7 +65,10 @@ func findGitDirAndRoot() (string, string, error) {
5465
return "", "", fmt.Errorf("get working directory: %w", err)
5566
}
5667

57-
dir := cwd
68+
return findGitDirAndRootFrom(cwd)
69+
}
70+
71+
func findGitDirAndRootFrom(dir string) (string, string, error) {
5872
for {
5973
dotGit := filepath.Join(dir, ".git")
6074
fi, err := os.Lstat(dotGit)

‎app/cli/internal/trace/git/repo_test.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,79 @@ func TestHooksInstallFromWorktree(t *testing.T) {
210210
"hook %s should NOT be written to worktree-private hooks dir", name)
211211
}
212212
}
213+
214+
// Agent hooks run from the directory the session started in, which is not
215+
// necessarily the checkout that owns the file being edited (PFM-7412), so the
216+
// lookup has to start from an explicit directory rather than cwd.
217+
func TestFindGitDirAndRootFrom(t *testing.T) {
218+
if _, err := exec.LookPath("git"); err != nil {
219+
t.Skip("git binary not found, skipping worktree test")
220+
}
221+
222+
mainDir, err := filepath.EvalSymlinks(t.TempDir())
223+
require.NoError(t, err)
224+
wtDir := filepath.Join(mainDir, ".claude", "worktrees", "feature")
225+
226+
run := func(args ...string) {
227+
cmd := exec.Command("git", args...)
228+
cmd.Dir = mainDir
229+
out, err := cmd.CombinedOutput()
230+
require.NoError(t, err, "git %v failed: %s", args, string(out))
231+
}
232+
run("init", "-b", "main")
233+
run("config", "user.email", "test@test.com")
234+
run("config", "user.name", "Test")
235+
run("commit", "--allow-empty", "-m", "init")
236+
run("worktree", "add", wtDir, "-b", "feature")
237+
238+
// cwd is the main checkout throughout, as it is for an agent hook.
239+
t.Chdir(mainDir)
240+
241+
tests := []struct {
242+
name string
243+
dir string
244+
wantGitDir string
245+
wantRoot string
246+
errNotARepo bool
247+
}{
248+
{
249+
name: "main checkout",
250+
dir: mainDir,
251+
wantGitDir: filepath.Join(mainDir, ".git"),
252+
wantRoot: mainDir,
253+
},
254+
{
255+
name: "worktree nested in the main checkout",
256+
dir: wtDir,
257+
wantGitDir: filepath.Join(mainDir, ".git", "worktrees", "feature"),
258+
wantRoot: wtDir,
259+
},
260+
{
261+
// A Write creating a file in a new directory: the directory does
262+
// not exist yet when the pre-tool-use hook runs.
263+
name: "not yet created directory inside a worktree",
264+
dir: filepath.Join(wtDir, "new", "pkg"),
265+
wantGitDir: filepath.Join(mainDir, ".git", "worktrees", "feature"),
266+
wantRoot: wtDir,
267+
},
268+
{
269+
name: "outside any repository",
270+
dir: t.TempDir(),
271+
errNotARepo: true,
272+
},
273+
}
274+
275+
for _, tc := range tests {
276+
t.Run(tc.name, func(t *testing.T) {
277+
gitDir, root, err := FindGitDirAndRootFrom(tc.dir)
278+
if tc.errNotARepo {
279+
assert.ErrorIs(t, err, ErrNotARepository)
280+
return
281+
}
282+
283+
require.NoError(t, err)
284+
assert.Equal(t, tc.wantGitDir, gitDir)
285+
assert.Equal(t, tc.wantRoot, root)
286+
})
287+
}
288+
}

‎app/cli/internal/trace/provider.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ type Provider interface {
5454
// CopySessionData copies the agent's on-disk session artifacts into
5555
// the store's raw/ directory so pre-push can parse them independently of
5656
// the agent's own storage (which may be rotated/cleaned later).
57-
CopySessionData(store *state.Store, repoRoot, sessionID string) error
57+
// agentCwd is the directory the session runs in, which decides where the
58+
// agent keeps its transcripts; it need not be the checkout that owns
59+
// store.
60+
CopySessionData(store *state.Store, agentCwd, sessionID string) error
5861

5962
// CaptureFileSnapshot is invoked from the pre-edit hook to record any
6063
// state the provider needs to later reconstruct the file's pre-edit
@@ -132,6 +135,11 @@ type HookInput struct {
132135
ToolName string `json:"tool_name,omitempty"`
133136
// FilePath is the absolute path of the file being edited, set by provider's ReadHookInput.
134137
FilePath string `json:"-"`
138+
// Cwd is the directory the agent session runs in, which is where the
139+
// agent files its transcripts. It can differ from the checkout that owns
140+
// FilePath, e.g. for an edit in a linked git worktree. Empty when the
141+
// agent does not report it.
142+
Cwd string `json:"cwd,omitempty"`
135143
// AgentVersion is the agent runtime version reported in the hook payload
136144
// (e.g., Cursor's cursor_version). Captured at session-start so parsing
137145
// can set Agent.Version even when the transcript itself doesn't carry it.

‎app/cli/internal/trace/state/locate.go‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,28 +42,39 @@ import (
4242
// binding such a clone to an ancestor's out-of-tree state would silently
4343
// record its commits nowhere.
4444
func Locate() (store *Store, root string, err error) {
45-
gitDir, repoRoot, err := tracegit.FindGitDirAndRoot()
45+
cwd, err := os.Getwd()
46+
if err != nil {
47+
return nil, "", fmt.Errorf("get working directory: %w", err)
48+
}
49+
50+
return LocateFrom(cwd)
51+
}
52+
53+
// LocateFrom is Locate starting from dir instead of cwd. Agent hooks use it to
54+
// file an edit under the checkout that owns the edited file: they run from the
55+
// directory the session started in, which for a file in a linked git worktree
56+
// is a different checkout with its own state (PFM-7412).
57+
func LocateFrom(start string) (store *Store, root string, err error) {
58+
gitDir, repoRoot, err := tracegit.FindGitDirAndRootFrom(start)
4659
switch {
4760
case err == nil:
4861
return NewGitStore(gitDir), repoRoot, nil
4962
case !errors.Is(err, tracegit.ErrNotARepository):
5063
return nil, "", err
5164
}
5265

53-
cwd, err := os.Getwd()
54-
if err != nil {
55-
return nil, "", fmt.Errorf("get working directory: %w", err)
56-
}
57-
5866
base, err := nonGitBase()
5967
if err != nil {
6068
return nil, "", err
6169
}
6270

63-
for dir := resolveDir(cwd); ; {
71+
for dir := resolveDir(start); ; {
6472
candidate := NewOutOfTreeStore(filepath.Join(base, hashDir(dir)))
6573
if candidate.IsTraceRunActive() {
66-
return candidate, dir, nil
74+
// start may name a directory that does not exist yet (a file
75+
// about to be created), which resolveDir cannot canonicalize;
76+
// the ancestor holding the run does exist, so resolve it here.
77+
return candidate, resolveDir(dir), nil
6778
}
6879

6980
parent := filepath.Dir(dir)
@@ -73,7 +84,7 @@ func Locate() (store *Store, root string, err error) {
7384
dir = parent
7485
}
7586

76-
return nil, "", fmt.Errorf("not a git repository and no active chainloop trace run found from %q up to root", cwd)
87+
return nil, "", fmt.Errorf("not a git repository and no active chainloop trace run found from %q up to root", start)
7788
}
7889

7990
// NonGitDir returns the out-of-tree directory that parents trace state for a

‎app/cli/internal/trace/state/locate_test.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,40 @@ func TestLocate(t *testing.T) {
167167
assert.Error(t, err)
168168
})
169169
}
170+
171+
func TestLocateFrom(t *testing.T) {
172+
t.Run("inside a git repository ignores cwd", func(t *testing.T) {
173+
isolateCacheDir(t)
174+
175+
repo := t.TempDir()
176+
require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0755))
177+
other := t.TempDir()
178+
require.NoError(t, os.MkdirAll(filepath.Join(other, ".git"), 0755))
179+
t.Chdir(other)
180+
181+
store, root, err := LocateFrom(filepath.Join(repo, "sub"))
182+
require.NoError(t, err)
183+
assert.True(t, store.IsGit())
184+
assert.Equal(t, repo, root)
185+
assert.Equal(t, filepath.Join(repo, ".git"), store.GitDir())
186+
})
187+
188+
t.Run("no repository binds to the active run above dir", func(t *testing.T) {
189+
isolateCacheDir(t)
190+
191+
dir := t.TempDir()
192+
want, err := NonGitDir(dir)
193+
require.NoError(t, err)
194+
wantStore := NewOutOfTreeStore(want)
195+
require.NoError(t, wantStore.InitTraceDir())
196+
require.NoError(t, wantStore.MarkTraceRunActive())
197+
198+
t.Chdir(t.TempDir())
199+
200+
store, root, err := LocateFrom(filepath.Join(dir, "a", "b"))
201+
require.NoError(t, err)
202+
assert.False(t, store.IsGit())
203+
assert.Equal(t, want, store.Dir())
204+
assert.Equal(t, resolveDir(dir), root)
205+
})
206+
}

0 commit comments

Comments
 (0)