fix: guard adopt against shared session stores · Entire
fix: guard adopt against shared session stores
86cb382·
peyton-alt·3w ago·2 files·+93 added/-11 removed
Sessions
c555c03f3864View transcript
Changes
2
cmd/entire/cli
Msession_adopt.go+18/-11
- Msession_adopt_test.go+75
60 unmodified lines
61
62
63
64
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
4 unmodified lines
84
85
86
79
80
81
82
87
88
89
15 unmodified lines
105
106
107
104
108
109
110
107
111
112
113
114
1 unmodified line
116
117
118
115
119
120
117
121
122
123
124
125
122
126
127
128
129
1 unmodified line
131
132
133
134
135
136
137
131
138
139
140
141
60 unmodified lines
return errors.New("source worktree is required; pass --from <path>")
}
sourceStore, sourceWorktree, err := stateStoreForWorktree(ctx, opts.FromWorktree)
sourceStore, sourceWorktree, sourceCommonDir, err := stateStoreForWorktree(ctx, opts.FromWorktree)
if err != nil {
return err
}
targetStore, _, targetCommonDir, err := stateStoreForWorktree(ctx, ".")
if err != nil {
return fmt.Errorf("open current session store: %w", err)
}
if sourceCommonDir == targetCommonDir {
return errors.New("source and target share the same git common dir; session adopt only moves sessions across independent git session stores")
}
sourceState, err := selectAdoptSourceSession(ctx, sourceStore, sourceWorktree, sessionID)
if err != nil {
return err
}
4 unmodified lines
return err
}
targetStore, err := session.NewStateStore(ctx)
if err != nil {
return fmt.Errorf("open current session store: %w", err)
}
existing, err := targetStore.Load(ctx, adopted.SessionID)
if err != nil {
return fmt.Errorf("load current session state: %w", err)
}
15 unmodified lines
return nil
}
func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, error) {
func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, string, error) {
absWorktree, err := filepath.Abs(worktreePath)
if err != nil {
return nil, "", fmt.Errorf("resolve source worktree: %w", err)
return nil, "", "", fmt.Errorf("resolve source worktree: %w", err)
}
cmd := exec.CommandContext(ctx, "git", "-C", absWorktree, "rev-parse", "--show-toplevel", "--git-common-dir")
1 unmodified line
if err != nil {
msg := strings.TrimSpace(string(output))
if msg != "" {
return nil, "", fmt.Errorf("resolve source git directory: %s: %w", msg, err)
return nil, "", "", fmt.Errorf("resolve source git directory: %s: %w", msg, err)
}
return nil, "", fmt.Errorf("resolve source git directory: %w", err)
return nil, "", "", fmt.Errorf("resolve source git directory: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) < 2 {
return nil, "", fmt.Errorf("resolve source git directory: unexpected git output %q", strings.TrimSpace(string(output)))
return nil, "", "", fmt.Errorf("resolve source git directory: unexpected git output %q", strings.TrimSpace(string(output)))
}
sourceRoot := strings.TrimSpace(lines[0])
commonDir := strings.TrimSpace(lines[1])
1 unmodified line
commonDir = filepath.Join(absWorktree, commonDir)
}
commonDir = filepath.Clean(commonDir)
if resolved, err := filepath.EvalSymlinks(commonDir); err == nil {
commonDir = resolved
}
return session.NewStateStoreWithDir(filepath.Join(commonDir, session.SessionStateDirName)), sourceRoot, nil
return session.NewStateStoreWithDir(filepath.Join(commonDir, session.SessionStateDirName)), sourceRoot, commonDir, nil
}
func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, sourceWorktree, sessionID string) (*session.State, error) {
Mcmd/entire/cli/session_adopt.go+18/-11
3 unmodified lines
4
5
6
7
8
9
10
305 unmodified lines
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
9 unmodified lines
394
395
396
397
398
399
400
401
402
403
404
405
406
407
3 unmodified lines
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
305 unmodified lines
}
func TestSessionAdopt_RejectsSameGitCommonDir(t *testing.T) {
sourceRepo := setupAdoptRepo(t)
targetWorktree := filepath.Join(t.TempDir(), "target-worktree")
runAdoptGit(t, sourceRepo, "worktree", "add", targetWorktree, "-b", "target-worktree")
t.Cleanup(func() {
runAdoptGit(t, sourceRepo, "worktree", "remove", targetWorktree, "--force")
})
sessionID := "test-adopt-same-common-dir"
lastInteraction := time.Now().Add(-1 * time.Minute)
sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName))
if err := sourceStore.Save(context.Background(), &session.State{
SessionID: sessionID,
AgentType: agent.AgentTypeClaudeCode,
StartedAt: time.Now().Add(-5 * time.Minute),
LastInteractionTime: &lastInteraction,
Phase: session.PhaseActive,
BaseCommit: testutil.GetHeadHash(t, sourceRepo),
WorktreePath: sourceRepo,
StepCount: 4,
CheckpointTranscriptStart: 2,
LastCheckpointID: id.MustCheckpointID("abc123def456"),
LastCheckpointCommitHash: "source-commit",
}); err != nil {
t.Fatal(err)
}
testutil.WriteFile(t, targetWorktree, "feature.txt", "agent change\n")
t.Chdir(targetWorktree)
var out bytes.Buffer
err := runAdopt(context.Background(), &out, sessionID, adoptOptions{
FromWorktree: sourceRepo,
Force: true,
})
if err == nil {
t.Fatal("runAdopt succeeded, want same-common-dir refusal")
}
if !strings.Contains(err.Error(), "same git common dir") {
t.Fatalf("runAdopt error = %v, want same git common dir refusal", err)
}
loaded, err := sourceStore.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if loaded == nil {
t.Fatal("expected source session state to remain")
}
if loaded.StepCount != 4 {
t.Fatalf("StepCount = %d, want source state preserved at 4", loaded.StepCount)
}
if loaded.CheckpointTranscriptStart != 2 {
t.Fatalf("CheckpointTranscriptStart = %d, want source state preserved at 2", loaded.CheckpointTranscriptStart)
}
if loaded.LastCheckpointID.String() != "abc123def456" {
t.Fatalf("LastCheckpointID = %s, want source checkpoint preserved", loaded.LastCheckpointID.String())
}
if loaded.LastCheckpointCommitHash != "source-commit" {
t.Fatalf("LastCheckpointCommitHash = %q, want source commit preserved", loaded.LastCheckpointCommitHash)
}
}
func setupAdoptRepo(t *testing.T) string {
t.Helper()
9 unmodified lines
}
return realRepoDir
}
func runAdoptGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.CommandContext(context.Background(), "git", args...)
cmd.Dir = dir
cmd.Env = testutil.GitIsolatedEnv()
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output)
}
}