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

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)
    }
}