Skip to content

Commit 289fec4

Browse files
committed
fix(cli): record AI sessions for edits in git worktrees (PFM-7412)
An agent runs its hooks from the directory where the session started. When the agent edited a file in a linked git worktree, trace recorded the edit in the state of the main checkout. The git hooks of the worktree read only the state of the worktree. So the commits in the worktree did not get the session, and pre-push sent no attestation. Record each file edit in the state of the checkout that owns the file. Save the working directory of the agent in the session record. Use it to find the session transcript from any worktree. Assisted-by: Claude Code Signed-off-by: Jose I. Paris <jiparis@chainloop.dev> Chainloop-Trace-Sessions: 1a5dee29-1a25-49e8-8e20-17ebb61dde56
1 parent 85af496 commit 289fec4

14 files changed

Lines changed: 392 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)