diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 60aa4a243..108b8dd2a 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -894,65 +894,37 @@ func (s *GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error) { var checkpoints []CommittedInfo // Scan sharded structure: <2-char-prefix>//metadata.json - for _, bucketEntry := range tree.Entries { - if bucketEntry.Mode != filemode.Dir { - continue - } - // Bucket should be 2 hex chars - if len(bucketEntry.Name) != 2 { - continue + _ = WalkCheckpointShards(s.repo, tree, func(checkpointID id.CheckpointID, cpTreeHash plumbing.Hash) error { //nolint:errcheck // callback never returns errors + checkpointTree, cpTreeErr := s.repo.TreeObject(cpTreeHash) + if cpTreeErr != nil { + return nil //nolint:nilerr // skip unreadable entries, continue walking } - bucketTree, treeErr := s.repo.TreeObject(bucketEntry.Hash) - if treeErr != nil { - continue + info := CommittedInfo{ + CheckpointID: checkpointID, } - // Each entry in the bucket is the remaining part of the checkpoint ID - for _, checkpointEntry := range bucketTree.Entries { - if checkpointEntry.Mode != filemode.Dir { - continue - } - - checkpointTree, cpTreeErr := s.repo.TreeObject(checkpointEntry.Hash) - if cpTreeErr != nil { - continue - } - - // Reconstruct checkpoint ID: - checkpointIDStr := bucketEntry.Name + checkpointEntry.Name - checkpointID, cpIDErr := id.NewCheckpointID(checkpointIDStr) - if cpIDErr != nil { - // Skip invalid checkpoint IDs (shouldn't happen with our own data) - continue - } - - info := CommittedInfo{ - CheckpointID: checkpointID, - } - - // Get details from root metadata file (CheckpointSummary format) - if metadataFile, fileErr := checkpointTree.File(paths.MetadataFileName); fileErr == nil { - if content, contentErr := metadataFile.Contents(); contentErr == nil { - var summary CheckpointSummary - if err := json.Unmarshal([]byte(content), &summary); err == nil { - info.CheckpointsCount = summary.CheckpointsCount - info.FilesTouched = summary.FilesTouched - info.SessionCount = len(summary.Sessions) - - // Read session metadata from latest session to get Agent, SessionID, CreatedAt - if len(summary.Sessions) > 0 { - latestIndex := len(summary.Sessions) - 1 - latestDir := strconv.Itoa(latestIndex) - if sessionTree, treeErr := checkpointTree.Tree(latestDir); treeErr == nil { - if sessionMetadataFile, smErr := sessionTree.File(paths.MetadataFileName); smErr == nil { - if sessionContent, scErr := sessionMetadataFile.Contents(); scErr == nil { - var sessionMetadata CommittedMetadata - if json.Unmarshal([]byte(sessionContent), &sessionMetadata) == nil { - info.Agent = sessionMetadata.Agent - info.SessionID = sessionMetadata.SessionID - info.CreatedAt = sessionMetadata.CreatedAt - } + // Get details from root metadata file (CheckpointSummary format) + if metadataFile, fileErr := checkpointTree.File(paths.MetadataFileName); fileErr == nil { + if content, contentErr := metadataFile.Contents(); contentErr == nil { + var summary CheckpointSummary + if err := json.Unmarshal([]byte(content), &summary); err == nil { + info.CheckpointsCount = summary.CheckpointsCount + info.FilesTouched = summary.FilesTouched + info.SessionCount = len(summary.Sessions) + + // Read session metadata from latest session to get Agent, SessionID, CreatedAt + if len(summary.Sessions) > 0 { + latestIndex := len(summary.Sessions) - 1 + latestDir := strconv.Itoa(latestIndex) + if sessionTree, treeErr := checkpointTree.Tree(latestDir); treeErr == nil { + if sessionMetadataFile, smErr := sessionTree.File(paths.MetadataFileName); smErr == nil { + if sessionContent, scErr := sessionMetadataFile.Contents(); scErr == nil { + var sessionMetadata CommittedMetadata + if json.Unmarshal([]byte(sessionContent), &sessionMetadata) == nil { + info.Agent = sessionMetadata.Agent + info.SessionID = sessionMetadata.SessionID + info.CreatedAt = sessionMetadata.CreatedAt } } } @@ -960,10 +932,11 @@ func (s *GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error) { } } } - - checkpoints = append(checkpoints, info) } - } + + checkpoints = append(checkpoints, info) + return nil + }) // Sort by time (most recent first) sort.Slice(checkpoints, func(i, j int) bool { diff --git a/cmd/entire/cli/checkpoint/parse_tree.go b/cmd/entire/cli/checkpoint/parse_tree.go index 9e3d7fcad..63f97a0a4 100644 --- a/cmd/entire/cli/checkpoint/parse_tree.go +++ b/cmd/entire/cli/checkpoint/parse_tree.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v6" @@ -282,6 +283,42 @@ func ApplyTreeChanges( return storeTree(repo, result) } +// WalkCheckpointShards iterates over the two-level shard structure (//) +// in a checkpoint tree, calling fn for each checkpoint found. Skips non-directory entries +// at both levels (e.g., generation.json at the root). The callback receives the parsed +// checkpoint ID and the tree hash of the checkpoint subtree. +func WalkCheckpointShards(repo *git.Repository, tree *object.Tree, fn func(cpID id.CheckpointID, cpTreeHash plumbing.Hash) error) error { + for _, bucketEntry := range tree.Entries { + if bucketEntry.Mode != filemode.Dir { + continue + } + if len(bucketEntry.Name) != 2 { + continue + } + + bucketTree, err := repo.TreeObject(bucketEntry.Hash) + if err != nil { + continue + } + + for _, cpEntry := range bucketTree.Entries { + if cpEntry.Mode != filemode.Dir { + continue + } + + cpID, err := id.NewCheckpointID(bucketEntry.Name + cpEntry.Name) + if err != nil { + continue + } + + if err := fn(cpID, cpEntry.Hash); err != nil { + return err + } + } + } + return nil +} + // splitFirstSegment splits "a/b/c" into ("a", "b/c"), and "file.txt" into ("file.txt", ""). func splitFirstSegment(path string) (first, rest string) { parts := strings.SplitN(path, "/", 2) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index b3df705e7..015a887c8 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -79,7 +79,7 @@ func (s *V2GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOp // Returns the session index for coordination with /full/current. func (s *V2GitStore) updateCommittedMain(ctx context.Context, opts UpdateCommittedOptions) (int, error) { refName := plumbing.ReferenceName(paths.V2MainRefName) - parentHash, rootTreeHash, err := s.getRefState(refName) + parentHash, rootTreeHash, err := s.GetRefState(refName) if err != nil { return 0, ErrCheckpointNotFound } @@ -192,7 +192,7 @@ func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts Upd return fmt.Errorf("failed to ensure /full/current ref: %w", err) } - parentHash, rootTreeHash, err := s.getRefState(refName) + parentHash, rootTreeHash, err := s.GetRefState(refName) if err != nil { return err } @@ -244,7 +244,7 @@ func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommitted return 0, fmt.Errorf("failed to ensure /main ref: %w", err) } - parentHash, rootTreeHash, err := s.getRefState(refName) + parentHash, rootTreeHash, err := s.GetRefState(refName) if err != nil { return 0, err } @@ -463,7 +463,7 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ return fmt.Errorf("failed to ensure /full/current ref: %w", err) } - parentHash, rootTreeHash, err := s.getRefState(refName) + parentHash, rootTreeHash, err := s.GetRefState(refName) if err != nil { return err } @@ -500,29 +500,25 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ return err } - // Update generation.json at the tree root with the new checkpoint ID and timestamps. - // This reads from the pre-splice root tree (to get existing metadata) and writes - // into the post-splice tree (which already has the shard directories). - gen, err := s.updateGenerationForWrite(rootTreeHash, opts.CheckpointID, time.Now().UTC()) - if err != nil { - return fmt.Errorf("failed to update generation metadata: %w", err) - } - newTreeHash, err = s.addGenerationToRootTree(newTreeHash, gen) - if err != nil { - return fmt.Errorf("failed to add generation.json to tree: %w", err) - } - commitMsg := fmt.Sprintf("Checkpoint: %s\n", opts.CheckpointID) if err := s.updateRef(refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail); err != nil { return err } // Check if rotation is needed after successful write. - if len(gen.Checkpoints) >= s.maxCheckpoints() { + // Count checkpoints by walking the tree (no generation.json on /full/current). + checkpointCount, countErr := s.CountCheckpointsInTree(newTreeHash) + if countErr != nil { + logging.Warn(ctx, "failed to count checkpoints for rotation check", + slog.String("error", countErr.Error()), + ) + return nil + } + if checkpointCount >= s.maxCheckpoints() { if rotErr := s.rotateGeneration(ctx); rotErr != nil { logging.Warn(ctx, "generation rotation failed", slog.String("error", rotErr.Error()), - slog.Int("checkpoint_count", len(gen.Checkpoints)), + slog.Int("checkpoint_count", checkpointCount), ) // Non-fatal: rotation failure doesn't invalidate the write } diff --git a/cmd/entire/cli/checkpoint/v2_generation.go b/cmd/entire/cli/checkpoint/v2_generation.go index bdd8dab25..620afc78e 100644 --- a/cmd/entire/cli/checkpoint/v2_generation.go +++ b/cmd/entire/cli/checkpoint/v2_generation.go @@ -27,24 +27,13 @@ import ( const DefaultMaxCheckpointsPerGeneration = 100 // GenerationMetadata tracks the state of a /full/* generation. -// A "generation" is a batch of raw transcripts stored under a single ref. -// The active generation lives at /full/current; when it reaches the checkpoint -// limit, it is archived as a numbered ref (e.g., /full/0000000000001) and a -// fresh /full/current is created. Archived generations can later be cleaned up -// based on their timestamps (see RFD-009 cleanup path). +// Written to the tree root as generation.json at archive time only — not during +// normal writes to /full/current. This keeps /full/current free of root-level +// files, ensuring conflict-free tree merges during push recovery. // -// Stored at the tree root as generation.json and updated on every WriteCommitted. -// UpdateCommitted (stop-time finalization) does NOT update this file since it -// replaces an existing transcript rather than adding a new checkpoint. -// -// The generation's sequence number is derived from the ref name, not stored -// in this struct. The checkpoint count is len(Checkpoints). +// The generation's sequence number is derived from the ref name, not stored here. +// Checkpoint membership is determined by walking the tree (shard directories). type GenerationMetadata struct { - // Checkpoints is the list of checkpoint IDs stored in this generation. - // Used for finding which generation holds a specific checkpoint - // without walking the tree. len(Checkpoints) gives the count. - Checkpoints []id.CheckpointID `json:"checkpoints"` - // OldestCheckpointAt is the creation time of the earliest checkpoint. OldestCheckpointAt time.Time `json:"oldest_checkpoint_at"` @@ -87,7 +76,7 @@ func (s *V2GitStore) readGeneration(treeHash plumbing.Hash) (GenerationMetadata, // readGenerationFromRef reads generation.json from the tree pointed to by the given ref. func (s *V2GitStore) readGenerationFromRef(refName plumbing.ReferenceName) (GenerationMetadata, error) { - _, treeHash, err := s.getRefState(refName) + _, treeHash, err := s.GetRefState(refName) if err != nil { return GenerationMetadata{}, fmt.Errorf("failed to get ref state: %w", err) } @@ -124,41 +113,33 @@ func (s *V2GitStore) writeGeneration(gen GenerationMetadata, entries map[string] return nil } -// updateGenerationForWrite reads the current generation metadata, appends the -// checkpoint ID (if not already present), and updates timestamps. -// Returns the updated metadata for the caller to write into the tree. -func (s *V2GitStore) updateGenerationForWrite(rootTreeHash plumbing.Hash, checkpointID id.CheckpointID, now time.Time) (GenerationMetadata, error) { - gen, err := s.readGeneration(rootTreeHash) - if err != nil { - return GenerationMetadata{}, err +// CountCheckpointsInTree counts checkpoint shard directories in a /full/* tree. +// The tree structure is // — we count second-level directories +// across all shard prefixes. Returns 0 for an empty tree. +func (s *V2GitStore) CountCheckpointsInTree(treeHash plumbing.Hash) (int, error) { + if treeHash == plumbing.ZeroHash { + return 0, nil } - // Only append if checkpoint ID is not already present (multi-session writes - // to the same checkpoint should not duplicate the ID). - found := false - for _, existing := range gen.Checkpoints { - if existing == checkpointID { - found = true - break - } + tree, err := s.repo.TreeObject(treeHash) + if err != nil { + return 0, fmt.Errorf("failed to read tree: %w", err) } - if !found { - gen.Checkpoints = append(gen.Checkpoints, checkpointID) - // Only update timestamps when a new checkpoint is added, so they reflect - // checkpoint creation times rather than last-write times. - if gen.OldestCheckpointAt.IsZero() { - gen.OldestCheckpointAt = now - } - gen.NewestCheckpointAt = now + count := 0 + if err := WalkCheckpointShards(s.repo, tree, func(_ id.CheckpointID, _ plumbing.Hash) error { + count++ + return nil + }); err != nil { + return 0, err } - return gen, nil + return count, nil } -// addGenerationToRootTree adds generation.json to an existing root tree, returning +// AddGenerationJSONToTree adds generation.json to an existing root tree, returning // a new root tree hash. Preserves all existing entries (shard directories, etc.). -func (s *V2GitStore) addGenerationToRootTree(rootTreeHash plumbing.Hash, gen GenerationMetadata) (plumbing.Hash, error) { +func (s *V2GitStore) AddGenerationJSONToTree(rootTreeHash plumbing.Hash, gen GenerationMetadata) (plumbing.Hash, error) { entry, err := s.marshalGenerationBlob(gen) if err != nil { return plumbing.ZeroHash, err @@ -168,15 +149,54 @@ func (s *V2GitStore) addGenerationToRootTree(rootTreeHash plumbing.Hash, gen Gen UpdateSubtreeOptions{MergeMode: MergeKeepExisting}) } +// computeGenerationTimestamps derives timestamps for a generation being archived. +// Uses the commit history of the /full/current ref: oldest = first commit time, +// newest = latest commit time. Falls back to time.Now() if the ref has no history. +// Note: /full/* trees don't contain session metadata (that's on /main), so we +// derive timestamps from git commit times rather than walking the tree. +func (s *V2GitStore) computeGenerationTimestamps() GenerationMetadata { + now := time.Now().UTC() + fallback := GenerationMetadata{OldestCheckpointAt: now, NewestCheckpointAt: now} + + refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) + ref, err := s.repo.Reference(refName, true) + if err != nil { + return fallback + } + + commit, err := s.repo.CommitObject(ref.Hash()) + if err != nil { + return fallback + } + + newest := commit.Committer.When.UTC() + + // Walk parents to find the oldest commit in this generation + iter := commit + for len(iter.ParentHashes) > 0 { + parent, parentErr := s.repo.CommitObject(iter.ParentHashes[0]) + if parentErr != nil { + break + } + iter = parent + } + oldest := iter.Committer.When.UTC() + + return GenerationMetadata{ + OldestCheckpointAt: oldest, + NewestCheckpointAt: newest, + } +} + // generationRefWidth is the zero-padded width of archived generation ref names. const generationRefWidth = 13 -// generationRefPattern matches exactly 13 digits (the archived generation ref suffix format). -var generationRefPattern = regexp.MustCompile(`^\d{13}$`) +// GenerationRefPattern matches exactly 13 digits (the archived generation ref suffix format). +var GenerationRefPattern = regexp.MustCompile(`^\d{13}$`) // listArchivedGenerations returns the names of all archived generation refs // (everything under V2FullRefPrefix matching the expected numeric format), sorted ascending. -func (s *V2GitStore) listArchivedGenerations() ([]string, error) { +func (s *V2GitStore) ListArchivedGenerations() ([]string, error) { refs, err := s.repo.References() if err != nil { return nil, fmt.Errorf("failed to list references: %w", err) @@ -189,7 +209,7 @@ func (s *V2GitStore) listArchivedGenerations() ([]string, error) { return nil } suffix := strings.TrimPrefix(name, paths.V2FullRefPrefix) - if suffix == "current" || !generationRefPattern.MatchString(suffix) { + if suffix == "current" || !GenerationRefPattern.MatchString(suffix) { return nil } archived = append(archived, suffix) @@ -206,7 +226,7 @@ func (s *V2GitStore) listArchivedGenerations() ([]string, error) { // nextGenerationNumber returns the next sequential generation number for archiving. // Scans existing archived refs and returns max+1. Returns 1 if no archives exist. func (s *V2GitStore) nextGenerationNumber() (int, error) { - archived, err := s.listArchivedGenerations() + archived, err := s.ListArchivedGenerations() if err != nil { return 0, err } @@ -234,13 +254,17 @@ func (s *V2GitStore) nextGenerationNumber() (int, error) { func (s *V2GitStore) rotateGeneration(ctx context.Context) error { refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) - // Guard against concurrent rotation: if another instance already rotated, - // /full/current will have fewer checkpoints than the threshold. - gen, err := s.readGenerationFromRef(refName) + // Guard against concurrent rotation: re-read /full/current and check if + // it's still above the threshold. If not, another instance already rotated. + _, currentTreeHash, err := s.GetRefState(refName) if err != nil { return fmt.Errorf("rotation: failed to read /full/current: %w", err) } - if len(gen.Checkpoints) < s.maxCheckpoints() { + checkpointCount, err := s.CountCheckpointsInTree(currentTreeHash) + if err != nil { + return fmt.Errorf("rotation: failed to count checkpoints: %w", err) + } + if checkpointCount < s.maxCheckpoints() { return nil } @@ -280,21 +304,32 @@ func (s *V2GitStore) rotateGeneration(ctx context.Context) error { return nil } - // Phase 2: Create fresh orphan /full/current - seedGen := GenerationMetadata{ - Checkpoints: []id.CheckpointID{}, + // Write generation.json to the current tree before archiving. + gen := s.computeGenerationTimestamps() + archiveTreeHash, err := s.AddGenerationJSONToTree(currentTreeHash, gen) + if err != nil { + return fmt.Errorf("rotation: failed to add generation.json: %w", err) } - seedEntries := make(map[string]object.TreeEntry) - if err := s.writeGeneration(seedGen, seedEntries); err != nil { - return fmt.Errorf("rotation: failed to build seed generation: %w", err) + + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) + archiveCommitHash, err := CreateCommit(s.repo, archiveTreeHash, currentRef.Hash(), "Archive generation", authorName, authorEmail) + if err != nil { + return fmt.Errorf("rotation: failed to create archive commit: %w", err) } - seedTreeHash, err := BuildTreeFromEntries(s.repo, seedEntries) + + // Update the archive ref to point to the commit with generation.json + archiveRef = plumbing.NewHashReference(archiveRefName, archiveCommitHash) + if err := s.repo.Storer.SetReference(archiveRef); err != nil { + return fmt.Errorf("rotation: failed to update archived ref %s: %w", archiveRefName, err) + } + + // Phase 2: Create fresh orphan /full/current (empty tree, no generation.json) + emptyTreeHash, err := BuildTreeFromEntries(s.repo, make(map[string]object.TreeEntry)) if err != nil { - return fmt.Errorf("rotation: failed to build seed tree: %w", err) + return fmt.Errorf("rotation: failed to build empty tree: %w", err) } - authorName, authorEmail := GetGitAuthorFromRepo(s.repo) - orphanCommitHash, err := CreateCommit(s.repo, seedTreeHash, plumbing.ZeroHash, "Start generation", authorName, authorEmail) + orphanCommitHash, err := CreateCommit(s.repo, emptyTreeHash, plumbing.ZeroHash, "Start generation", authorName, authorEmail) if err != nil { return fmt.Errorf("rotation: failed to create orphan commit: %w", err) } diff --git a/cmd/entire/cli/checkpoint/v2_generation_test.go b/cmd/entire/cli/checkpoint/v2_generation_test.go index 8a0a04fba..724e8d4ab 100644 --- a/cmd/entire/cli/checkpoint/v2_generation_test.go +++ b/cmd/entire/cli/checkpoint/v2_generation_test.go @@ -20,7 +20,7 @@ import ( func TestReadGeneration_EmptyTree_ReturnsDefault(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") // Build an empty tree emptyTree, err := BuildTreeFromEntries(repo, map[string]object.TreeEntry{}) @@ -29,7 +29,6 @@ func TestReadGeneration_EmptyTree_ReturnsDefault(t *testing.T) { gen, err := store.readGeneration(emptyTree) require.NoError(t, err) - assert.Empty(t, gen.Checkpoints) assert.True(t, gen.OldestCheckpointAt.IsZero()) assert.True(t, gen.NewestCheckpointAt.IsZero()) } @@ -37,11 +36,10 @@ func TestReadGeneration_EmptyTree_ReturnsDefault(t *testing.T) { func TestReadGeneration_ParsesJSON(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") now := time.Date(2026, 3, 25, 12, 0, 0, 0, time.UTC) original := GenerationMetadata{ - Checkpoints: []id.CheckpointID{id.MustCheckpointID("aabbccddeeff"), id.MustCheckpointID("112233445566")}, OldestCheckpointAt: now.Add(-1 * time.Hour), NewestCheckpointAt: now, } @@ -57,7 +55,6 @@ func TestReadGeneration_ParsesJSON(t *testing.T) { gen, err := store.readGeneration(treeHash) require.NoError(t, err) - assert.Equal(t, []id.CheckpointID{id.MustCheckpointID("aabbccddeeff"), id.MustCheckpointID("112233445566")}, gen.Checkpoints) assert.True(t, gen.OldestCheckpointAt.Equal(now.Add(-1*time.Hour))) assert.True(t, gen.NewestCheckpointAt.Equal(now)) } @@ -65,11 +62,10 @@ func TestReadGeneration_ParsesJSON(t *testing.T) { func TestWriteGeneration_RoundTrips(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") now := time.Date(2026, 3, 25, 10, 0, 0, 0, time.UTC) original := GenerationMetadata{ - Checkpoints: []id.CheckpointID{id.MustCheckpointID("aabbccddeeff")}, OldestCheckpointAt: now, NewestCheckpointAt: now, } @@ -88,18 +84,18 @@ func TestWriteGeneration_RoundTrips(t *testing.T) { gen, err := store.readGeneration(treeHash) require.NoError(t, err) - assert.Equal(t, original.Checkpoints, gen.Checkpoints) + assert.True(t, gen.OldestCheckpointAt.Equal(now)) + assert.True(t, gen.NewestCheckpointAt.Equal(now)) } func TestReadGenerationFromRef(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") // Create a ref with generation.json in its tree now := time.Date(2026, 3, 25, 14, 0, 0, 0, time.UTC) gen := GenerationMetadata{ - Checkpoints: []id.CheckpointID{id.MustCheckpointID("aabbccddeeff")}, OldestCheckpointAt: now, NewestCheckpointAt: now, } @@ -119,13 +115,14 @@ func TestReadGenerationFromRef(t *testing.T) { result, err := store.readGenerationFromRef(refName) require.NoError(t, err) - assert.Equal(t, []id.CheckpointID{id.MustCheckpointID("aabbccddeeff")}, result.Checkpoints) + assert.True(t, result.OldestCheckpointAt.Equal(now)) + assert.True(t, result.NewestCheckpointAt.Equal(now)) } -func TestAddGenerationToRootTree(t *testing.T) { +func TestAddGenerationJSONToTree(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") // Start with a root tree that has a shard directory entry (simulating checkpoint data) shardEntries := map[string]object.TreeEntry{} @@ -138,18 +135,19 @@ func TestAddGenerationToRootTree(t *testing.T) { require.NoError(t, err) gen := GenerationMetadata{ - Checkpoints: []id.CheckpointID{id.MustCheckpointID("aabbccddeeff")}, + OldestCheckpointAt: time.Now().UTC(), + NewestCheckpointAt: time.Now().UTC(), } // Add generation.json to the root tree - newRootHash, err := store.addGenerationToRootTree(rootTreeHash, gen) + newRootHash, err := store.AddGenerationJSONToTree(rootTreeHash, gen) require.NoError(t, err) assert.NotEqual(t, rootTreeHash, newRootHash) // Verify generation.json is present and shard dir is preserved readGen, err := store.readGeneration(newRootHash) require.NoError(t, err) - assert.Len(t, readGen.Checkpoints, 1) + assert.False(t, readGen.OldestCheckpointAt.IsZero()) // Verify the shard directory still exists in the tree tree, err := repo.TreeObject(newRootHash) @@ -163,81 +161,85 @@ func TestAddGenerationToRootTree(t *testing.T) { assert.True(t, foundShard, "shard directory should be preserved") } -// v2FullGeneration reads generation.json from the /full/current ref. -func v2FullGeneration(t *testing.T, repo *git.Repository) GenerationMetadata { - t.Helper() - store := NewV2GitStore(repo) - gen, err := store.readGenerationFromRef(plumbing.ReferenceName(paths.V2FullCurrentRefName)) +func TestCountCheckpointsInTree_EmptyTree(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo, "origin") + + count, err := store.CountCheckpointsInTree(plumbing.ZeroHash) require.NoError(t, err) - return gen + assert.Equal(t, 0, count) } -func TestWriteCommittedFull_UpdatesGenerationJSON(t *testing.T) { +func TestCountCheckpointsInTree_CountsShardDirectories(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() - cpID := id.MustCheckpointID("d1e2f3a4b5c6") - err := store.WriteCommitted(ctx, WriteCommittedOptions{ - CheckpointID: cpID, - SessionID: "session-gen-001", - Strategy: "manual-commit", - Agent: agent.AgentTypeClaudeCode, - Transcript: []byte(`{"type":"assistant","message":"hello"}`), - AuthorName: "Test", - AuthorEmail: "test@test.com", - }) + // Write 3 checkpoints to /full/current + cpIDs := []id.CheckpointID{ + id.MustCheckpointID("aabbccddeeff"), + id.MustCheckpointID("112233445566"), + id.MustCheckpointID("ffeeddccbbaa"), + } + + for _, cpID := range cpIDs { + err := store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session", + Strategy: "manual-commit", + Agent: agent.AgentTypeClaudeCode, + Transcript: []byte(`{"type":"test"}`), + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + } + + refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) + _, treeHash, err := store.GetRefState(refName) require.NoError(t, err) - gen := v2FullGeneration(t, repo) - assert.Len(t, gen.Checkpoints, 1) - assert.Equal(t, []id.CheckpointID{cpID}, gen.Checkpoints) - assert.False(t, gen.OldestCheckpointAt.IsZero()) - assert.False(t, gen.NewestCheckpointAt.IsZero()) + count, err := store.CountCheckpointsInTree(treeHash) + require.NoError(t, err) + assert.Equal(t, 3, count) } -func TestWriteCommittedFull_AccumulatesInGenerationJSON(t *testing.T) { +func TestWriteCommittedFull_NoGenerationJSON(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() - cpA := id.MustCheckpointID("e2f3a4b5c6d1") - cpB := id.MustCheckpointID("f3a4b5c6d1e2") - + cpID := id.MustCheckpointID("d1e2f3a4b5c6") err := store.WriteCommitted(ctx, WriteCommittedOptions{ - CheckpointID: cpA, - SessionID: "session-acc-A", + CheckpointID: cpID, + SessionID: "session-gen-001", Strategy: "manual-commit", Agent: agent.AgentTypeClaudeCode, - Transcript: []byte(`{"from":"A"}`), + Transcript: []byte(`{"type":"assistant","message":"hello"}`), AuthorName: "Test", AuthorEmail: "test@test.com", }) require.NoError(t, err) - err = store.WriteCommitted(ctx, WriteCommittedOptions{ - CheckpointID: cpB, - SessionID: "session-acc-B", - Strategy: "manual-commit", - Agent: agent.AgentTypeClaudeCode, - Transcript: []byte(`{"from":"B"}`), - AuthorName: "Test", - AuthorEmail: "test@test.com", - }) - require.NoError(t, err) + // /full/current should NOT contain generation.json (written at archive time only) + fullTree := v2FullTree(t, repo) + for _, entry := range fullTree.Entries { + assert.NotEqual(t, paths.GenerationFileName, entry.Name, + "/full/current should not contain generation.json") + } - gen := v2FullGeneration(t, repo) - assert.Len(t, gen.Checkpoints, 2) - assert.Equal(t, []id.CheckpointID{cpA, cpB}, gen.Checkpoints) - assert.True(t, gen.NewestCheckpointAt.After(gen.OldestCheckpointAt) || gen.NewestCheckpointAt.Equal(gen.OldestCheckpointAt)) + // Checkpoint data should still be present + content := v2ReadFile(t, fullTree, cpID.Path()+"/0/"+paths.TranscriptFileName) + assert.Contains(t, content, "hello") } -func TestUpdateCommitted_DoesNotUpdateGenerationJSON(t *testing.T) { +func TestUpdateCommitted_DoesNotAddGenerationJSON(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("a4b5c6d1e2f3") @@ -255,10 +257,7 @@ func TestUpdateCommitted_DoesNotUpdateGenerationJSON(t *testing.T) { }) require.NoError(t, err) - genBefore := v2FullGeneration(t, repo) - require.Len(t, genBefore.Checkpoints, 1) - - // Update (stop-time finalization) — should NOT change generation.json + // Update (stop-time finalization) err = store.UpdateCommitted(ctx, UpdateCommittedOptions{ CheckpointID: cpID, SessionID: "session-noupdate-gen", @@ -268,82 +267,28 @@ func TestUpdateCommitted_DoesNotUpdateGenerationJSON(t *testing.T) { }) require.NoError(t, err) - genAfter := v2FullGeneration(t, repo) - assert.Len(t, genAfter.Checkpoints, 1, "UpdateCommitted should not change checkpoint count") - assert.Equal(t, genBefore.Checkpoints, genAfter.Checkpoints) - - // Verify the transcript was actually updated (sanity check) + // /full/current should still not have generation.json fullTree := v2FullTree(t, repo) - content := v2ReadFile(t, fullTree, cpID.Path()+"/0/"+paths.TranscriptFileName) - assert.Contains(t, content, "finalized") -} - -func TestWriteCommittedFull_GenerationJSON_SameCheckpointIdNotDuplicated(t *testing.T) { - t.Parallel() - repo := initTestRepo(t) - store := NewV2GitStore(repo) - ctx := context.Background() - - cpID := id.MustCheckpointID("b5c6d1e2f3a4") - - // Write same checkpoint twice (e.g., two sessions for the same commit) - for _, sessID := range []string{"session-dup-1", "session-dup-2"} { - err := store.WriteCommitted(ctx, WriteCommittedOptions{ - CheckpointID: cpID, - SessionID: sessID, - Strategy: "manual-commit", - Agent: agent.AgentTypeClaudeCode, - Transcript: []byte(`{"from":"` + sessID + `"}`), - AuthorName: "Test", - AuthorEmail: "test@test.com", - }) - require.NoError(t, err) + for _, entry := range fullTree.Entries { + assert.NotEqual(t, paths.GenerationFileName, entry.Name, + "/full/current should not contain generation.json after update") } - gen := v2FullGeneration(t, repo) - // Same checkpoint ID written twice should only appear once in the array - assert.Len(t, gen.Checkpoints, 1) - assert.Equal(t, []id.CheckpointID{cpID}, gen.Checkpoints) -} - -func TestWriteCommittedFull_GenerationJSON_PreservedInTree(t *testing.T) { - t.Parallel() - repo := initTestRepo(t) - store := NewV2GitStore(repo) - ctx := context.Background() - - cpID := id.MustCheckpointID("c6d1e2f3a4b5") - err := store.WriteCommitted(ctx, WriteCommittedOptions{ - CheckpointID: cpID, - SessionID: "session-tree-check", - Strategy: "manual-commit", - Agent: agent.AgentTypeClaudeCode, - Transcript: []byte(`{"check":"tree"}`), - AuthorName: "Test", - AuthorEmail: "test@test.com", - }) - require.NoError(t, err) - - // Read the /full/current tree and verify generation.json is at root - fullTree := v2FullTree(t, repo) - genContent := v2ReadFile(t, fullTree, paths.GenerationFileName) - var gen GenerationMetadata - require.NoError(t, json.Unmarshal([]byte(genContent), &gen)) - assert.Len(t, gen.Checkpoints, 1) - - // Verify checkpoint data is also present + // Verify the transcript was actually updated (sanity check) content := v2ReadFile(t, fullTree, cpID.Path()+"/0/"+paths.TranscriptFileName) - assert.Contains(t, content, `"check":"tree"`) + assert.Contains(t, content, "finalized") } // createArchivedRef creates a dummy archived generation ref for testing. func createArchivedRef(t *testing.T, repo *git.Repository, number int) { t.Helper() - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") // Build a minimal tree with just generation.json + now := time.Now().UTC() gen := GenerationMetadata{ - Checkpoints: []id.CheckpointID{id.MustCheckpointID("d00000000000")}, + OldestCheckpointAt: now.Add(-time.Hour), + NewestCheckpointAt: now, } entries := make(map[string]object.TreeEntry) require.NoError(t, store.writeGeneration(gen, entries)) @@ -361,9 +306,9 @@ func createArchivedRef(t *testing.T, repo *git.Repository, number int) { func TestListArchivedGenerations_Empty(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") - archived, err := store.listArchivedGenerations() + archived, err := store.ListArchivedGenerations() require.NoError(t, err) assert.Empty(t, archived) } @@ -371,12 +316,12 @@ func TestListArchivedGenerations_Empty(t *testing.T) { func TestListArchivedGenerations_FindsArchived(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") createArchivedRef(t, repo, 1) createArchivedRef(t, repo, 2) - archived, err := store.listArchivedGenerations() + archived, err := store.ListArchivedGenerations() require.NoError(t, err) assert.Equal(t, []string{"0000000000001", "0000000000002"}, archived) } @@ -384,7 +329,7 @@ func TestListArchivedGenerations_FindsArchived(t *testing.T) { func TestListArchivedGenerations_ExcludesCurrent(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") // Create /full/current ref require.NoError(t, store.ensureRef(plumbing.ReferenceName(paths.V2FullCurrentRefName))) @@ -392,7 +337,7 @@ func TestListArchivedGenerations_ExcludesCurrent(t *testing.T) { // Create an archived ref createArchivedRef(t, repo, 1) - archived, err := store.listArchivedGenerations() + archived, err := store.ListArchivedGenerations() require.NoError(t, err) assert.Equal(t, []string{"0000000000001"}, archived) } @@ -400,7 +345,7 @@ func TestListArchivedGenerations_ExcludesCurrent(t *testing.T) { func TestNextGenerationNumber_NoArchives(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") next, err := store.nextGenerationNumber() require.NoError(t, err) @@ -410,7 +355,7 @@ func TestNextGenerationNumber_NoArchives(t *testing.T) { func TestNextGenerationNumber_WithExisting(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") createArchivedRef(t, repo, 1) createArchivedRef(t, repo, 2) @@ -445,7 +390,7 @@ func populateFullCurrent(t *testing.T, store *V2GitStore, n, offset int) []id.Ch func TestRotateGeneration_ArchivesCurrentAndCreatesNewOrphan(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") store.maxCheckpointsPerGeneration = 3 // Write 3 checkpoints — the 3rd triggers auto-rotation via writeCommittedFullTranscript @@ -456,15 +401,13 @@ func TestRotateGeneration_ArchivesCurrentAndCreatesNewOrphan(t *testing.T) { archiveRef, err := repo.Reference(plumbing.ReferenceName(archiveRefName), true) require.NoError(t, err, "archived ref should exist") - // Archived ref should contain all 3 checkpoints + // Archived ref should contain generation.json with timestamps archiveCommit, err := repo.CommitObject(archiveRef.Hash()) require.NoError(t, err) archiveGen, err := store.readGeneration(archiveCommit.TreeHash) require.NoError(t, err) - assert.Len(t, archiveGen.Checkpoints, 3) - for i, cpID := range cpIDs { - assert.Equal(t, cpID, archiveGen.Checkpoints[i]) - } + assert.False(t, archiveGen.OldestCheckpointAt.IsZero(), "archived generation should have oldest timestamp") + assert.False(t, archiveGen.NewestCheckpointAt.IsZero(), "archived generation should have newest timestamp") // Archived tree should contain the checkpoint data archiveTree, err := archiveCommit.Tree() @@ -474,6 +417,10 @@ func TestRotateGeneration_ArchivesCurrentAndCreatesNewOrphan(t *testing.T) { require.NoError(t, treeErr, "archived tree should contain transcript for %s", cpID) } + // Archived tree should also contain generation.json + _, genErr := archiveTree.File(paths.GenerationFileName) + require.NoError(t, genErr, "archived tree should contain generation.json") + // --- Verify fresh /full/current --- fullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) require.NoError(t, err) @@ -483,22 +430,16 @@ func TestRotateGeneration_ArchivesCurrentAndCreatesNewOrphan(t *testing.T) { // Fresh commit should be an orphan (no parents) assert.Empty(t, freshCommit.ParentHashes, "fresh /full/current should be an orphan commit") - // Fresh tree should contain only generation.json (no shard directories) + // Fresh tree should be empty (no generation.json, no shard directories) freshTree, err := freshCommit.Tree() require.NoError(t, err) - assert.Len(t, freshTree.Entries, 1, "fresh tree should contain only generation.json") - assert.Equal(t, paths.GenerationFileName, freshTree.Entries[0].Name) - - // Seed generation.json should have empty checkpoints - freshGen, err := store.readGeneration(freshCommit.TreeHash) - require.NoError(t, err) - assert.Empty(t, freshGen.Checkpoints) + assert.Empty(t, freshTree.Entries, "fresh tree should be empty (no generation.json)") } func TestRotateGeneration_SequentialNumbering(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") store.maxCheckpointsPerGeneration = 2 ctx := context.Background() @@ -511,15 +452,71 @@ func TestRotateGeneration_SequentialNumbering(t *testing.T) { require.NoError(t, store.rotateGeneration(ctx)) // Verify both archived refs exist with correct generation numbers - archived, err := store.listArchivedGenerations() + archived, err := store.ListArchivedGenerations() require.NoError(t, err) assert.Equal(t, []string{"0000000000001", "0000000000002"}, archived) - // Verify each archived ref has checkpoints + // Verify each archived ref has generation.json with timestamps for _, name := range archived { refName := plumbing.ReferenceName(paths.V2FullRefPrefix + name) gen, readErr := store.readGenerationFromRef(refName) require.NoError(t, readErr) - assert.Len(t, gen.Checkpoints, 2, "archive %s should have 2 checkpoints", name) + assert.False(t, gen.OldestCheckpointAt.IsZero(), "archive %s should have oldest timestamp", name) + assert.False(t, gen.NewestCheckpointAt.IsZero(), "archive %s should have newest timestamp", name) + + // Verify checkpoint count via tree walk + _, treeHash, refErr := store.GetRefState(refName) + require.NoError(t, refErr) + count, countErr := store.CountCheckpointsInTree(treeHash) + require.NoError(t, countErr) + assert.Equal(t, 2, count, "archive %s should have 2 checkpoints", name) } } + +// Verify generation.json is correctly read from old format (with checkpoints field). +// This ensures backward compatibility when reading archived generations created +// before the Checkpoints field was removed. +func TestReadGeneration_BackwardCompatible(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo, "origin") + + // Simulate old format with a checkpoints field + oldJSON := `{ + "checkpoints": ["aabbccddeeff", "112233445566"], + "oldest_checkpoint_at": "2026-03-25T11:00:00Z", + "newest_checkpoint_at": "2026-03-25T12:00:00Z" + }` + blobHash, err := CreateBlobFromContent(repo, []byte(oldJSON)) + require.NoError(t, err) + + entries := map[string]object.TreeEntry{ + paths.GenerationFileName: { + Name: paths.GenerationFileName, + Mode: 0o100644, + Hash: blobHash, + }, + } + treeHash, err := BuildTreeFromEntries(repo, entries) + require.NoError(t, err) + + // Should parse without error, ignoring the unknown checkpoints field + gen, err := store.readGeneration(treeHash) + require.NoError(t, err) + + expected := time.Date(2026, 3, 25, 12, 0, 0, 0, time.UTC) + assert.True(t, gen.NewestCheckpointAt.Equal(expected)) +} + +// Verify backward-compatible JSON encoding: old data with "checkpoints" key +// should still parse (JSON ignores unknown fields by default). +func TestGenerationMetadata_JSONBackwardCompat(t *testing.T) { + t.Parallel() + + oldJSON := `{"checkpoints":["aabbccddeeff"],"oldest_checkpoint_at":"2026-01-01T00:00:00Z","newest_checkpoint_at":"2026-02-01T00:00:00Z"}` + var gen GenerationMetadata + err := json.Unmarshal([]byte(oldJSON), &gen) + require.NoError(t, err) + assert.False(t, gen.OldestCheckpointAt.IsZero()) + assert.False(t, gen.NewestCheckpointAt.IsZero()) +} diff --git a/cmd/entire/cli/checkpoint/v2_read.go b/cmd/entire/cli/checkpoint/v2_read.go index 49384dc83..b8e0c4e59 100644 --- a/cmd/entire/cli/checkpoint/v2_read.go +++ b/cmd/entire/cli/checkpoint/v2_read.go @@ -4,12 +4,16 @@ import ( "context" "encoding/json" "fmt" + "log/slog" + "os/exec" "strconv" "strings" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v6/plumbing" @@ -24,7 +28,7 @@ func (s *V2GitStore) ReadCommitted(ctx context.Context, checkpointID id.Checkpoi } refName := plumbing.ReferenceName(paths.V2MainRefName) - _, rootTreeHash, err := s.getRefState(refName) + _, rootTreeHash, err := s.GetRefState(refName) if err != nil { return nil, nil //nolint:nilnil,nilerr // Ref doesn't exist means no checkpoint } @@ -69,7 +73,7 @@ func (s *V2GitStore) ReadSessionContent(ctx context.Context, checkpointID id.Che } refName := plumbing.ReferenceName(paths.V2MainRefName) - _, rootTreeHash, err := s.getRefState(refName) + _, rootTreeHash, err := s.GetRefState(refName) if err != nil { return nil, ErrCheckpointNotFound } @@ -120,6 +124,7 @@ func (s *V2GitStore) ReadSessionContent(ctx context.Context, checkpointID id.Che // readTranscriptFromFullRefs reads the raw transcript for a checkpoint session // by searching /full/current first, then archived generations in reverse order. +// If not found locally, attempts to discover and fetch remote /full/* refs. func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int, agentType types.AgentType) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err //nolint:wrapcheck // Propagating context cancellation @@ -127,12 +132,13 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI sessionPath := fmt.Sprintf("%s/%d", checkpointID.Path(), sessionIndex) + // Search locally first transcript, err := s.readTranscriptFromRef(plumbing.ReferenceName(paths.V2FullCurrentRefName), sessionPath, agentType) if err == nil && len(transcript) > 0 { return transcript, nil } - archived, err := s.listArchivedGenerations() + archived, err := s.ListArchivedGenerations() if err != nil { return nil, err } @@ -144,16 +150,94 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI } } + // Not found locally — try fetching remote /full/* refs + if fetchErr := s.fetchRemoteFullRefs(ctx); fetchErr != nil { + logging.Debug(ctx, "failed to fetch remote /full/* refs", + slog.String("error", fetchErr.Error()), + ) + return nil, nil + } + + // Search newly fetched refs only + newArchived, err := s.ListArchivedGenerations() + if err != nil { + return nil, nil //nolint:nilerr // Best-effort: fetch-on-demand failure shouldn't block resume + } + existingSet := make(map[string]bool, len(archived)) + for _, a := range archived { + existingSet[a] = true + } + for i := len(newArchived) - 1; i >= 0; i-- { + if existingSet[newArchived[i]] { + continue + } + refName := plumbing.ReferenceName(paths.V2FullRefPrefix + newArchived[i]) + transcript, err := s.readTranscriptFromRef(refName, sessionPath, agentType) + if err == nil && len(transcript) > 0 { + return transcript, nil + } + } + + // Also retry /full/current in case it was updated by the fetch + transcript, err = s.readTranscriptFromRef(plumbing.ReferenceName(paths.V2FullCurrentRefName), sessionPath, agentType) + if err == nil && len(transcript) > 0 { + return transcript, nil + } + return nil, nil } +// fetchRemoteFullRefs discovers and fetches /full/* refs from the configured +// FetchRemote that aren't local. +func (s *V2GitStore) fetchRemoteFullRefs(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + lsCmd := exec.CommandContext(ctx, "git", "ls-remote", s.FetchRemote, paths.V2FullRefPrefix+"*") + output, err := lsCmd.Output() + if err != nil { + return fmt.Errorf("ls-remote failed: %w", err) + } + + var refSpecs []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + remoteRefName := parts[1] + + // Skip refs that already exist locally + if _, refErr := s.repo.Reference(plumbing.ReferenceName(remoteRefName), true); refErr == nil { + continue + } + + refSpecs = append(refSpecs, fmt.Sprintf("+%s:%s", remoteRefName, remoteRefName)) + } + + if len(refSpecs) == 0 { + return nil + } + + args := append([]string{"fetch", s.FetchRemote}, refSpecs...) + fetchCmd := exec.CommandContext(ctx, "git", args...) + if fetchOutput, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil { + return fmt.Errorf("fetch failed: %s", fetchOutput) + } + + return nil +} + // readTranscriptFromRef reads the raw transcript from a specific /full/* ref. // Follows the same chunking convention as readTranscriptFromTree in committed.go: // chunk 0 is the base file (full.jsonl), chunks 1+ are full.jsonl.001, .002, etc. // When chunk files exist, all chunks (including chunk 0) are reassembled using // agent-aware reassembly via agent.ReassembleTranscript. func (s *V2GitStore) readTranscriptFromRef(refName plumbing.ReferenceName, sessionPath string, agentType types.AgentType) ([]byte, error) { - _, rootTreeHash, err := s.getRefState(refName) + _, rootTreeHash, err := s.GetRefState(refName) if err != nil { return nil, err } diff --git a/cmd/entire/cli/checkpoint/v2_read_test.go b/cmd/entire/cli/checkpoint/v2_read_test.go index a87211f29..a07740de6 100644 --- a/cmd/entire/cli/checkpoint/v2_read_test.go +++ b/cmd/entire/cli/checkpoint/v2_read_test.go @@ -17,7 +17,7 @@ import ( func TestV2ReadCommitted_ReturnsCheckpointSummary(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") cpID := id.MustCheckpointID("a1a2a3a4a5a6") ctx := context.Background() @@ -42,7 +42,7 @@ func TestV2ReadCommitted_ReturnsCheckpointSummary(t *testing.T) { func TestV2ReadCommitted_ReturnsNilForMissing(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") cpID := id.MustCheckpointID("b1b2b3b4b5b6") ctx := context.Background() @@ -54,7 +54,7 @@ func TestV2ReadCommitted_ReturnsNilForMissing(t *testing.T) { func TestV2ReadSessionContent_ReturnsMetadataAndTranscript(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") cpID := id.MustCheckpointID("c1c2c3c4c5c6") ctx := context.Background() @@ -80,7 +80,7 @@ func TestV2ReadSessionContent_ReturnsMetadataAndTranscript(t *testing.T) { func TestV2ReadSessionContent_TranscriptFromArchivedGeneration(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") store.maxCheckpointsPerGeneration = 1 ctx := context.Background() @@ -115,7 +115,7 @@ func TestV2ReadSessionContent_TranscriptFromArchivedGeneration(t *testing.T) { func TestV2ReadSessionContent_MissingTranscript_ReturnsError(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") cpID := id.MustCheckpointID("f1f2f3f4f5f6") ctx := context.Background() @@ -140,7 +140,7 @@ func TestV2ReadSessionContent_ChunkedTranscript(t *testing.T) { ctx := context.Background() // Write metadata to /main so ReadSessionContent can find the checkpoint - v2Store := NewV2GitStore(repo) + v2Store := NewV2GitStore(repo, "origin") err := v2Store.WriteCommitted(ctx, WriteCommittedOptions{ CheckpointID: cpID, SessionID: "session-chunked", @@ -159,7 +159,7 @@ func TestV2ReadSessionContent_ChunkedTranscript(t *testing.T) { err = v2Store.ensureRef(refName) require.NoError(t, err) - _, rootTreeHash, err := v2Store.getRefState(refName) + _, rootTreeHash, err := v2Store.GetRefState(refName) require.NoError(t, err) sessionPath := cpID.Path() + "/0/" @@ -186,7 +186,7 @@ func TestV2ReadSessionContent_ChunkedTranscript(t *testing.T) { newTreeHash, err := v2Store.gs.spliceCheckpointSubtree(rootTreeHash, cpID, cpID.Path()+"/", entries) require.NoError(t, err) - parentHash, _, err := v2Store.getRefState(refName) + parentHash, _, err := v2Store.GetRefState(refName) require.NoError(t, err) err = v2Store.updateRef(refName, newTreeHash, parentHash, "chunked test", "Test", "test@test.com") require.NoError(t, err) diff --git a/cmd/entire/cli/checkpoint/v2_resolve_test.go b/cmd/entire/cli/checkpoint/v2_resolve_test.go index 547073df7..e579194dd 100644 --- a/cmd/entire/cli/checkpoint/v2_resolve_test.go +++ b/cmd/entire/cli/checkpoint/v2_resolve_test.go @@ -15,7 +15,7 @@ import ( func TestGetV2MetadataTree_LocalRef(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") cpID := id.MustCheckpointID("a1a2a3a4a5a6") ctx := context.Background() @@ -64,7 +64,7 @@ func TestGetV2MetadataTree_NoRef_ReturnsError(t *testing.T) { func TestGetV2MetadataTree_FetchSucceeds(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") cpID := id.MustCheckpointID("b1b2b3b4b5b6") ctx := context.Background() @@ -98,7 +98,7 @@ func TestGetV2MetadataTree_FetchSucceeds(t *testing.T) { func TestGetV2MetadataTree_TreelessFetchFails_FallsBackToFullFetch(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") cpID := id.MustCheckpointID("c1c2c3c4c5c6") ctx := context.Background() diff --git a/cmd/entire/cli/checkpoint/v2_store.go b/cmd/entire/cli/checkpoint/v2_store.go index c965f652b..7a63fe01f 100644 --- a/cmd/entire/cli/checkpoint/v2_store.go +++ b/cmd/entire/cli/checkpoint/v2_store.go @@ -24,6 +24,11 @@ type V2GitStore struct { // maxCheckpointsPerGeneration overrides the rotation threshold for testing. // Zero means use DefaultMaxCheckpointsPerGeneration. maxCheckpointsPerGeneration int + + // FetchRemote is the git remote used for fetch-on-demand operations (e.g., + // fetching /full/* refs during entire resume). Defaults to "origin". + // Set to the checkpoint remote URL when checkpoint_remote is configured. + FetchRemote string } // maxCheckpoints returns the effective rotation threshold. @@ -35,10 +40,16 @@ func (s *V2GitStore) maxCheckpoints() int { } // NewV2GitStore creates a new v2 checkpoint store backed by the given git repository. -func NewV2GitStore(repo *git.Repository) *V2GitStore { +// fetchRemote is the git remote used for fetch-on-demand operations (e.g., fetching +// /full/* refs during entire resume). Pass "origin" or the checkpoint remote URL. +func NewV2GitStore(repo *git.Repository, fetchRemote string) *V2GitStore { + if fetchRemote == "" { + fetchRemote = "origin" + } return &V2GitStore{ - repo: repo, - gs: &GitStore{repo: repo}, + repo: repo, + gs: &GitStore{repo: repo}, + FetchRemote: fetchRemote, } } @@ -69,8 +80,8 @@ func (s *V2GitStore) ensureRef(refName plumbing.ReferenceName) error { return nil } -// getRefState returns the parent commit hash and root tree hash for a ref. -func (s *V2GitStore) getRefState(refName plumbing.ReferenceName) (parentHash, treeHash plumbing.Hash, err error) { +// GetRefState returns the parent commit hash and root tree hash for a ref. +func (s *V2GitStore) GetRefState(refName plumbing.ReferenceName) (parentHash, treeHash plumbing.Hash, err error) { ref, err := s.repo.Reference(refName, true) if err != nil { return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("ref %s not found: %w", refName, err) diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index e28fcfc26..3e4887713 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -45,7 +45,7 @@ func initTestRepo(t *testing.T) *git.Repository { func TestNewV2GitStore(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") require.NotNil(t, store) require.Equal(t, repo, store.repo) } @@ -53,7 +53,7 @@ func TestNewV2GitStore(t *testing.T) { func TestV2GitStore_EnsureRef_CreatesNewRef(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") refName := plumbing.ReferenceName(paths.V2MainRefName) @@ -79,7 +79,7 @@ func TestV2GitStore_EnsureRef_CreatesNewRef(t *testing.T) { func TestV2GitStore_EnsureRef_Idempotent(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") refName := plumbing.ReferenceName(paths.V2MainRefName) @@ -97,7 +97,7 @@ func TestV2GitStore_EnsureRef_Idempotent(t *testing.T) { func TestV2GitStore_EnsureRef_DifferentRefs(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") mainRef := plumbing.ReferenceName(paths.V2MainRefName) fullRef := plumbing.ReferenceName(paths.V2FullCurrentRefName) @@ -115,12 +115,12 @@ func TestV2GitStore_EnsureRef_DifferentRefs(t *testing.T) { func TestV2GitStore_GetRefState_ReturnsParentAndTree(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") refName := plumbing.ReferenceName(paths.V2MainRefName) require.NoError(t, store.ensureRef(refName)) - parentHash, treeHash, err := store.getRefState(refName) + parentHash, treeHash, err := store.GetRefState(refName) require.NoError(t, err) require.NotEqual(t, plumbing.ZeroHash, parentHash, "parent hash should be non-zero") // Tree hash can be zero hash for empty tree or a valid hash — just verify no error @@ -130,22 +130,22 @@ func TestV2GitStore_GetRefState_ReturnsParentAndTree(t *testing.T) { func TestV2GitStore_GetRefState_ErrorsOnMissingRef(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") refName := plumbing.ReferenceName("refs/entire/nonexistent") - _, _, err := store.getRefState(refName) + _, _, err := store.GetRefState(refName) require.Error(t, err) } func TestV2GitStore_UpdateRef_CreatesCommit(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") refName := plumbing.ReferenceName(paths.V2MainRefName) require.NoError(t, store.ensureRef(refName)) - parentHash, treeHash, err := store.getRefState(refName) + parentHash, treeHash, err := store.GetRefState(refName) require.NoError(t, err) // Build a tree with one file @@ -200,7 +200,7 @@ func v2ReadFile(t *testing.T, tree *object.Tree, path string) string { func TestV2GitStore_WriteCommittedMain_WritesMetadata(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("a1b2c3d4e5f6") @@ -238,7 +238,7 @@ func TestV2GitStore_WriteCommittedMain_WritesMetadata(t *testing.T) { func TestV2GitStore_WriteCommittedMain_WritesPrompts(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("b2c3d4e5f6a1") @@ -271,7 +271,7 @@ func TestV2GitStore_WriteCommittedMain_WritesPrompts(t *testing.T) { func TestV2GitStore_WriteCommittedMain_ExcludesTranscript(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("c3d4e5f6a1b2") @@ -307,7 +307,7 @@ func TestV2GitStore_WriteCommittedMain_ExcludesTranscript(t *testing.T) { func TestV2GitStore_WriteCommittedMain_WritesCompactTranscript(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() compactData := []byte(`{"v":1,"agent":"claude-code","cli_version":"0.1.0","type":"user","ts":"2026-01-01T00:00:00Z","content":"hello"}`) @@ -349,7 +349,7 @@ func TestV2GitStore_WriteCommittedMain_WritesCompactTranscript(t *testing.T) { func TestV2GitStore_WriteCommittedMain_NoCompactTranscript_SkipsGracefully(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("e5f6a1b2c3d4") @@ -382,7 +382,7 @@ func TestV2GitStore_WriteCommittedMain_NoCompactTranscript_SkipsGracefully(t *te func TestV2GitStore_UpdateCommitted_WritesCompactTranscript(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("f6a1b2c3d4e5") @@ -428,7 +428,7 @@ func TestV2GitStore_UpdateCommitted_WritesCompactTranscript(t *testing.T) { func TestV2GitStore_WriteCommittedMain_MultiSession(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("e5f6a1b2c3d4") @@ -487,7 +487,7 @@ func v2FullTree(t *testing.T, repo *git.Repository) *object.Tree { func TestV2GitStore_WriteCommittedFull_WritesTranscript(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("f1a2b3c4d5e6") @@ -516,7 +516,7 @@ func TestV2GitStore_WriteCommittedFull_WritesTranscript(t *testing.T) { func TestV2GitStore_WriteCommittedFull_ExcludesMetadata(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("a2b3c4d5e6f1") @@ -555,7 +555,7 @@ func TestV2GitStore_WriteCommittedFull_ExcludesMetadata(t *testing.T) { func TestV2GitStore_WriteCommittedFull_NoTranscript_Noop(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("b3c4d5e6f1a2") @@ -583,7 +583,7 @@ func TestV2GitStore_WriteCommittedFull_NoTranscript_Noop(t *testing.T) { func TestV2GitStore_WriteCommittedFullTranscript_AccumulatesCheckpoints(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpA := id.MustCheckpointID("c4d5e6f1a2b3") @@ -624,7 +624,7 @@ func TestV2GitStore_WriteCommittedFullTranscript_AccumulatesCheckpoints(t *testi func TestV2GitStore_WriteCommitted_WritesBothRefs(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("aa11bb22cc33") @@ -666,7 +666,7 @@ func TestV2GitStore_WriteCommitted_WritesBothRefs(t *testing.T) { func TestV2GitStore_WriteCommitted_NoTranscript_OnlyWritesMain(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("bb22cc33dd44") @@ -691,7 +691,7 @@ func TestV2GitStore_WriteCommitted_NoTranscript_OnlyWritesMain(t *testing.T) { func TestV2GitStore_WriteCommitted_MultiSession_ConsistentIndex(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("cc33dd44ee55") @@ -738,7 +738,7 @@ func TestV2GitStore_WriteCommitted_MultiSession_ConsistentIndex(t *testing.T) { func TestV2GitStore_UpdateCommitted_UpdatesBothRefs(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("ff11aa22bb33") @@ -783,7 +783,7 @@ func TestV2GitStore_UpdateCommitted_UpdatesBothRefs(t *testing.T) { func TestV2GitStore_UpdateCommitted_NoTranscript_OnlyUpdatesMain(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("aa33bb44cc55") @@ -823,7 +823,7 @@ func TestV2GitStore_UpdateCommitted_NoTranscript_OnlyUpdatesMain(t *testing.T) { func TestV2GitStore_UpdateCommitted_CheckpointNotFound(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("bb44cc55dd66") @@ -841,7 +841,7 @@ func TestV2GitStore_UpdateCommitted_CheckpointNotFound(t *testing.T) { func TestWriteCommitted_TriggersRotationAtThreshold(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") store.maxCheckpointsPerGeneration = 3 // Low threshold for testing ctx := context.Background() @@ -861,19 +861,23 @@ func TestWriteCommitted_TriggersRotationAtThreshold(t *testing.T) { } // Verify an archived generation exists - archived, err := store.listArchivedGenerations() + archived, err := store.ListArchivedGenerations() require.NoError(t, err) assert.Len(t, archived, 1, "one archived generation should exist after rotation") - // Verify /full/current is now a fresh generation - gen, err := store.readGenerationFromRef(plumbing.ReferenceName(paths.V2FullCurrentRefName)) + // Verify /full/current is now a fresh generation (empty tree, no generation.json) + _, freshTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName)) require.NoError(t, err) - assert.Empty(t, gen.Checkpoints, "fresh /full/current should have no checkpoints") + freshCount, err := store.CountCheckpointsInTree(freshTreeHash) + require.NoError(t, err) + assert.Equal(t, 0, freshCount, "fresh /full/current should have no checkpoints") // Verify the archived generation has 3 checkpoints - archiveGen, err := store.readGenerationFromRef(plumbing.ReferenceName(paths.V2FullRefPrefix + archived[0])) + _, archiveTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullRefPrefix + archived[0])) + require.NoError(t, err) + archiveCount, err := store.CountCheckpointsInTree(archiveTreeHash) require.NoError(t, err) - assert.Len(t, archiveGen.Checkpoints, 3) + assert.Equal(t, 3, archiveCount) // Write a 4th checkpoint — should land on the fresh /full/current cpID4 := id.MustCheckpointID("000000000004") @@ -888,16 +892,17 @@ func TestWriteCommitted_TriggersRotationAtThreshold(t *testing.T) { }) require.NoError(t, err) - gen, err = store.readGenerationFromRef(plumbing.ReferenceName(paths.V2FullCurrentRefName)) + _, newTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName)) require.NoError(t, err) - assert.Len(t, gen.Checkpoints, 1, "new checkpoint should be on fresh generation") - assert.Equal(t, cpID4, gen.Checkpoints[0]) + newCount, err := store.CountCheckpointsInTree(newTreeHash) + require.NoError(t, err) + assert.Equal(t, 1, newCount, "new checkpoint should be on fresh generation") } func TestWriteCommitted_NoRotationBelowThreshold(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") store.maxCheckpointsPerGeneration = 5 ctx := context.Background() @@ -917,11 +922,13 @@ func TestWriteCommitted_NoRotationBelowThreshold(t *testing.T) { } // No rotation should have occurred - archived, err := store.listArchivedGenerations() + archived, err := store.ListArchivedGenerations() require.NoError(t, err) assert.Empty(t, archived, "no archived generations should exist below threshold") - gen, err := store.readGenerationFromRef(plumbing.ReferenceName(paths.V2FullCurrentRefName)) + _, noRotTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName)) + require.NoError(t, err) + noRotCount, err := store.CountCheckpointsInTree(noRotTreeHash) require.NoError(t, err) - assert.Len(t, gen.Checkpoints, 3) + assert.Equal(t, 3, noRotCount) } diff --git a/cmd/entire/cli/integration_test/v2_push_test.go b/cmd/entire/cli/integration_test/v2_push_test.go new file mode 100644 index 000000000..f47d8d730 --- /dev/null +++ b/cmd/entire/cli/integration_test/v2_push_test.go @@ -0,0 +1,125 @@ +//go:build integration + +package integration + +import ( + "os/exec" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bareRefExists checks if a ref exists in a bare repo by running git ls-remote. +func bareRefExists(t *testing.T, bareDir, refName string) bool { + t.Helper() + cmd := exec.Command("git", "ls-remote", bareDir, refName) + cmd.Env = testutil.GitIsolatedEnv() + output, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(output)) != "" +} + +func TestV2Push_FullCycle(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.WriteFile(".gitignore", ".entire/\n") + env.GitAdd("README.md") + env.GitAdd(".gitignore") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/v2-push-test") + + // Initialize with both checkpoints_v2 and push_v2_refs enabled + env.InitEntireWithOptions(map[string]any{ + "checkpoints_v2": true, + "push_v2_refs": true, + }) + + bareDir := env.SetupBareRemote() + + // Start session, create file, stop, commit + session := env.NewSession() + err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add feature") + require.NoError(t, err) + + env.WriteFile("feature.go", "package main\n\nfunc Feature() {}") + session.CreateTranscript( + "Add feature", + []FileChange{{Path: "feature.go", Content: "package main\n\nfunc Feature() {}"}}, + ) + err = env.SimulateStop(session.ID, session.TranscriptPath) + require.NoError(t, err) + + env.GitAdd("feature.go") + env.GitCommitWithShadowHooks("Add feature") + + // Run pre-push (which pushes v1 and v2 refs) + env.RunPrePush("origin") + + // Verify v2 refs exist on remote + assert.True(t, bareRefExists(t, bareDir, paths.V2MainRefName), + "v2 /main ref should exist on remote after push") + assert.True(t, bareRefExists(t, bareDir, paths.V2FullCurrentRefName), + "v2 /full/current ref should exist on remote after push") + + // v1 should also be pushed (dual-write) + assert.True(t, bareRefExists(t, bareDir, "refs/heads/"+paths.MetadataBranchName), + "v1 metadata branch should exist on remote after push") +} + +func TestV2Push_Disabled_NoV2Refs(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.WriteFile(".gitignore", ".entire/\n") + env.GitAdd("README.md") + env.GitAdd(".gitignore") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/v2-push-disabled") + + // Enable checkpoints_v2 but NOT push_v2_refs + env.InitEntireWithOptions(map[string]any{ + "checkpoints_v2": true, + }) + + bareDir := env.SetupBareRemote() + + session := env.NewSession() + err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add feature") + require.NoError(t, err) + + env.WriteFile("feature.go", "package main\n\nfunc Feature() {}") + session.CreateTranscript( + "Add feature", + []FileChange{{Path: "feature.go", Content: "package main\n\nfunc Feature() {}"}}, + ) + err = env.SimulateStop(session.ID, session.TranscriptPath) + require.NoError(t, err) + + env.GitAdd("feature.go") + env.GitCommitWithShadowHooks("Add feature") + + env.RunPrePush("origin") + + // v2 refs should NOT be pushed + assert.False(t, bareRefExists(t, bareDir, paths.V2MainRefName), + "v2 /main ref should NOT exist on remote when push_v2_refs is disabled") + assert.False(t, bareRefExists(t, bareDir, paths.V2FullCurrentRefName), + "v2 /full/current ref should NOT exist on remote when push_v2_refs is disabled") + + // v1 should still be pushed + assert.True(t, bareRefExists(t, bareDir, "refs/heads/"+paths.MetadataBranchName), + "v1 metadata branch should still exist on remote") +} diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index fcc6781ce..a1bbf6065 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -907,7 +907,11 @@ func resumeSingleSession(ctx context.Context, w, errW io.Writer, ag agent.Agent, if settings.IsCheckpointsV2Enabled(ctx) { repo, repoErr := openRepository(ctx) if repoErr == nil { - v2Store := checkpoint.NewV2GitStore(repo) + fetchRemote := strategy.ResolveCheckpointURL(ctx, "origin") + if fetchRemote == "" { + fetchRemote = "origin" + } + v2Store := checkpoint.NewV2GitStore(repo, fetchRemote) var v2Err error logContent, _, v2Err = v2Store.GetSessionLog(ctx, checkpointID) if v2Err != nil { diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 49f1895c9..99a695f6d 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -410,6 +410,16 @@ func IsCheckpointsV2Enabled(ctx context.Context) bool { return settings.IsCheckpointsV2Enabled() } +// IsPushV2RefsEnabled checks if pushing v2 refs is enabled in settings. +// Returns false by default if settings cannot be loaded or flags are missing. +func IsPushV2RefsEnabled(ctx context.Context) bool { + s, err := Load(ctx) + if err != nil { + return false + } + return s.IsPushV2RefsEnabled() +} + // IsSummarizeEnabled checks if auto-summarize is enabled in settings. // Returns false by default if settings cannot be loaded or the key is missing. func IsSummarizeEnabled(ctx context.Context) bool { @@ -489,6 +499,19 @@ func (s *EntireSettings) IsCheckpointsV2Enabled() bool { return ok && val } +// IsPushV2RefsEnabled checks if pushing v2 refs is enabled. +// Requires both checkpoints_v2 and push_v2_refs to be true. +func (s *EntireSettings) IsPushV2RefsEnabled() bool { + if !s.IsCheckpointsV2Enabled() { + return false + } + if s.StrategyOptions == nil { + return false + } + val, ok := s.StrategyOptions["push_v2_refs"].(bool) + return ok && val +} + // IsPushSessionsDisabled checks if push_sessions is disabled in settings. // Returns true if push_sessions is explicitly set to false. func (s *EntireSettings) IsPushSessionsDisabled() bool { diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index 76d419be8..bfc52755f 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -559,6 +559,44 @@ func TestIsCheckpointsV2Enabled_LocalOverride(t *testing.T) { } } +func TestIsPushV2RefsEnabled_DefaultsFalse(t *testing.T) { + t.Parallel() + s := &EntireSettings{Enabled: true} + if s.IsPushV2RefsEnabled() { + t.Error("expected IsPushV2RefsEnabled to default to false") + } +} + +func TestIsPushV2RefsEnabled_RequiresBothFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts map[string]any + expected bool + }{ + {"both true", map[string]any{"checkpoints_v2": true, "push_v2_refs": true}, true}, + {"only checkpoints_v2", map[string]any{"checkpoints_v2": true}, false}, + {"only push_v2_refs", map[string]any{"push_v2_refs": true}, false}, + {"both false", map[string]any{"checkpoints_v2": false, "push_v2_refs": false}, false}, + {"push_v2_refs wrong type", map[string]any{"checkpoints_v2": true, "push_v2_refs": "yes"}, false}, + {"empty options", map[string]any{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s := &EntireSettings{ + Enabled: true, + StrategyOptions: tt.opts, + } + if got := s.IsPushV2RefsEnabled(); got != tt.expected { + t.Errorf("IsPushV2RefsEnabled() = %v, want %v", got, tt.expected) + } + }) + } +} + // containsUnknownField checks if the error message indicates an unknown field func containsUnknownField(msg string) bool { // Go's json package reports unknown fields with this message format diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index 6202d4160..3268981e1 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -129,9 +129,57 @@ func resolvePushSettings(ctx context.Context, pushRemoteName string) pushSetting ) } + // Also fetch v2 /main ref if push_v2_refs is enabled + if s.IsPushV2RefsEnabled() { + if err := fetchV2MainRefIfMissing(ctx, checkpointURL); err != nil { + logging.Warn(ctx, "checkpoint-remote: failed to fetch v2 /main ref", + slog.String("error", err.Error()), + ) + } + } + return ps } +// ResolveCheckpointURL returns the checkpoint remote URL if configured, or empty string +// if not configured or derivation fails. Uses the push remote's protocol for URL construction. +func ResolveCheckpointURL(ctx context.Context, pushRemoteName string) string { + s, err := settings.Load(ctx) + if err != nil { + return "" + } + config := s.GetCheckpointRemote() + if config == nil { + return "" + } + pushRemoteURL, err := getRemoteURL(ctx, pushRemoteName) + if err != nil { + logging.Debug(ctx, "checkpoint-remote: could not get push remote URL for v2 resolution", + slog.String("remote", pushRemoteName), + slog.String("error", err.Error()), + ) + return "" + } + url, err := deriveCheckpointURL(pushRemoteURL, config) + if err != nil { + logging.Debug(ctx, "checkpoint-remote: could not derive v2 checkpoint URL", + slog.String("repo", config.Repo), + slog.String("error", err.Error()), + ) + return "" + } + return url +} + +// resolveV2FetchRemote returns the remote to use for v2 fetch operations. +// Returns the checkpoint remote URL if configured, otherwise "origin". +func resolveV2FetchRemote(ctx context.Context) string { + if url := ResolveCheckpointURL(ctx, "origin"); url != "" { + return url + } + return "origin" +} + // ResolveRemoteRepo returns the host, owner, and repo name for the given git remote. // It parses the remote URL (SSH or HTTPS) and extracts the components. // For example, git@github.com:org/my-repo.git returns ("github.com", "org", "my-repo"). @@ -417,3 +465,24 @@ func fetchMetadataBranchIfMissing(ctx context.Context, remoteURL string) error { logging.Info(ctx, "checkpoint-remote: fetched metadata branch from URL") return nil } + +// fetchV2MainRefIfMissing fetches the v2 /main ref from a URL only if it doesn't +// exist locally. Delegates to FetchV2MainFromURL for the actual fetch. +func fetchV2MainRefIfMissing(ctx context.Context, remoteURL string) error { + repo, err := OpenRepository(ctx) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + refName := plumbing.ReferenceName(paths.V2MainRefName) + if _, err := repo.Reference(refName, true); err == nil { + return nil // Ref exists locally, skip fetch + } + + if err := FetchV2MainFromURL(ctx, remoteURL); err != nil { + return nil //nolint:nilerr // Fetch failure is not fatal — ref may not exist on remote yet + } + + logging.Info(ctx, "checkpoint-remote: fetched v2 /main ref from URL") + return nil +} diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 9a7b835ff..089c9abd7 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -71,14 +71,15 @@ func (s *ManualCommitStrategy) getCheckpointStore() (*checkpoint.GitStore, error } // getV2CheckpointStore returns the v2 checkpoint store, initializing it lazily. -func (s *ManualCommitStrategy) getV2CheckpointStore() (*checkpoint.V2GitStore, error) { +// The context from the first call is used for initialization (settings loading, repo opening). +func (s *ManualCommitStrategy) getV2CheckpointStore(ctx context.Context) (*checkpoint.V2GitStore, error) { s.v2CheckpointStoreOnce.Do(func() { - repo, err := OpenRepository(context.Background()) + repo, err := OpenRepository(ctx) if err != nil { s.v2CheckpointStoreErr = fmt.Errorf("failed to open repository: %w", err) return } - s.v2CheckpointStore = checkpoint.NewV2GitStore(repo) + s.v2CheckpointStore = checkpoint.NewV2GitStore(repo, resolveV2FetchRemote(ctx)) }) return s.v2CheckpointStore, s.v2CheckpointStoreErr } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 1f1df3f60..1cc54990f 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -1088,7 +1088,7 @@ func writeCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts c return } - v2Store := cpkg.NewV2GitStore(repo) + v2Store := cpkg.NewV2GitStore(repo, resolveV2FetchRemote(ctx)) if err := v2Store.WriteCommitted(ctx, opts); err != nil { logging.Warn(ctx, "v2 dual-write failed", slog.String("checkpoint_id", opts.CheckpointID.String()), diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index c307bee2f..48ad08efe 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2337,7 +2337,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s // Evaluate v2 flag once before the loop to avoid re-reading settings per checkpoint var v2Store *checkpoint.V2GitStore if settings.IsCheckpointsV2Enabled(logCtx) { - v2Store = checkpoint.NewV2GitStore(repo) + v2Store = checkpoint.NewV2GitStore(repo, resolveV2FetchRemote(logCtx)) } // Update each checkpoint with the full transcript diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index af8fd061a..36c873f2b 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -4,18 +4,21 @@ import ( "context" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/perf" ) // PrePush is called by the git pre-push hook before pushing to a remote. -// It pushes the entire/checkpoints/v1 branch alongside the user's push. +// It pushes the entire/checkpoints/v1 branch alongside the user's push, +// and v2 refs when both checkpoints_v2 and push_v2_refs are enabled. // -// If a checkpoint_remote is configured in settings, the checkpoint branch is -// pushed to the derived URL instead of the user's push remote. +// If a checkpoint_remote is configured in settings, checkpoint branches/refs +// are pushed to the derived URL instead of the user's push remote. // // Configuration options (stored in .entire/settings.json under strategy_options): // - push_sessions: false to disable automatic pushing of checkpoints // - checkpoint_remote: {"provider": "github", "repo": "org/repo"} to push to a separate repo +// - push_v2_refs: true to enable pushing v2 refs (requires checkpoints_v2) func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error { // Load settings once for remote resolution and push_sessions check ps := resolvePushSettings(ctx, remote) @@ -30,5 +33,13 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error pushCheckpointsSpan.RecordError(err) } pushCheckpointsSpan.End() + + // Push v2 refs when both checkpoints_v2 and push_v2_refs are enabled + if settings.IsPushV2RefsEnabled(ctx) { + _, pushV2Span := perf.Start(ctx, "push_v2_refs") + pushV2Refs(ctx, ps.pushTarget()) + pushV2Span.End() + } + return err } diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index d3f250265..38d711738 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -640,7 +640,7 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, w, errW io.W var summary *cpkg.CheckpointSummary if settings.IsCheckpointsV2Enabled(ctx) { - v2Store, v2Err := s.getV2CheckpointStore() + v2Store, v2Err := s.getV2CheckpointStore(ctx) if v2Err == nil { v2Summary, readErr := v2Store.ReadCommitted(ctx, point.CheckpointID) if readErr != nil { diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go new file mode 100644 index 000000000..fcb23ac17 --- /dev/null +++ b/cmd/entire/cli/strategy/push_v2.go @@ -0,0 +1,419 @@ +package strategy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sort" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// pushRefIfNeeded pushes a custom ref to the given target if it exists locally. +// Custom refs (under refs/entire/) don't have remote-tracking refs, so there's +// no "has unpushed" optimization — we always attempt the push and let git handle +// the no-op case. +func pushRefIfNeeded(ctx context.Context, target string, refName plumbing.ReferenceName) error { + repo, err := OpenRepository(ctx) + if err != nil { + return nil //nolint:nilerr // Hook must be silent on failure + } + + if _, err := repo.Reference(refName, true); err != nil { + return nil //nolint:nilerr // Ref doesn't exist locally, nothing to push + } + + return doPushRef(ctx, target, refName) +} + +// tryPushRef attempts to push a custom ref using an explicit refspec. +func tryPushRef(ctx context.Context, target string, refName plumbing.ReferenceName) error { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + // Use --no-verify to prevent recursive hook calls (this runs inside pre-push) + refSpec := fmt.Sprintf("%s:%s", refName, refName) + cmd := CheckpointGitCommand(ctx, target, "push", "--no-verify", target, refSpec) + + output, err := cmd.CombinedOutput() + if err != nil { + if strings.Contains(string(output), "non-fast-forward") || + strings.Contains(string(output), "rejected") { + return errors.New("non-fast-forward") + } + return fmt.Errorf("push failed: %s", output) + } + return nil +} + +// doPushRef pushes a custom ref with fetch+merge recovery on conflict. +func doPushRef(ctx context.Context, target string, refName plumbing.ReferenceName) error { + displayTarget := target + if isURL(target) { + displayTarget = "checkpoint remote" + } + + shortRef := shortRefName(refName) + fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...", shortRef, displayTarget) + stop := startProgressDots(os.Stderr) + + if err := tryPushRef(ctx, target, refName); err == nil { + stop(" done") + return nil + } + stop("") + + fmt.Fprintf(os.Stderr, "[entire] Syncing %s with remote...", shortRef) + stop = startProgressDots(os.Stderr) + + if err := fetchAndMergeRef(ctx, target, refName); err != nil { + stop("") + fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", shortRef, err) + printCheckpointRemoteHint(target) + return nil + } + stop(" done") + + fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...", shortRef, displayTarget) + stop = startProgressDots(os.Stderr) + + if err := tryPushRef(ctx, target, refName); err != nil { + stop("") + fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push %s after sync: %v\n", shortRef, err) + printCheckpointRemoteHint(target) + } else { + stop(" done") + } + + return nil +} + +// fetchAndMergeRef fetches a remote custom ref and merges it into the local ref. +// Uses the same tree-flattening merge as v1 (sharded paths are unique, so no conflicts). +// +// For /full/current: if the remote has archived generations not present locally, +// another machine rotated. In that case, local data is merged into the latest +// archived generation instead of into /full/current (see handleRotationConflict). +func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.ReferenceName) error { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + // Fetch to a temp ref + tmpRefSuffix := strings.ReplaceAll(string(refName), "/", "-") + tmpRefName := plumbing.ReferenceName("refs/entire-fetch-tmp/" + tmpRefSuffix) + refSpec := fmt.Sprintf("+%s:%s", refName, tmpRefName) + + fetchCmd := CheckpointGitCommand(ctx, target, "fetch", target, refSpec) + fetchCmd.Env = append(fetchCmd.Env, "GIT_TERMINAL_PROMPT=0") + if output, err := fetchCmd.CombinedOutput(); err != nil { + return fmt.Errorf("fetch failed: %s", output) + } + + repo, err := OpenRepository(ctx) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + defer func() { + _ = repo.Storer.RemoveReference(tmpRefName) //nolint:errcheck // cleanup is best-effort + }() + + // Check for rotation conflict on /full/current + if refName == plumbing.ReferenceName(paths.V2FullCurrentRefName) { + remoteOnlyArchives, detectErr := detectRemoteOnlyArchives(ctx, target, repo) + if detectErr == nil && len(remoteOnlyArchives) > 0 { + return handleRotationConflict(ctx, target, repo, refName, tmpRefName, remoteOnlyArchives) + } + } + + // Standard tree merge (no rotation detected) + localRef, err := repo.Reference(refName, true) + if err != nil { + return fmt.Errorf("failed to get local ref: %w", err) + } + localCommit, err := repo.CommitObject(localRef.Hash()) + if err != nil { + return fmt.Errorf("failed to get local commit: %w", err) + } + localTree, err := localCommit.Tree() + if err != nil { + return fmt.Errorf("failed to get local tree: %w", err) + } + + remoteRef, err := repo.Reference(tmpRefName, true) + if err != nil { + return fmt.Errorf("failed to get remote ref: %w", err) + } + remoteCommit, err := repo.CommitObject(remoteRef.Hash()) + if err != nil { + return fmt.Errorf("failed to get remote commit: %w", err) + } + remoteTree, err := remoteCommit.Tree() + if err != nil { + return fmt.Errorf("failed to get remote tree: %w", err) + } + + entries := make(map[string]object.TreeEntry) + if err := checkpoint.FlattenTree(repo, localTree, "", entries); err != nil { + return fmt.Errorf("failed to flatten local tree: %w", err) + } + if err := checkpoint.FlattenTree(repo, remoteTree, "", entries); err != nil { + return fmt.Errorf("failed to flatten remote tree: %w", err) + } + + mergedTreeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + if err != nil { + return fmt.Errorf("failed to build merged tree: %w", err) + } + + mergeCommitHash, err := createMergeCommitCommon(repo, mergedTreeHash, + []plumbing.Hash{localRef.Hash(), remoteRef.Hash()}, + "Merge remote "+shortRefName(refName)) + if err != nil { + return fmt.Errorf("failed to create merge commit: %w", err) + } + + newRef := plumbing.NewHashReference(refName, mergeCommitHash) + if err := repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to update ref: %w", err) + } + + return nil +} + +// detectRemoteOnlyArchives discovers archived generation refs on the remote +// that don't exist locally. Returns them sorted ascending (oldest first). +func detectRemoteOnlyArchives(ctx context.Context, target string, repo *git.Repository) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + cmd := CheckpointGitCommand(ctx, target, "ls-remote", target, paths.V2FullRefPrefix+"*") + cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ls-remote failed: %w", err) + } + + var remoteOnly []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + refName := parts[1] + suffix := strings.TrimPrefix(refName, paths.V2FullRefPrefix) + if suffix == "current" || !checkpoint.GenerationRefPattern.MatchString(suffix) { + continue + } + // Only check for existence, not hash equality. A locally-present archive + // could be stale if another machine updated it via rotation conflict recovery, + // but that's unlikely and the checkpoints are still on /main regardless. + if _, err := repo.Reference(plumbing.ReferenceName(refName), true); err != nil { + remoteOnly = append(remoteOnly, suffix) + } + } + + sort.Strings(remoteOnly) + return remoteOnly, nil +} + +// handleRotationConflict handles the case where remote /full/current was rotated. +// Merges local /full/current into the latest remote archived generation to avoid +// duplicating checkpoint data, then adopts remote's /full/current as local. +func handleRotationConflict(ctx context.Context, target string, repo *git.Repository, refName, tmpRefName plumbing.ReferenceName, remoteOnlyArchives []string) error { + // Use the latest remote-only archive + latestArchive := remoteOnlyArchives[len(remoteOnlyArchives)-1] + archiveRefName := plumbing.ReferenceName(paths.V2FullRefPrefix + latestArchive) + + // Fetch the latest archived generation + archiveTmpRef := plumbing.ReferenceName("refs/entire-fetch-tmp/archive-" + latestArchive) + archiveRefSpec := fmt.Sprintf("+%s:%s", archiveRefName, archiveTmpRef) + fetchCmd := CheckpointGitCommand(ctx, target, "fetch", target, archiveRefSpec) + fetchCmd.Env = append(fetchCmd.Env, "GIT_TERMINAL_PROMPT=0") + if output, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil { + return fmt.Errorf("fetch archived generation failed: %s", output) + } + defer func() { + _ = repo.Storer.RemoveReference(archiveTmpRef) //nolint:errcheck // cleanup is best-effort + }() + + // Get archived generation state + archiveRef, err := repo.Reference(archiveTmpRef, true) + if err != nil { + return fmt.Errorf("failed to get archived ref: %w", err) + } + archiveCommit, err := repo.CommitObject(archiveRef.Hash()) + if err != nil { + return fmt.Errorf("failed to get archive commit: %w", err) + } + archiveTree, err := archiveCommit.Tree() + if err != nil { + return fmt.Errorf("failed to get archive tree: %w", err) + } + + // Get local /full/current state + localRef, err := repo.Reference(refName, true) + if err != nil { + return fmt.Errorf("failed to get local ref: %w", err) + } + localCommit, err := repo.CommitObject(localRef.Hash()) + if err != nil { + return fmt.Errorf("failed to get local commit: %w", err) + } + localTree, err := localCommit.Tree() + if err != nil { + return fmt.Errorf("failed to get local tree: %w", err) + } + + // Tree-merge local /full/current into archived generation. + // Git content-addressing deduplicates shared shard paths automatically. + entries := make(map[string]object.TreeEntry) + if err := checkpoint.FlattenTree(repo, archiveTree, "", entries); err != nil { + return fmt.Errorf("failed to flatten archive tree: %w", err) + } + if err := checkpoint.FlattenTree(repo, localTree, "", entries); err != nil { + return fmt.Errorf("failed to flatten local tree: %w", err) + } + + // Update generation.json timestamps if present in the merged tree. + // Use the local /full/current HEAD commit time as the newest checkpoint time + // (more accurate than time.Now() for cleanup scheduling). + if genEntry, exists := entries[paths.GenerationFileName]; exists { + if updatedEntry, updateErr := updateGenerationTimestamps(repo, genEntry.Hash, localCommit.Committer.When.UTC()); updateErr == nil { + entries[paths.GenerationFileName] = updatedEntry + } else { + logging.Warn(ctx, "rotation recovery: failed to update generation timestamps, using stale values", + slog.String("error", updateErr.Error()), + ) + } + } + + mergedTreeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + if err != nil { + return fmt.Errorf("failed to build merged tree: %w", err) + } + + // Create commit parented on archive's commit (fast-forward) + mergeCommitHash, err := createMergeCommitCommon(repo, mergedTreeHash, + []plumbing.Hash{archiveRef.Hash()}, + "Merge local checkpoints into archived generation") + if err != nil { + return fmt.Errorf("failed to create merge commit: %w", err) + } + + // Update local archived ref and push it + newArchiveRef := plumbing.NewHashReference(archiveRefName, mergeCommitHash) + if err := repo.Storer.SetReference(newArchiveRef); err != nil { + return fmt.Errorf("failed to update archive ref: %w", err) + } + + if pushErr := tryPushRef(ctx, target, archiveRefName); pushErr != nil { + return fmt.Errorf("failed to push updated archive: %w", pushErr) + } + + // Adopt remote's /full/current as local + remoteRef, err := repo.Reference(tmpRefName, true) + if err != nil { + return fmt.Errorf("failed to get fetched /full/current: %w", err) + } + adoptedRef := plumbing.NewHashReference(refName, remoteRef.Hash()) + if err := repo.Storer.SetReference(adoptedRef); err != nil { + return fmt.Errorf("failed to adopt remote /full/current: %w", err) + } + + return nil +} + +// updateGenerationTimestamps reads generation.json from a blob, updates +// newest_checkpoint_at if the provided newestFromLocal is newer, and returns +// an updated tree entry. Uses the local commit timestamp rather than +// time.Now() so cleanup scheduling reflects actual checkpoint creation time. +func updateGenerationTimestamps(repo *git.Repository, genBlobHash plumbing.Hash, newestFromLocal time.Time) (object.TreeEntry, error) { + blob, err := repo.BlobObject(genBlobHash) + if err != nil { + return object.TreeEntry{}, fmt.Errorf("failed to read generation blob: %w", err) + } + reader, err := blob.Reader() + if err != nil { + return object.TreeEntry{}, fmt.Errorf("failed to open generation blob reader: %w", err) + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + return object.TreeEntry{}, fmt.Errorf("failed to read generation blob data: %w", err) + } + + var gen checkpoint.GenerationMetadata + if err := json.Unmarshal(data, &gen); err != nil { + return object.TreeEntry{}, fmt.Errorf("failed to parse generation.json: %w", err) + } + + if newestFromLocal.After(gen.NewestCheckpointAt) { + gen.NewestCheckpointAt = newestFromLocal + } + + updatedData, err := jsonutil.MarshalIndentWithNewline(gen, "", " ") + if err != nil { + return object.TreeEntry{}, fmt.Errorf("failed to marshal generation.json: %w", err) + } + + newBlobHash, err := checkpoint.CreateBlobFromContent(repo, updatedData) + if err != nil { + return object.TreeEntry{}, fmt.Errorf("failed to create generation blob: %w", err) + } + + return object.TreeEntry{ + Name: paths.GenerationFileName, + Mode: filemode.Regular, + Hash: newBlobHash, + }, nil +} + +// pushV2Refs pushes v2 checkpoint refs to the target. +// Pushes /main, /full/current, and the latest archived generation (if any). +// Older archived generations are immutable and were pushed when created. +func pushV2Refs(ctx context.Context, target string) { + _ = pushRefIfNeeded(ctx, target, plumbing.ReferenceName(paths.V2MainRefName)) //nolint:errcheck // pushRefIfNeeded handles errors internally + _ = pushRefIfNeeded(ctx, target, plumbing.ReferenceName(paths.V2FullCurrentRefName)) //nolint:errcheck // pushRefIfNeeded handles errors internally + + // Push only the latest archived generation (most likely to be newly created) + repo, err := OpenRepository(ctx) + if err != nil { + return + } + store := checkpoint.NewV2GitStore(repo, resolveV2FetchRemote(ctx)) + archived, err := store.ListArchivedGenerations() + if err != nil || len(archived) == 0 { + return + } + latest := archived[len(archived)-1] + _ = pushRefIfNeeded(ctx, target, plumbing.ReferenceName(paths.V2FullRefPrefix+latest)) //nolint:errcheck // pushRefIfNeeded handles errors internally +} + +// shortRefName returns a human-readable short form of a ref name for log output. +// e.g., "refs/entire/checkpoints/v2/main" -> "v2/main" +func shortRefName(refName plumbing.ReferenceName) string { + const prefix = "refs/entire/checkpoints/" + s := string(refName) + if strings.HasPrefix(s, prefix) { + return s[len(prefix):] + } + return s +} diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go new file mode 100644 index 000000000..a544e75bd --- /dev/null +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -0,0 +1,388 @@ +package strategy + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupRepoWithV2Ref creates a temp repo with one commit and a v2 /main ref. +// Returns the repo directory. +func setupRepoWithV2Ref(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "f.txt", "init") + testutil.GitAdd(t, tmpDir, "f.txt") + testutil.GitCommit(t, tmpDir, "init") + + repo, err := git.PlainOpen(tmpDir) + require.NoError(t, err) + + // Create v2 /main ref with an empty tree + emptyTree, err := checkpoint.BuildTreeFromEntries(repo, map[string]object.TreeEntry{}) + require.NoError(t, err) + + commitHash, err := checkpoint.CreateCommit(repo, emptyTree, plumbing.ZeroHash, + "Init v2 main", "Test", "test@test.com") + require.NoError(t, err) + + ref := plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), commitHash) + require.NoError(t, repo.Storer.SetReference(ref)) + + return tmpDir +} + +// Not parallel: uses t.Chdir() +func TestPushRefIfNeeded_NoLocalRef_ReturnsNil(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "f.txt", "init") + testutil.GitAdd(t, tmpDir, "f.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + + ctx := context.Background() + err := pushRefIfNeeded(ctx, "origin", plumbing.ReferenceName(paths.V2MainRefName)) + assert.NoError(t, err) +} + +// Not parallel: uses t.Chdir() +func TestPushRefIfNeeded_LocalBareRepo_PushesSuccessfully(t *testing.T) { + ctx := context.Background() + + tmpDir := setupRepoWithV2Ref(t) + t.Chdir(tmpDir) + + bareDir := t.TempDir() + initCmd := exec.CommandContext(ctx, "git", "init", "--bare") + initCmd.Dir = bareDir + initCmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, initCmd.Run()) + + err := pushRefIfNeeded(ctx, bareDir, plumbing.ReferenceName(paths.V2MainRefName)) + require.NoError(t, err) + + // Verify ref exists in bare repo + bareRepo, err := git.PlainOpen(bareDir) + require.NoError(t, err) + _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true) + assert.NoError(t, err, "v2 /main ref should exist in bare repo after push") +} + +// Not parallel: uses t.Chdir() +func TestPushRefIfNeeded_UnreachableTarget_ReturnsNil(t *testing.T) { + tmpDir := setupRepoWithV2Ref(t) + t.Chdir(tmpDir) + + ctx := context.Background() + nonExistentPath := filepath.Join(t.TempDir(), "does-not-exist") + err := pushRefIfNeeded(ctx, nonExistentPath, plumbing.ReferenceName(paths.V2MainRefName)) + assert.NoError(t, err, "pushRefIfNeeded should return nil when target is unreachable") +} + +func TestShortRefName(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {"refs/entire/checkpoints/v2/main", "v2/main"}, + {"refs/entire/checkpoints/v2/full/current", "v2/full/current"}, + {"refs/entire/checkpoints/v2/full/0000000000001", "v2/full/0000000000001"}, + {"refs/heads/main", "refs/heads/main"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, shortRefName(plumbing.ReferenceName(tt.input))) + }) + } +} + +// Not parallel: uses t.Chdir() +func TestFetchV2MainRefIfMissing_SkipsWhenExists(t *testing.T) { + tmpDir := setupRepoWithV2Ref(t) + t.Chdir(tmpDir) + + ctx := context.Background() + // Should be a no-op since the ref already exists locally + err := fetchV2MainRefIfMissing(ctx, "https://example.com/repo.git") + assert.NoError(t, err) +} + +// writeV2Checkpoint writes a checkpoint to both /main and /full/current via V2GitStore. +func writeV2Checkpoint(t *testing.T, repo *git.Repository, cpID id.CheckpointID, sessionID string) { + t.Helper() + store := checkpoint.NewV2GitStore(repo, "origin") + err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: sessionID, + Strategy: "manual-commit", + Transcript: []byte(`{"from":"` + sessionID + `"}`), + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) +} + +// TestFetchAndMergeRef_MergesTrees verifies that fetchAndMergeRef correctly +// merges divergent trees from two repos sharing a common ref. +// Not parallel: uses t.Chdir() +func TestFetchAndMergeRef_MergesTrees(t *testing.T) { + ctx := context.Background() + refName := plumbing.ReferenceName(paths.V2MainRefName) + + // Create source repo with a v2 /main ref containing one checkpoint + srcDir := setupRepoWithV2Ref(t) + srcRepo, err := git.PlainOpen(srcDir) + require.NoError(t, err) + writeV2Checkpoint(t, srcRepo, id.MustCheckpointID("aabbccddeeff"), "session-src") + + // Create a bare "remote" and push src to it + bareDir := t.TempDir() + initCmd := exec.CommandContext(ctx, "git", "init", "--bare") + initCmd.Dir = bareDir + initCmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, initCmd.Run()) + + pushCmd := exec.CommandContext(ctx, "git", "push", bareDir, + string(refName)+":"+string(refName)) + pushCmd.Dir = srcDir + require.NoError(t, pushCmd.Run()) + + // Create a local repo that also has the ref but with a different checkpoint + localDir := setupRepoWithV2Ref(t) + localRepo, err := git.PlainOpen(localDir) + require.NoError(t, err) + writeV2Checkpoint(t, localRepo, id.MustCheckpointID("112233445566"), "session-local") + + t.Chdir(localDir) + + // Fetch and merge — should combine both checkpoints + err = fetchAndMergeRef(ctx, bareDir, refName) + require.NoError(t, err) + + // Verify merged tree contains both checkpoints on /main + mergedRepo, err := git.PlainOpen(localDir) + require.NoError(t, err) + ref, err := mergedRepo.Reference(refName, true) + require.NoError(t, err) + commit, err := mergedRepo.CommitObject(ref.Hash()) + require.NoError(t, err) + tree, err := commit.Tree() + require.NoError(t, err) + + entries := make(map[string]object.TreeEntry) + require.NoError(t, checkpoint.FlattenTree(mergedRepo, tree, "", entries)) + + // Should have entries from both checkpoints (aa/ shard and 11/ shard) + hasAA := false + has11 := false + for path := range entries { + if strings.HasPrefix(path, "aa/") { + hasAA = true + } + if strings.HasPrefix(path, "11/") { + has11 = true + } + } + assert.True(t, hasAA, "merged tree should contain checkpoint aabbccddeeff") + assert.True(t, has11, "merged tree should contain checkpoint 112233445566") +} + +// TestPushV2Refs_PushesAllRefs verifies that pushV2Refs pushes /main, +// /full/current, and any archived generations to a bare repo. +// Not parallel: uses t.Chdir() +func TestPushV2Refs_PushesAllRefs(t *testing.T) { + ctx := context.Background() + + tmpDir := setupRepoWithV2Ref(t) + repo, err := git.PlainOpen(tmpDir) + require.NoError(t, err) + + // Write a checkpoint (creates both /main and /full/current) + writeV2Checkpoint(t, repo, id.MustCheckpointID("aabbccddeeff"), "test-session") + + // Create two fake archived generation refs — only the latest should be pushed + fullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) + require.NoError(t, err) + for _, num := range []string{"0000000000001", "0000000000002"} { + ref := plumbing.NewHashReference( + plumbing.ReferenceName(paths.V2FullRefPrefix+num), + fullRef.Hash(), + ) + require.NoError(t, repo.Storer.SetReference(ref)) + } + + t.Chdir(tmpDir) + + bareDir := t.TempDir() + initCmd := exec.CommandContext(ctx, "git", "init", "--bare") + initCmd.Dir = bareDir + initCmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, initCmd.Run()) + + pushV2Refs(ctx, bareDir) + + // Verify all three refs exist in bare repo + bareRepo, err := git.PlainOpen(bareDir) + require.NoError(t, err) + + _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true) + require.NoError(t, err, "/main ref should exist in bare repo") + + _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) + require.NoError(t, err, "/full/current ref should exist in bare repo") + + _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000002"), true) + require.NoError(t, err, "latest archived generation should exist in bare repo") + + _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000001"), true) + assert.Error(t, err, "older archived generation should NOT be pushed") +} + +// TestFetchAndMergeRef_RotationConflict verifies that when /full/current push +// fails because the remote was rotated, local data is merged into the latest +// archived generation and remote's /full/current is adopted locally. +// Not parallel: uses t.Chdir() +func TestFetchAndMergeRef_RotationConflict(t *testing.T) { + ctx := context.Background() + fullCurrentRef := plumbing.ReferenceName(paths.V2FullCurrentRefName) + + // Create bare "remote" + bareDir := t.TempDir() + initCmd := exec.CommandContext(ctx, "git", "init", "--bare") + initCmd.Dir = bareDir + initCmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, initCmd.Run()) + + // Create local repo with a shared checkpoint on /full/current + localDir := t.TempDir() + testutil.InitRepo(t, localDir) + testutil.WriteFile(t, localDir, "f.txt", "init") + testutil.GitAdd(t, localDir, "f.txt") + testutil.GitCommit(t, localDir, "init") + + localRepo, err := git.PlainOpen(localDir) + require.NoError(t, err) + writeV2Checkpoint(t, localRepo, id.MustCheckpointID("aabbccddeeff"), "shared-session") + + // Push initial state to bare + pushCmd := exec.CommandContext(ctx, "git", "push", bareDir, + string(fullCurrentRef)+":"+string(fullCurrentRef)) + pushCmd.Dir = localDir + require.NoError(t, pushCmd.Run()) + + // Simulate remote rotation: create a second repo, fetch, add checkpoint, rotate, push + remoteDir := t.TempDir() + testutil.InitRepo(t, remoteDir) + testutil.WriteFile(t, remoteDir, "f.txt", "init") + testutil.GitAdd(t, remoteDir, "f.txt") + testutil.GitCommit(t, remoteDir, "init") + + fetchCmd := exec.CommandContext(ctx, "git", "fetch", bareDir, + "+"+string(fullCurrentRef)+":"+string(fullCurrentRef)) + fetchCmd.Dir = remoteDir + require.NoError(t, fetchCmd.Run()) + + remoteRepo, err := git.PlainOpen(remoteDir) + require.NoError(t, err) + writeV2Checkpoint(t, remoteRepo, id.MustCheckpointID("112233445566"), "remote-session") + + // Manually rotate: archive /full/current, create fresh orphan + remoteStore := checkpoint.NewV2GitStore(remoteRepo, "origin") + currentRef, err := remoteRepo.Reference(fullCurrentRef, true) + require.NoError(t, err) + + // Write generation.json and archive + _, currentTreeHash, err := remoteStore.GetRefState(fullCurrentRef) + require.NoError(t, err) + gen := checkpoint.GenerationMetadata{ + OldestCheckpointAt: time.Now().UTC().Add(-time.Hour), + NewestCheckpointAt: time.Now().UTC(), + } + archiveTreeHash, err := remoteStore.AddGenerationJSONToTree(currentTreeHash, gen) + require.NoError(t, err) + archiveCommitHash, err := checkpoint.CreateCommit(remoteRepo, archiveTreeHash, + currentRef.Hash(), "Archive", "Test", "test@test.com") + require.NoError(t, err) + + archiveRefName := plumbing.ReferenceName(paths.V2FullRefPrefix + "0000000000001") + require.NoError(t, remoteRepo.Storer.SetReference( + plumbing.NewHashReference(archiveRefName, archiveCommitHash))) + + // Create fresh orphan /full/current + emptyTree, err := checkpoint.BuildTreeFromEntries(remoteRepo, map[string]object.TreeEntry{}) + require.NoError(t, err) + orphanHash, err := checkpoint.CreateCommit(remoteRepo, emptyTree, plumbing.ZeroHash, + "Start generation", "Test", "test@test.com") + require.NoError(t, err) + require.NoError(t, remoteRepo.Storer.SetReference( + plumbing.NewHashReference(fullCurrentRef, orphanHash))) + + // Push rotated state to bare (force /full/current since it's now an orphan) + pushRotated := exec.CommandContext(ctx, "git", "push", "--force", bareDir, + string(fullCurrentRef)+":"+string(fullCurrentRef), + string(archiveRefName)+":"+string(archiveRefName)) + pushRotated.Dir = remoteDir + out, pushErr := pushRotated.CombinedOutput() + require.NoError(t, pushErr, "push rotated state failed: %s", out) + + // Add a local-only checkpoint + writeV2Checkpoint(t, localRepo, id.MustCheckpointID("ffeeddccbbaa"), "local-session") + + t.Chdir(localDir) + + // fetchAndMergeRef should detect rotation and merge into the archive + err = fetchAndMergeRef(ctx, bareDir, fullCurrentRef) + require.NoError(t, err) + + // Verify: local /full/current should now be the fresh orphan from remote + localRepo, err = git.PlainOpen(localDir) + require.NoError(t, err) + localStore := checkpoint.NewV2GitStore(localRepo, "origin") + _, freshTreeHash, err := localStore.GetRefState(fullCurrentRef) + require.NoError(t, err) + freshCount, err := localStore.CountCheckpointsInTree(freshTreeHash) + require.NoError(t, err) + assert.Equal(t, 0, freshCount, "local /full/current should be fresh orphan after rotation recovery") + + // Verify: archived generation should exist locally and contain the local-only checkpoint + archiveRef, err := localRepo.Reference(archiveRefName, true) + require.NoError(t, err) + archiveCommit, err := localRepo.CommitObject(archiveRef.Hash()) + require.NoError(t, err) + archiveTree, err := archiveCommit.Tree() + require.NoError(t, err) + + // Check that the local-only checkpoint (ffeeddccbbaa) is in the archive + _, err = archiveTree.Tree("ff/eeddccbbaa") + require.NoError(t, err, "archived generation should contain local-only checkpoint ffeeddccbbaa") + + // Check that the shared checkpoint (aabbccddeeff) is also there + _, err = archiveTree.Tree("aa/bbccddeeff") + require.NoError(t, err, "archived generation should contain shared checkpoint aabbccddeeff") + + // Check that the remote checkpoint (112233445566) is also there + _, err = archiveTree.Tree("11/2233445566") + assert.NoError(t, err, "archived generation should contain remote checkpoint 112233445566") +}