Skip to content

Commit 8dd3a0a

Browse files
committed
Match sessions across sibling worktrees
Entire-Checkpoint: c8a3d493b515
1 parent d9d2d03 commit 8dd3a0a

2 files changed

Lines changed: 330 additions & 3 deletions

File tree

cmd/entire/cli/strategy/manual_commit_session.go

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package strategy
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
610
"time"
711

812
"github.com/entireio/cli/cmd/entire/cli/agent/types"
@@ -140,13 +144,86 @@ func (s *ManualCommitStrategy) findSessionsForWorktree(ctx context.Context, work
140144
return nil, err
141145
}
142146

143-
var matching []*SessionState
147+
var exact []*SessionState
144148
for _, state := range allStates {
145149
if state.WorktreePath == worktreePath {
146-
matching = append(matching, state)
150+
exact = append(exact, state)
147151
}
148152
}
149-
return matching, nil
153+
if len(exact) > 0 {
154+
return exact, nil
155+
}
156+
157+
worktreeCommonDir := gitCommonDirForWorktreeOrEmpty(ctx, worktreePath)
158+
if worktreeCommonDir == "" {
159+
return nil, nil
160+
}
161+
162+
var parentWorktreeMatches []*SessionState
163+
var commonDirMatches []*SessionState
164+
for _, state := range allStates {
165+
if state.WorktreePath == "" {
166+
continue
167+
}
168+
169+
stateCommonDir, err := gitCommonDirForWorktree(ctx, state.WorktreePath)
170+
if err != nil || stateCommonDir != worktreeCommonDir {
171+
continue
172+
}
173+
174+
if isNestedWorktreeOfRecordedRepo(state.WorktreePath, worktreePath) {
175+
parentWorktreeMatches = append(parentWorktreeMatches, state)
176+
continue
177+
}
178+
179+
commonDirMatches = append(commonDirMatches, state)
180+
}
181+
182+
if len(parentWorktreeMatches) > 0 {
183+
return parentWorktreeMatches, nil
184+
}
185+
if len(commonDirMatches) == 1 {
186+
return commonDirMatches, nil
187+
}
188+
return nil, nil
189+
}
190+
191+
func gitCommonDirForWorktreeOrEmpty(ctx context.Context, worktreePath string) string {
192+
commonDir, err := gitCommonDirForWorktree(ctx, worktreePath)
193+
if err != nil {
194+
return ""
195+
}
196+
return commonDir
197+
}
198+
199+
func gitCommonDirForWorktree(ctx context.Context, worktreePath string) (string, error) {
200+
if worktreePath == "" {
201+
return "", errors.New("empty worktree path")
202+
}
203+
204+
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rev-parse", "--git-common-dir")
205+
output, err := cmd.Output()
206+
if err != nil {
207+
return "", fmt.Errorf("failed to get git common dir for %s: %w", worktreePath, err)
208+
}
209+
210+
commonDir := strings.TrimSpace(string(output))
211+
if commonDir == "" {
212+
return "", fmt.Errorf("empty git common dir for %s", worktreePath)
213+
}
214+
if !filepath.IsAbs(commonDir) {
215+
commonDir = filepath.Join(worktreePath, commonDir)
216+
}
217+
commonDir = filepath.Clean(commonDir)
218+
if resolved, err := filepath.EvalSymlinks(commonDir); err == nil {
219+
commonDir = resolved
220+
}
221+
return commonDir, nil
222+
}
223+
224+
func isNestedWorktreeOfRecordedRepo(recordedWorktreePath, commitWorktreePath string) bool {
225+
nestedWorktreesDir := filepath.Join(filepath.Clean(recordedWorktreePath), ".worktrees")
226+
return paths.IsSubpath(nestedWorktreesDir, filepath.Clean(commitWorktreePath))
150227
}
151228

152229
type rewritePair struct {
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package strategy
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
11+
"github.com/entireio/cli/cmd/entire/cli/paths"
12+
"github.com/entireio/cli/cmd/entire/cli/session"
13+
"github.com/entireio/cli/cmd/entire/cli/testutil"
14+
"github.com/entireio/cli/cmd/entire/cli/trailers"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestManualCommitStrategy_FindSessionsForWorktree_MatchesParentSessionFromNestedWorktree(t *testing.T) {
19+
testutil.IsolateGitConfigEnv(t)
20+
ctx := context.Background()
21+
mainDir := setupSessionMatchRepo(t)
22+
worktreeDir := filepath.Join(mainDir, ".worktrees", "feature")
23+
createSessionMatchWorktree(t, mainDir, worktreeDir, "feature")
24+
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, worktreeDir) })
25+
26+
s := &ManualCommitStrategy{}
27+
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{
28+
SessionID: "parent-session",
29+
WorktreePath: mainDir,
30+
})
31+
32+
t.Chdir(worktreeDir)
33+
clearSessionMatchCaches()
34+
35+
finder := &ManualCommitStrategy{}
36+
matching, err := finder.findSessionsForWorktree(ctx, worktreeDir)
37+
require.NoError(t, err)
38+
require.Len(t, matching, 1)
39+
require.Equal(t, "parent-session", matching[0].SessionID)
40+
}
41+
42+
func TestManualCommitStrategy_PrepareCommitMsg_AddsTrailerForParentSessionFromNestedWorktree(t *testing.T) {
43+
testutil.IsolateGitConfigEnv(t)
44+
ctx := context.Background()
45+
mainDir := setupSessionMatchRepo(t)
46+
worktreeDir := filepath.Join(mainDir, ".worktrees", "feature")
47+
createSessionMatchWorktree(t, mainDir, worktreeDir, "feature")
48+
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, worktreeDir) })
49+
50+
saver := &ManualCommitStrategy{}
51+
saveSessionMatchState(ctx, t, saver, mainDir, &SessionState{
52+
SessionID: "parent-session",
53+
WorktreePath: mainDir,
54+
FilesTouched: []string{"smoke.txt"},
55+
StepCount: 1,
56+
})
57+
58+
t.Chdir(worktreeDir)
59+
clearSessionMatchCaches()
60+
61+
commitMsgFile := filepath.Join(worktreeDir, "COMMIT_EDITMSG")
62+
require.NoError(t, os.WriteFile(commitMsgFile, []byte("smoke commit\n"), 0o600))
63+
64+
hook := &ManualCommitStrategy{}
65+
require.NoError(t, hook.PrepareCommitMsg(ctx, commitMsgFile, "message"))
66+
67+
content, err := os.ReadFile(commitMsgFile)
68+
require.NoError(t, err)
69+
cpID, found := trailers.ParseCheckpoint(string(content))
70+
require.True(t, found, "prepare-commit-msg should add a checkpoint trailer from the parent-recorded session")
71+
require.False(t, cpID.IsEmpty())
72+
}
73+
74+
func TestManualCommitStrategy_FindSessionsForWorktree_MatchesUniqueSiblingByCommonDir(t *testing.T) {
75+
testutil.IsolateGitConfigEnv(t)
76+
ctx := context.Background()
77+
mainDir := setupSessionMatchRepo(t)
78+
recordedWorktree := resolvedRemovedTempDir(t)
79+
commitWorktree := resolvedRemovedTempDir(t)
80+
createSessionMatchWorktree(t, mainDir, recordedWorktree, "recorded")
81+
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, recordedWorktree) })
82+
createSessionMatchWorktree(t, mainDir, commitWorktree, "commit")
83+
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, commitWorktree) })
84+
85+
s := &ManualCommitStrategy{}
86+
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{
87+
SessionID: "unique-sibling-session",
88+
WorktreePath: recordedWorktree,
89+
})
90+
91+
t.Chdir(commitWorktree)
92+
clearSessionMatchCaches()
93+
94+
finder := &ManualCommitStrategy{}
95+
matching, err := finder.findSessionsForWorktree(ctx, commitWorktree)
96+
require.NoError(t, err)
97+
require.Len(t, matching, 1)
98+
require.Equal(t, "unique-sibling-session", matching[0].SessionID)
99+
}
100+
101+
func TestManualCommitStrategy_FindSessionsForWorktree_ExactMatchWinsOverSiblingFallback(t *testing.T) {
102+
testutil.IsolateGitConfigEnv(t)
103+
ctx := context.Background()
104+
mainDir := setupSessionMatchRepo(t)
105+
worktreeDir := filepath.Join(mainDir, ".worktrees", "feature")
106+
createSessionMatchWorktree(t, mainDir, worktreeDir, "feature")
107+
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, worktreeDir) })
108+
109+
s := &ManualCommitStrategy{}
110+
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{
111+
SessionID: "parent-session",
112+
WorktreePath: mainDir,
113+
})
114+
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{
115+
SessionID: "exact-session",
116+
WorktreePath: worktreeDir,
117+
})
118+
119+
t.Chdir(worktreeDir)
120+
clearSessionMatchCaches()
121+
122+
finder := &ManualCommitStrategy{}
123+
matching, err := finder.findSessionsForWorktree(ctx, worktreeDir)
124+
require.NoError(t, err)
125+
require.Len(t, matching, 1)
126+
require.Equal(t, "exact-session", matching[0].SessionID)
127+
}
128+
129+
func TestManualCommitStrategy_FindSessionsForWorktree_DoesNotMatchUnrelatedRepo(t *testing.T) {
130+
testutil.IsolateGitConfigEnv(t)
131+
ctx := context.Background()
132+
mainDir := setupSessionMatchRepo(t)
133+
otherDir := setupSessionMatchRepo(t)
134+
135+
s := &ManualCommitStrategy{}
136+
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{
137+
SessionID: "unrelated-session",
138+
WorktreePath: otherDir,
139+
})
140+
141+
t.Chdir(mainDir)
142+
clearSessionMatchCaches()
143+
144+
finder := &ManualCommitStrategy{}
145+
matching, err := finder.findSessionsForWorktree(ctx, mainDir)
146+
require.NoError(t, err)
147+
require.Empty(t, matching)
148+
}
149+
150+
func TestManualCommitStrategy_FindSessionsForWorktree_DoesNotGuessAmbiguousSiblingSessions(t *testing.T) {
151+
testutil.IsolateGitConfigEnv(t)
152+
ctx := context.Background()
153+
mainDir := setupSessionMatchRepo(t)
154+
firstWorktree := resolvedRemovedTempDir(t)
155+
secondWorktree := resolvedRemovedTempDir(t)
156+
commitWorktree := resolvedRemovedTempDir(t)
157+
createSessionMatchWorktree(t, mainDir, firstWorktree, "first")
158+
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, firstWorktree) })
159+
createSessionMatchWorktree(t, mainDir, secondWorktree, "second")
160+
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, secondWorktree) })
161+
createSessionMatchWorktree(t, mainDir, commitWorktree, "commit")
162+
t.Cleanup(func() { removeSessionMatchWorktree(mainDir, commitWorktree) })
163+
164+
s := &ManualCommitStrategy{}
165+
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{
166+
SessionID: "first-session",
167+
WorktreePath: firstWorktree,
168+
})
169+
saveSessionMatchState(ctx, t, s, mainDir, &SessionState{
170+
SessionID: "second-session",
171+
WorktreePath: secondWorktree,
172+
})
173+
174+
t.Chdir(commitWorktree)
175+
clearSessionMatchCaches()
176+
177+
finder := &ManualCommitStrategy{}
178+
matching, err := finder.findSessionsForWorktree(ctx, commitWorktree)
179+
require.NoError(t, err)
180+
require.Empty(t, matching)
181+
}
182+
183+
func setupSessionMatchRepo(t *testing.T) string {
184+
t.Helper()
185+
186+
dir := resolvedTempDir(t)
187+
testutil.InitRepo(t, dir)
188+
testutil.WriteFile(t, dir, "README.md", "test\n")
189+
testutil.GitAdd(t, dir, "README.md")
190+
testutil.GitCommit(t, dir, "initial")
191+
return dir
192+
}
193+
194+
func saveSessionMatchState(ctx context.Context, t *testing.T, s *ManualCommitStrategy, repoDir string, state *SessionState) {
195+
t.Helper()
196+
197+
t.Chdir(repoDir)
198+
clearSessionMatchCaches()
199+
200+
now := time.Now()
201+
state.StartedAt = now
202+
state.Phase = session.PhaseActive
203+
state.BaseCommit = testutil.GetHeadHash(t, repoDir)
204+
if state.WorktreeID == "" && state.WorktreePath != "" {
205+
worktreeID, err := paths.GetWorktreeID(state.WorktreePath)
206+
require.NoError(t, err)
207+
state.WorktreeID = worktreeID
208+
}
209+
require.NoError(t, s.saveSessionState(ctx, state))
210+
}
211+
212+
func createSessionMatchWorktree(t *testing.T, repoDir, worktreeDir, branch string) {
213+
t.Helper()
214+
215+
require.NoError(t, os.MkdirAll(filepath.Dir(worktreeDir), 0o755))
216+
cmd := exec.CommandContext(context.Background(), "git", "worktree", "add", worktreeDir, "-b", branch)
217+
cmd.Dir = repoDir
218+
cmd.Env = testutil.GitIsolatedEnv()
219+
output, err := cmd.CombinedOutput()
220+
require.NoError(t, err, "git worktree add output:\n%s", output)
221+
}
222+
223+
func removeSessionMatchWorktree(repoDir, worktreeDir string) {
224+
cmd := exec.CommandContext(context.Background(), "git", "worktree", "remove", worktreeDir, "--force")
225+
cmd.Dir = repoDir
226+
cmd.Env = testutil.GitIsolatedEnv()
227+
_ = cmd.Run() //nolint:errcheck // best-effort test cleanup
228+
}
229+
230+
func resolvedRemovedTempDir(t *testing.T) string {
231+
t.Helper()
232+
233+
dir := resolvedTempDir(t)
234+
require.NoError(t, os.Remove(dir))
235+
return dir
236+
}
237+
238+
func resolvedTempDir(t *testing.T) string {
239+
t.Helper()
240+
241+
dir := t.TempDir()
242+
resolved, err := filepath.EvalSymlinks(dir)
243+
require.NoError(t, err)
244+
return resolved
245+
}
246+
247+
func clearSessionMatchCaches() {
248+
paths.ClearWorktreeRootCache()
249+
session.ClearGitCommonDirCache()
250+
}

0 commit comments

Comments
 (0)