fix: support shared-store session adoption · Entire

fix: support shared-store session adoption

2bed72c·

peyton-alt·3w ago·4 files·+203 added/-35 removed

Sessions

48521e055655View transcript

Changes

4

23 unmodified lines

24
25
26
27
27
28
29
29
30
31
32
22 unmodified lines

55
56
57
58
59
60
58
59
60
61

23 unmodified lines

// temporary capability and resolved committed-ref topology.  
type Stores struct {  
    // Primary is the committed store that serves committed reads and writes.  
    Primary CommittedStore  
    Primary *GitStore

temporary TemporaryStore  
    temporary *GitStore  
    refs      CommittedRefs  
}

22 unmodified lines

}

// Temporary returns the git-backed temporary shadow-branch store.  
//nolint:ireturn // temporary store capability is the abstraction boundary  
func (s *Stores) Temporary() TemporaryStore { return s.temporary }  
func (s *Stores) Temporary() *GitStore { return s.temporary }

// Refs returns the resolved committed-ref topology.  
func (s *Stores) Refs() CommittedRefs { return s.refs }

Mcmd/entire/cli/checkpoint/open.go+3/-5

67 unmodified lines

68
69
70
71
71
72
73
74
75
76
75
76
77
78
79
80
13 unmodified lines

94
95
96
96
97
98
99
100
10 unmodified lines

111
112
113
114
115
116
117
118
119
120
121
122
123
47 unmodified lines

171
172
173
174
175
176
177
178
179
180
5 unmodified lines

186
187
188
189
190
191
192
193
194
195
3 unmodified lines

199
200
201
186
202
203
204
205
16 unmodified lines

222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
95 unmodified lines

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

67 unmodified lines

return err  
}

targetStore, _, targetCommonDir, err := stateStoreForWorktree(ctx, ".")  
targetStore, targetWorktree, 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")  
sameSessionStore := sourceCommonDir == targetCommonDir  
if sameSessionStore && sameAdoptPath(sourceWorktree, targetWorktree) {  
    return errors.New("source and target are the same worktree; no session adoption is needed")  
}

sourceState, err := selectAdoptSourceSession(ctx, sourceStore, sourceWorktree, sessionID)  
13 unmodified lines

if err != nil {  
    return fmt.Errorf("load current session state: %w", err)  
}  
if existing != nil && !opts.Force {  
    if existing != nil && !opts.Force && !canReplaceAdoptState(existing, sourceState, sameSessionStore) {  
        return fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID)  
    }  
    if err := targetStore.Save(ctx, adopted); err != nil {  
        10 unmodified lines

return nil  
}

func canReplaceAdoptState(existing, source *session.State, sameSessionStore bool) bool {  
    return sameSessionStore &&  
        existing != nil &&  
        source != nil &&  
        existing.SessionID == source.SessionID  
}

func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) error {  
    if source == nil || strings.TrimSpace(source.TranscriptPath) == "" {  
        return nil  
}

}

func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, sourceWorktree, sessionID string) (*session.State, error) {  
    sourceWorktreeID, worktreeIDErr := paths.GetWorktreeID(sourceWorktree)  
    if worktreeIDErr != nil {  
        sourceWorktreeID = ""  
    }
    if sessionID != "" {  
        sourceState, err := store.Load(ctx, sessionID)  
        if err != nil {

if !isAdoptableSourceSession(sourceState) {  
            return nil, fmt.Errorf("session %s is ended or fully condensed and cannot be adopted", sessionID)  
        }  
        if !sessionBelongsToSourceWorktree(sourceState, sourceWorktree, sourceWorktreeID) {  
            return nil, fmt.Errorf("session %s belongs to %s, not %s",  
                sessionID, adoptSessionWorktreeLabel(sourceState), sourceWorktree)  
        }  
        return sourceState, nil  
    }

3 unmodified lines

}
    candidates := make([]*session.State, 0, len(states))  
    for _, state := range states {  
        if isRecentAdoptCandidate(state) {  
            if isRecentAdoptCandidate(state) && sessionBelongsToSourceWorktree(state, sourceWorktree, sourceWorktreeID) {  
                candidates = append(candidates, state)  
            }  
        }  
    }
}

func sessionBelongsToSourceWorktree(state *session.State, sourceWorktree, sourceWorktreeID string) bool {  
    if state == nil {  
        return false  
    }  
    if state.WorktreeID != "" && sourceWorktreeID != "" {  
        return state.WorktreeID == sourceWorktreeID  
    }  
    if state.WorktreePath != "" {  
        return sameAdoptPath(state.WorktreePath, sourceWorktree)  
    }  
    return true  
}

func adoptSessionWorktreeLabel(state *session.State) string {  
    if state == nil {  
        return unknownPlaceholder  
    }  
    if state.WorktreePath != "" {  
        return state.WorktreePath  
    }  
    if state.WorktreeID != "" {  
        return state.WorktreeID  
    }  
    return unknownPlaceholder  
}

func isRecentAdoptCandidate(state *session.State) bool {  
    if !isAdoptableSourceSession(state) {  
        return false  
95 unmodified lines

return &adopted, filesTouched, nil  
}

func sameAdoptPath(a, b string) bool {  
    return canonicalAdoptPath(a) == canonicalAdoptPath(b)  
}

func canonicalAdoptPath(path string) string {  
    if path == "" {  
        return ""  
    }  
    abs, err := filepath.Abs(path)  
    if err == nil {  
        path = abs  
    }  
    path = filepath.Clean(path)  
    if resolved, err := filepath.EvalSymlinks(path); err == nil {  
        path = resolved  
    }  
    return path  
}

func currentFilesTouched(ctx context.Context) ([]string, error) {  
    changes, err := DetectFileChanges(ctx, nil)  
    if err != nil {  
    

Mcmd/entire/cli/session_adopt.go+66/-5

13 unmodified lines

14
15
16
17
18
19
20
540 unmodified lines

561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
27 unmodified lines

673
674
675
596
676
677
678
679
680
681
682
683
684
685
686
687
688
604
689
690
691
692
693
694
695
696
697
698
699
700
701
4 unmodified lines

706
707
708
709
710
711
712
3 unmodified lines

716
717
718
719
720
721
722
627
723
724
629
725
631
632
633
634
635
726
727
728
729
730
731
732
733
642
643
734
735
736
645
646
737
738
739
648
649
740
741
742
651
652
743
744
745
654
655
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771

13 unmodified lines

"github.com/entireio/cli/cmd/entire/cli/agent"  
    "github.com/entireio/cli/cmd/entire/cli/checkpoint/id"  
    "github.com/entireio/cli/cmd/entire/cli/paths"  
    "github.com/entireio/cli/cmd/entire/cli/session"  
    "github.com/entireio/cli/cmd/entire/cli/strategy"  
    "github.com/entireio/cli/cmd/entire/cli/testutil"  
540 unmodified lines

}
}

func TestSessionAdopt_FiltersSharedSourceStoreByFromWorktree(t *testing.T) {  
    sourceRepo := setupAdoptRepo(t)  
    siblingWorktree := filepath.Join(t.TempDir(), "sibling-worktree")  
    runAdoptGit(t, sourceRepo, "worktree", "add", siblingWorktree, "-b", "sibling-worktree")  
    resolvedSiblingWorktree, err := filepath.EvalSymlinks(siblingWorktree)  
    if err != nil {  
        t.Fatal(err)  
    }
    siblingWorktree = resolvedSiblingWorktree  
    t.Cleanup(func() {  
        runAdoptGit(t, sourceRepo, "worktree", "remove", siblingWorktree, "--force")  
    })  
targetRepo := setupAdoptRepo(t)

sourceWorktreeID, err := paths.GetWorktreeID(sourceRepo)  
    if err != nil {  
        t.Fatal(err)  
    }
    siblingWorktreeID, err := paths.GetWorktreeID(siblingWorktree)  
    if err != nil {  
        t.Fatal(err)  
    }

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:           "source-worktree-session",  
        AgentType:           agent.AgentTypeClaudeCode,  
        StartedAt:           time.Now().Add(-5 * time.Minute),  
        LastInteractionTime: &lastInteraction,  
        Phase:               session.PhaseActive,  
        BaseCommit:          testutil.GetHeadHash(t, sourceRepo),  
        WorktreePath:        sourceRepo,  
        WorktreeID:          sourceWorktreeID,  
    }); err != nil {  
        t.Fatal(err)  
    }
    if err := sourceStore.Save(context.Background(), &session.State{  
        SessionID:           "sibling-worktree-session",  
        AgentType:           agent.AgentTypeClaudeCode,  
        StartedAt:           time.Now().Add(-5 * time.Minute),  
        LastInteractionTime: &lastInteraction,  
        Phase:               session.PhaseActive,  
        BaseCommit:          testutil.GetHeadHash(t, siblingWorktree),  
        WorktreePath:        siblingWorktree,  
        WorktreeID:          siblingWorktreeID,  
    }); err != nil {  
        t.Fatal(err)  
    }

testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n")  
    t.Chdir(targetRepo)

var out bytes.Buffer  
    err = runAdopt(context.Background(), &out, "", adoptOptions{  
        FromWorktree: sourceRepo,  
    })  
    if err != nil {  
        t.Fatalf("runAdopt failed: %v", err)  
    }

targetStore, err := session.NewStateStore(context.Background())  
    if err != nil {  
        t.Fatal(err)  
    }
    adopted, err := targetStore.Load(context.Background(), "source-worktree-session")  
    if err != nil {  
        t.Fatal(err)  
    }
    if adopted == nil {  
        t.Fatal("expected source worktree session to be adopted")  
    }
    if wrong, err := targetStore.Load(context.Background(), "sibling-worktree-session"); err != nil {  
        t.Fatal(err)  
    } else if wrong != nil {  
        t.Fatalf("adopted sibling worktree session unexpectedly: %#v", wrong)  
    }
}

func TestStateStoreForWorktreeIgnoresGitStderrOnSuccess(t *testing.T) {  
    if runtime.GOOS == "windows" {  
        t.Skip("uses a POSIX shell script fake git")  
    }
}

func TestSessionAdopt_RejectsSameGitCommonDir(t *testing.T) {
func TestSessionAdopt_MovesSameStoreSessionIntoCurrentWorktree(t *testing.T) {  
    sourceRepo := setupAdoptRepo(t)  
    targetWorktree := filepath.Join(t.TempDir(), "target-worktree")  
    runAdoptGit(t, sourceRepo, "worktree", "add", targetWorktree, "-b", "target-worktree")  
    resolvedTargetWorktree, err := filepath.EvalSymlinks(targetWorktree)  
    if err != nil {  
        t.Fatal(err)  
    }
    targetWorktree = resolvedTargetWorktree  
    t.Cleanup(func() {  
        runAdoptGit(t, sourceRepo, "worktree", "remove", targetWorktree, "--force")  
    })

sessionID := "test-adopt-same-common-dir"  
    sourceWorktreeID, err := paths.GetWorktreeID(sourceRepo)  
    if err != nil {  
        t.Fatal(err)  
    }
    targetWorktreeID, err := paths.GetWorktreeID(targetWorktree)  
    if err != nil {  
        t.Fatal(err)  
    }

sessionID := "test-adopt-same-store"  
    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,  
        WorktreeID:          sourceWorktreeID,  
        StepCount:           4,  
        CheckpointTranscriptStart: 2,  
        LastCheckpointID:    id.MustCheckpointID("abc123def456"),  
    }); err != nil {  
        t.Fatal(err)  
    }

testutil.WriteFile(t, targetWorktree, "feature.txt", "agent change\n")  
    testutil.GitAdd(t, targetWorktree, "feature.txt")  
    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)  
    }
    if err != nil {  
        t.Fatalf("runAdopt failed: %v", 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.WorktreePath != targetWorktree {  
        t.Fatalf("WorktreePath = %q, want %q", loaded.WorktreePath, targetWorktree)  
    }
    if loaded.StepCount != 4 {  
        t.Fatalf("StepCount = %d, want source state preserved at 4", loaded.StepCount)  
    }
    if loaded.WorktreeID != targetWorktreeID {  
        t.Fatalf("WorktreeID = %q, want %q", loaded.WorktreeID, targetWorktreeID)  
    }
    if loaded.CheckpointTranscriptStart != 2 {  
        t.Fatalf("CheckpointTranscriptStart = %d, want source state preserved at 2", loaded.CheckpointTranscriptStart)  
    }
    if loaded.BaseCommit != testutil.GetHeadHash(t, targetWorktree) {  
        t.Fatalf("BaseCommit = %q, want target HEAD", loaded.BaseCommit)  
    }
    if loaded.LastCheckpointID.String() != "abc123def456" {  
        t.Fatalf("LastCheckpointID = %s, want source checkpoint preserved", loaded.LastCheckpointID.String())  
    }
    if loaded.StepCount != 0 {  
        t.Fatalf("StepCount = %d, want reset target-local checkpoint state", loaded.StepCount)  
    }
    if loaded.LastCheckpointCommitHash != "source-commit" {  
        t.Fatalf("LastCheckpointCommitHash = %q, want source commit preserved", loaded.LastCheckpointCommitHash)  
    }
    if loaded.CheckpointTranscriptStart != 0 {  
        t.Fatalf("CheckpointTranscriptStart = %d, want reset target-local transcript window", loaded.CheckpointTranscriptStart)  
    }
    if !loaded.LastCheckpointID.IsEmpty() {  
        t.Fatalf("LastCheckpointID = %s, want empty target-local checkpoint ID", loaded.LastCheckpointID.String())  
    }
    if loaded.LastCheckpointCommitHash != "" {  
        t.Fatalf("LastCheckpointCommitHash = %q, want empty target-local commit hash", loaded.LastCheckpointCommitHash)  
    }

commitMsgFile := filepath.Join(targetWorktree, "COMMIT_EDITMSG")  
    if err := os.WriteFile(commitMsgFile, []byte("add same-store feature\n"), 0o600); err != nil {  
        t.Fatal(err)  
    }
    if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil {  
        t.Fatalf("PrepareCommitMsg failed: %v", err)  
    }
    content, err := os.ReadFile(commitMsgFile)  
    if err != nil {  
        t.Fatal(err)  
    }  
    if !strings.Contains(string(content), "Entire-Checkpoint:") {  
        t.Fatalf("commit message = %q, want Entire-Checkpoint trailer", string(content))  
    }
}