From e71bfdbc28d7cb40704ffa35211983046c627b60 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 30 Mar 2026 14:28:29 -0700 Subject: [PATCH 01/27] feat: Introduce IsPushV2RefsEnabled() Entire-Checkpoint: ae2c70521c60 --- cmd/entire/cli/settings/settings.go | 13 ++++++++ cmd/entire/cli/settings/settings_test.go | 38 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 49f1895c9..28d9db61f 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -489,6 +489,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 From 0d793fc5ee21f5bfac855b1df84fcac665770640 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 30 Mar 2026 14:48:18 -0700 Subject: [PATCH 02/27] refactor: simplify GenerationMetadata to timestamps only, write at archive time generation.json is no longer written to /full/current on every write. This keeps /full/current free of root-level files, ensuring conflict-free tree merges during push recovery. Rotation threshold is now checked by counting shard directories in the tree. - Remove Checkpoints field from GenerationMetadata (timestamps only) - Replace updateGenerationForWrite with CountCheckpointsInTree - Write generation.json only during rotateGeneration (archive time) - Fresh /full/current orphan has empty tree (no generation.json) - Export GetRefState, ListArchivedGenerations, AddGenerationJSONToTree Entire-Checkpoint: 76722c974e37 --- cmd/entire/cli/checkpoint/v2_committed.go | 32 +- cmd/entire/cli/checkpoint/v2_generation.go | 174 +++++++---- .../cli/checkpoint/v2_generation_test.go | 277 +++++++++--------- cmd/entire/cli/checkpoint/v2_read.go | 8 +- cmd/entire/cli/checkpoint/v2_read_test.go | 4 +- cmd/entire/cli/checkpoint/v2_store.go | 4 +- cmd/entire/cli/checkpoint/v2_store_test.go | 37 ++- 7 files changed, 296 insertions(+), 240 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index d5a7a4f34..40be5a6cd 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 } @@ -155,7 +155,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 } @@ -207,7 +207,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 } @@ -392,7 +392,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 } @@ -429,29 +429,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..85107f93a 100644 --- a/cmd/entire/cli/checkpoint/v2_generation.go +++ b/cmd/entire/cli/checkpoint/v2_generation.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -27,24 +26,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 +75,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 +112,41 @@ 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 + count := 0 + for _, entry := range tree.Entries { + if entry.Mode != filemode.Dir { + continue // skip root-level files (e.g., generation.json on archived gens) + } + subtree, err := s.repo.TreeObject(entry.Hash) + if err != nil { + continue + } + for _, subEntry := range subtree.Entries { + if subEntry.Mode == filemode.Dir { + count++ + } } - gen.NewestCheckpointAt = now } - 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,6 +156,50 @@ 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(_ context.Context, _ plumbing.Hash) (GenerationMetadata, error) { + refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) + ref, err := s.repo.Reference(refName, true) + if err != nil { + now := time.Now().UTC() + return GenerationMetadata{OldestCheckpointAt: now, NewestCheckpointAt: now}, nil + } + + // Walk commit history to find oldest and newest commit times + commit, err := s.repo.CommitObject(ref.Hash()) + if err != nil { + now := time.Now().UTC() + return GenerationMetadata{OldestCheckpointAt: now, NewestCheckpointAt: now}, nil + } + + newest := commit.Committer.When.UTC() + oldest := newest + + // Walk parents to find the oldest commit in this generation + iter := commit + for { + if len(iter.ParentHashes) == 0 { + oldest = iter.Committer.When.UTC() + break + } + parent, parentErr := s.repo.CommitObject(iter.ParentHashes[0]) + if parentErr != nil { + oldest = iter.Committer.When.UTC() + break + } + iter = parent + } + + return GenerationMetadata{ + OldestCheckpointAt: oldest, + NewestCheckpointAt: newest, + }, nil +} + // generationRefWidth is the zero-padded width of archived generation ref names. const generationRefWidth = 13 @@ -176,7 +208,7 @@ 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) @@ -206,7 +238,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 +266,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 +316,41 @@ func (s *V2GitStore) rotateGeneration(ctx context.Context) error { return nil } - // Phase 2: Create fresh orphan /full/current - seedGen := GenerationMetadata{ - Checkpoints: []id.CheckpointID{}, - } - 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) + // Write generation.json to the current tree before archiving. + gen, genErr := s.computeGenerationTimestamps(ctx, currentTreeHash) + if genErr != nil { + logging.Warn(ctx, "rotation: failed to compute generation timestamps", + slog.String("error", genErr.Error()), + ) + gen = GenerationMetadata{ + OldestCheckpointAt: time.Now().UTC(), + NewestCheckpointAt: time.Now().UTC(), + } } - seedTreeHash, err := BuildTreeFromEntries(s.repo, seedEntries) + archiveTreeHash, err := s.AddGenerationJSONToTree(currentTreeHash, gen) if err != nil { - return fmt.Errorf("rotation: failed to build seed tree: %w", err) + return fmt.Errorf("rotation: failed to add generation.json: %w", err) } authorName, authorEmail := GetGitAuthorFromRepo(s.repo) - orphanCommitHash, err := CreateCommit(s.repo, seedTreeHash, plumbing.ZeroHash, "Start generation", authorName, authorEmail) + 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) + } + + // 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 empty tree: %w", err) + } + + 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..76d467f0a 100644 --- a/cmd/entire/cli/checkpoint/v2_generation_test.go +++ b/cmd/entire/cli/checkpoint/v2_generation_test.go @@ -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()) } @@ -41,7 +40,6 @@ func TestReadGeneration_ParsesJSON(t *testing.T) { 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)) } @@ -69,7 +66,6 @@ func TestWriteGeneration_RoundTrips(t *testing.T) { 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,7 +84,8 @@ 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) { @@ -99,7 +96,6 @@ func TestReadGenerationFromRef(t *testing.T) { // 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,10 +115,11 @@ 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) @@ -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,78 +161,82 @@ 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() +func TestCountCheckpointsInTree_EmptyTree(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) store := NewV2GitStore(repo) - gen, err := store.readGenerationFromRef(plumbing.ReferenceName(paths.V2FullCurrentRefName)) + + 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) 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) 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) @@ -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,72 +267,16 @@ 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. @@ -342,8 +285,10 @@ func createArchivedRef(t *testing.T, repo *git.Repository, number int) { store := NewV2GitStore(repo) // 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)) @@ -363,7 +308,7 @@ func TestListArchivedGenerations_Empty(t *testing.T) { repo := initTestRepo(t) store := NewV2GitStore(repo) - archived, err := store.listArchivedGenerations() + archived, err := store.ListArchivedGenerations() require.NoError(t, err) assert.Empty(t, archived) } @@ -376,7 +321,7 @@ func TestListArchivedGenerations_FindsArchived(t *testing.T) { 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) } @@ -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) } @@ -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,16 +430,10 @@ 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) { @@ -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) + + // 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 b996cfdbc..2b2c1feca 100644 --- a/cmd/entire/cli/checkpoint/v2_read.go +++ b/cmd/entire/cli/checkpoint/v2_read.go @@ -26,7 +26,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 } @@ -71,7 +71,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 } @@ -138,7 +138,7 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI return transcript, nil } - archived, err := s.listArchivedGenerations() + archived, err := s.ListArchivedGenerations() if err != nil { return nil, err } @@ -159,7 +159,7 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI // 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..e51c79f52 100644 --- a/cmd/entire/cli/checkpoint/v2_read_test.go +++ b/cmd/entire/cli/checkpoint/v2_read_test.go @@ -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_store.go b/cmd/entire/cli/checkpoint/v2_store.go index c965f652b..76f999c46 100644 --- a/cmd/entire/cli/checkpoint/v2_store.go +++ b/cmd/entire/cli/checkpoint/v2_store.go @@ -69,8 +69,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 63fa50ab7..a29fd7361 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -120,7 +120,7 @@ func TestV2GitStore_GetRefState_ReturnsParentAndTree(t *testing.T) { 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 @@ -133,7 +133,7 @@ func TestV2GitStore_GetRefState_ErrorsOnMissingRef(t *testing.T) { store := NewV2GitStore(repo) refName := plumbing.ReferenceName("refs/entire/nonexistent") - _, _, err := store.getRefState(refName) + _, _, err := store.GetRefState(refName) require.Error(t, err) } @@ -145,7 +145,7 @@ func TestV2GitStore_UpdateRef_CreatesCommit(t *testing.T) { 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 @@ -740,19 +740,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") @@ -767,10 +771,11 @@ 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) { @@ -796,11 +801,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) } From 4ebd0b4a029dec7fe1fd0ceac0b057e51a3606a8 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 30 Mar 2026 15:38:24 -0700 Subject: [PATCH 03/27] refactor: extract WalkCheckpointShards helper for shard iteration Reusable helper that walks the // shard structure, replacing hand-rolled two-level loops in CountCheckpointsInTree and ListCommitted. Entire-Checkpoint: efb9c422b636 --- cmd/entire/cli/checkpoint/committed.go | 89 ++++++++-------------- cmd/entire/cli/checkpoint/parse_tree.go | 37 +++++++++ cmd/entire/cli/checkpoint/v2_generation.go | 19 ++--- 3 files changed, 74 insertions(+), 71 deletions(-) diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 47a75beb8..bcee23252 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -886,65 +886,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 { + checkpointTree, cpTreeErr := s.repo.TreeObject(cpTreeHash) + if cpTreeErr != nil { + return nil // skip unreadable entries } - 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 } } } @@ -952,10 +924,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_generation.go b/cmd/entire/cli/checkpoint/v2_generation.go index 85107f93a..ecb33c86b 100644 --- a/cmd/entire/cli/checkpoint/v2_generation.go +++ b/cmd/entire/cli/checkpoint/v2_generation.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -126,19 +127,11 @@ func (s *V2GitStore) CountCheckpointsInTree(treeHash plumbing.Hash) (int, error) } count := 0 - for _, entry := range tree.Entries { - if entry.Mode != filemode.Dir { - continue // skip root-level files (e.g., generation.json on archived gens) - } - subtree, err := s.repo.TreeObject(entry.Hash) - if err != nil { - continue - } - for _, subEntry := range subtree.Entries { - if subEntry.Mode == filemode.Dir { - count++ - } - } + if err := WalkCheckpointShards(s.repo, tree, func(_ id.CheckpointID, _ plumbing.Hash) error { + count++ + return nil + }); err != nil { + return 0, err } return count, nil From 8fa80cfb880baf34990d15e92a8c643dad36198f Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 30 Mar 2026 16:43:31 -0700 Subject: [PATCH 04/27] feat: add tryPushRef and pushRefIfNeeded for v2 custom refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref-aware push functions that use explicit refspecs for refs under refs/entire/. No remote-tracking ref optimization — always attempts the push and lets git handle the no-op case. fetchAndMergeRef is stubbed for now (implemented next). Entire-Checkpoint: 191b34cb4fe7 --- cmd/entire/cli/strategy/push_v2.go | 110 ++++++++++++++++++++++ cmd/entire/cli/strategy/push_v2_test.go | 116 ++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 cmd/entire/cli/strategy/push_v2.go create mode 100644 cmd/entire/cli/strategy/push_v2_test.go diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go new file mode 100644 index 000000000..9706d5505 --- /dev/null +++ b/cmd/entire/cli/strategy/push_v2.go @@ -0,0 +1,110 @@ +package strategy + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/go-git/go-git/v6/plumbing" +) + +// 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 := exec.CommandContext(ctx, "git", "push", "--no-verify", target, refSpec) + cmd.Stdin = nil // Disconnect stdin to prevent hanging in hook context + + 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. +// Placeholder — implemented in Task 4. +func fetchAndMergeRef(_ context.Context, _ string, _ plumbing.ReferenceName) error { + return fmt.Errorf("fetchAndMergeRef not yet implemented") +} + +// 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..a516c65e3 --- /dev/null +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -0,0 +1,116 @@ +package strategy + +import ( + "context" + "os/exec" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "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)) + assert.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))) + }) + } +} From 92c4f7b335956112da31dac330f2e90eb36dc6a3 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 30 Mar 2026 16:53:20 -0700 Subject: [PATCH 05/27] feat: implement fetchAndMergeRef for v2 custom ref merge recovery Tree-flattening merge for custom refs under refs/entire/. Uses temp refs for fetching and the same sharded-path merge strategy as v1. Entire-Checkpoint: bd33aa361f7e --- cmd/entire/cli/strategy/push_v2.go | 88 ++++++++++++++++++++++++- cmd/entire/cli/strategy/push_v2_test.go | 82 +++++++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index 9706d5505..7d8498d46 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -9,7 +9,10 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" ) // pushRefIfNeeded pushes a custom ref to the given target if it exists locally. @@ -93,9 +96,88 @@ func doPushRef(ctx context.Context, target string, refName plumbing.ReferenceNam } // fetchAndMergeRef fetches a remote custom ref and merges it into the local ref. -// Placeholder — implemented in Task 4. -func fetchAndMergeRef(_ context.Context, _ string, _ plumbing.ReferenceName) error { - return fmt.Errorf("fetchAndMergeRef not yet implemented") +// Uses the same tree-flattening merge as v1 (sharded paths are unique, so no conflicts). +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 := exec.CommandContext(ctx, "git", "fetch", target, refSpec) + fetchCmd.Stdin = nil + 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) + } + + // Get local ref 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) + } + + // Get fetched remote state + 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) + } + + // Flatten both trees and combine entries + 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) + } + + // Build merged tree + mergedTreeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + if err != nil { + return fmt.Errorf("failed to build merged tree: %w", err) + } + + // Create merge commit + 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) + } + + // Update local ref + newRef := plumbing.NewHashReference(refName, mergeCommitHash) + if err := repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to update ref: %w", err) + } + + // Clean up temp ref (best-effort) + _ = repo.Storer.RemoveReference(tmpRefName) //nolint:errcheck // cleanup is best-effort + + return nil } // shortRefName returns a human-readable short form of a ref name for log output. diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go index a516c65e3..f828ae5c9 100644 --- a/cmd/entire/cli/strategy/push_v2_test.go +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -4,9 +4,11 @@ import ( "context" "os/exec" "path/filepath" + "strings" "testing" "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" @@ -114,3 +116,83 @@ func TestShortRefName(t *testing.T) { }) } } + +// 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) + 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") +} From 95c79fe1739235f94e6d86f91dcb74d0348cbea8 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 30 Mar 2026 18:11:44 -0700 Subject: [PATCH 06/27] feat: integrate v2 push into PrePush hook pushV2Refs pushes /main, /full/current, and the latest archived generation. Gated by IsPushV2RefsEnabled (requires both checkpoints_v2 and push_v2_refs settings). Older archived generations are immutable and were pushed when created. Entire-Checkpoint: fc4cb56be142 --- cmd/entire/cli/settings/settings.go | 10 ++++ cmd/entire/cli/strategy/manual_commit_push.go | 17 +++++-- cmd/entire/cli/strategy/push_v2.go | 22 ++++++++ cmd/entire/cli/strategy/push_v2_test.go | 51 +++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 28d9db61f..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 { 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/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index 7d8498d46..c401dc096 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -10,6 +10,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" @@ -180,6 +181,27 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return 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)) + pushRefIfNeeded(ctx, target, plumbing.ReferenceName(paths.V2FullCurrentRefName)) + + // Push only the latest archived generation (most likely to be newly created) + repo, err := OpenRepository(ctx) + if err != nil { + return + } + store := checkpoint.NewV2GitStore(repo) + archived, err := store.ListArchivedGenerations() + if err != nil || len(archived) == 0 { + return + } + latest := archived[len(archived)-1] + pushRefIfNeeded(ctx, target, plumbing.ReferenceName(paths.V2FullRefPrefix+latest)) +} + // 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 { diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go index f828ae5c9..fdc7d6e39 100644 --- a/cmd/entire/cli/strategy/push_v2_test.go +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -196,3 +196,54 @@ func TestFetchAndMergeRef_MergesTrees(t *testing.T) { 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) + assert.NoError(t, err, "/main ref should exist in bare repo") + + _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) + assert.NoError(t, err, "/full/current ref should exist in bare repo") + + _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000002"), true) + assert.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") +} From d4773c3a3f8fed8d76d4a2e22833f73c999d76e3 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 10:52:27 -0700 Subject: [PATCH 07/27] feat: rotation conflict recovery for v2 /full/current push When remote /full/current was rotated by another machine, merges local checkpoints into the latest archived generation instead of creating duplicates. Git content-addressing deduplicates shared checkpoint data. Uses local commit timestamps for generation.json (not time.Now()) so cleanup scheduling reflects actual checkpoint creation time. Entire-Checkpoint: 6dc060481ed1 --- cmd/entire/cli/strategy/push_v2.go | 217 +++++++++++++++++++++++- cmd/entire/cli/strategy/push_v2_test.go | 128 ++++++++++++++ 2 files changed, 337 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index c401dc096..9be17320a 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -2,17 +2,23 @@ package strategy import ( "context" + "encoding/json" "errors" "fmt" + "io" "os" "os/exec" + "regexp" + "sort" "strings" "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "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" ) @@ -98,6 +104,10 @@ func doPushRef(ctx context.Context, target string, refName plumbing.ReferenceNam // 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() @@ -108,7 +118,6 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer refSpec := fmt.Sprintf("+%s:%s", refName, tmpRefName) fetchCmd := exec.CommandContext(ctx, "git", "fetch", target, refSpec) - fetchCmd.Stdin = nil if output, err := fetchCmd.CombinedOutput(); err != nil { return fmt.Errorf("fetch failed: %s", output) } @@ -118,7 +127,17 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return fmt.Errorf("failed to open repository: %w", err) } - // Get local ref state + // Check for rotation conflict on /full/current + if string(refName) == paths.V2FullCurrentRefName { + remoteOnlyArchives, detectErr := detectRemoteOnlyArchives(ctx, target, repo) + if detectErr == nil && len(remoteOnlyArchives) > 0 { + err := handleRotationConflict(ctx, target, repo, refName, tmpRefName, remoteOnlyArchives) + _ = repo.Storer.RemoveReference(tmpRefName) //nolint:errcheck + return err + } + } + + // 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) @@ -132,7 +151,6 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return fmt.Errorf("failed to get local tree: %w", err) } - // Get fetched remote state remoteRef, err := repo.Reference(tmpRefName, true) if err != nil { return fmt.Errorf("failed to get remote ref: %w", err) @@ -146,7 +164,6 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return fmt.Errorf("failed to get remote tree: %w", err) } - // Flatten both trees and combine entries 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) @@ -155,13 +172,11 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return fmt.Errorf("failed to flatten remote tree: %w", err) } - // Build merged tree mergedTreeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) if err != nil { return fmt.Errorf("failed to build merged tree: %w", err) } - // Create merge commit mergeCommitHash, err := createMergeCommitCommon(repo, mergedTreeHash, []plumbing.Hash{localRef.Hash(), remoteRef.Hash()}, "Merge remote "+shortRefName(refName)) @@ -169,18 +184,204 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return fmt.Errorf("failed to create merge commit: %w", err) } - // Update local ref newRef := plumbing.NewHashReference(refName, mergeCommitHash) if err := repo.Storer.SetReference(newRef); err != nil { return fmt.Errorf("failed to update ref: %w", err) } - // Clean up temp ref (best-effort) _ = repo.Storer.RemoveReference(tmpRefName) //nolint:errcheck // cleanup is best-effort + return nil +} + +// generationRefPattern matches the 13-digit archived generation ref suffix format. +var generationRefPattern = regexp.MustCompile(`^\d{13}$`) + +// 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 := exec.CommandContext(ctx, "git", "ls-remote", target, paths.V2FullRefPrefix+"*") + 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" || !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 := exec.CommandContext(ctx, "git", "fetch", target, archiveRefSpec) + if output, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil { + return fmt.Errorf("fetch archived generation failed: %s", output) + } + defer func() { + _ = repo.Storer.RemoveReference(archiveTmpRef) //nolint:errcheck + }() + + // 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 + } + } + + 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{}, err + } + reader, err := blob.Reader() + if err != nil { + return object.TreeEntry{}, err + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + return object.TreeEntry{}, err + } + + var gen checkpoint.GenerationMetadata + if err := json.Unmarshal(data, &gen); err != nil { + return object.TreeEntry{}, err + } + + if newestFromLocal.After(gen.NewestCheckpointAt) { + gen.NewestCheckpointAt = newestFromLocal + } + + updatedData, err := json.Marshal(gen) + if err != nil { + return object.TreeEntry{}, err + } + + newBlobHash, err := checkpoint.CreateBlobFromContent(repo, updatedData) + if err != nil { + return object.TreeEntry{}, 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. diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go index fdc7d6e39..33904d2fb 100644 --- a/cmd/entire/cli/strategy/push_v2_test.go +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" @@ -247,3 +248,130 @@ func TestPushV2Refs_PushesAllRefs(t *testing.T) { _, 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) + 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) + _, 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") + assert.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") + assert.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") +} From 186271df699e8188577d1a82517e054e6c825f1a Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 13:25:57 -0700 Subject: [PATCH 08/27] feat: fetch v2 /main ref from checkpoint remote when missing locally Extends resolvePushSettings to also fetch the v2 /main ref from the checkpoint remote URL when push_v2_refs is enabled. Same one-time fetch pattern as the v1 metadata branch. Entire-Checkpoint: 7c81d2786447 --- cmd/entire/cli/strategy/checkpoint_remote.go | 52 ++++++++++++++++++++ cmd/entire/cli/strategy/push_v2_test.go | 11 +++++ 2 files changed, 63 insertions(+) diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index c2837b4f8..c963527ed 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -129,6 +129,15 @@ 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 } @@ -336,3 +345,46 @@ 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. Same pattern as fetchMetadataBranchIfMissing but for custom refs +// under refs/entire/ (uses explicit refspec instead of refs/heads/). +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 + } + + fetchCtx, cancel := context.WithTimeout(ctx, checkpointRemoteFetchTimeout) + defer cancel() + + tmpRef := "refs/entire-fetch-tmp/v2-main" + refSpec := fmt.Sprintf("+%s:%s", paths.V2MainRefName, tmpRef) + fetchCmd := exec.CommandContext(fetchCtx, "git", "fetch", "--no-tags", remoteURL, refSpec) + fetchCmd.Env = append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + ) + if err := fetchCmd.Run(); err != nil { + return nil //nolint:nilerr // Fetch failure is not fatal + } + + fetchedRef, err := repo.Reference(plumbing.ReferenceName(tmpRef), true) + if err != nil { + return nil //nolint:nilerr // Ref not found after fetch + } + + newRef := plumbing.NewHashReference(refName, fetchedRef.Hash()) + if err := repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to create local ref from fetched: %w", err) + } + + _ = repo.Storer.RemoveReference(plumbing.ReferenceName(tmpRef)) //nolint:errcheck // cleanup is best-effort + + logging.Info(ctx, "checkpoint-remote: fetched v2 /main ref from URL") + return nil +} diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go index 33904d2fb..d2ff1bf19 100644 --- a/cmd/entire/cli/strategy/push_v2_test.go +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -118,6 +118,17 @@ func TestShortRefName(t *testing.T) { } } +// 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() From c0505b2efb751b055e7c12e33916cbb4a822ffc8 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 13:55:30 -0700 Subject: [PATCH 09/27] feat: fetch-on-demand for remote /full/* refs in entire resume When a transcript isn't found locally, readTranscriptFromFullRefs now discovers and fetches remote /full/* refs from origin before giving up. Only newly fetched refs are searched on the second pass. Entire-Checkpoint: 6b193c4a3527 --- cmd/entire/cli/checkpoint/v2_read.go | 81 ++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/cmd/entire/cli/checkpoint/v2_read.go b/cmd/entire/cli/checkpoint/v2_read.go index 2b2c1feca..b823ce795 100644 --- a/cmd/entire/cli/checkpoint/v2_read.go +++ b/cmd/entire/cli/checkpoint/v2_read.go @@ -5,8 +5,10 @@ import ( "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" @@ -126,6 +128,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 @@ -133,6 +136,7 @@ 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 @@ -150,9 +154,86 @@ 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 + } + 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 origin 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", "origin", 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", "origin"}, 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. From 5f79093d33edab3b30f5340a8389f71406c23b08 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 14:00:48 -0700 Subject: [PATCH 10/27] test: add integration tests for v2 push cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that PrePush pushes v2 refs to remote when push_v2_refs is enabled, and skips them when disabled. Both tests use the full CLI hook path (SimulateUserPromptSubmit → Stop → Commit → RunPrePush). Entire-Checkpoint: 199d3876a229 --- .../cli/integration_test/v2_push_test.go | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 cmd/entire/cli/integration_test/v2_push_test.go 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") +} From 0dbbf7d3476c51d061ff6bfbe98cdea5cfb25e3c Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 14:10:19 -0700 Subject: [PATCH 11/27] fix: resolve lint warnings in v2 push logic - Wrap errors from external packages (wrapcheck) - Add nolint explanations (nolintlint) - Suppress unchecked pushRefIfNeeded returns (errcheck) - Simplify computeGenerationTimestamps: remove unused error return (unparam, nilerr) - Fix wasted assignment in commit walk loop (wastedassign) - Fix gofmt alignment Entire-Checkpoint: 14886a520190 --- cmd/entire/cli/checkpoint/v2_generation.go | 34 ++++++-------------- cmd/entire/cli/checkpoint/v2_read.go | 2 +- cmd/entire/cli/strategy/checkpoint_remote.go | 4 +-- cmd/entire/cli/strategy/push_v2.go | 22 ++++++------- 4 files changed, 24 insertions(+), 38 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_generation.go b/cmd/entire/cli/checkpoint/v2_generation.go index ecb33c86b..8cafa719d 100644 --- a/cmd/entire/cli/checkpoint/v2_generation.go +++ b/cmd/entire/cli/checkpoint/v2_generation.go @@ -154,43 +154,38 @@ func (s *V2GitStore) AddGenerationJSONToTree(rootTreeHash plumbing.Hash, gen Gen // 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(_ context.Context, _ plumbing.Hash) (GenerationMetadata, error) { +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 { - now := time.Now().UTC() - return GenerationMetadata{OldestCheckpointAt: now, NewestCheckpointAt: now}, nil + return fallback } - // Walk commit history to find oldest and newest commit times commit, err := s.repo.CommitObject(ref.Hash()) if err != nil { - now := time.Now().UTC() - return GenerationMetadata{OldestCheckpointAt: now, NewestCheckpointAt: now}, nil + return fallback } newest := commit.Committer.When.UTC() - oldest := newest // Walk parents to find the oldest commit in this generation iter := commit - for { - if len(iter.ParentHashes) == 0 { - oldest = iter.Committer.When.UTC() - break - } + for len(iter.ParentHashes) > 0 { parent, parentErr := s.repo.CommitObject(iter.ParentHashes[0]) if parentErr != nil { - oldest = iter.Committer.When.UTC() break } iter = parent } + oldest := iter.Committer.When.UTC() return GenerationMetadata{ OldestCheckpointAt: oldest, NewestCheckpointAt: newest, - }, nil + } } // generationRefWidth is the zero-padded width of archived generation ref names. @@ -310,16 +305,7 @@ func (s *V2GitStore) rotateGeneration(ctx context.Context) error { } // Write generation.json to the current tree before archiving. - gen, genErr := s.computeGenerationTimestamps(ctx, currentTreeHash) - if genErr != nil { - logging.Warn(ctx, "rotation: failed to compute generation timestamps", - slog.String("error", genErr.Error()), - ) - gen = GenerationMetadata{ - OldestCheckpointAt: time.Now().UTC(), - NewestCheckpointAt: time.Now().UTC(), - } - } + gen := s.computeGenerationTimestamps() archiveTreeHash, err := s.AddGenerationJSONToTree(currentTreeHash, gen) if err != nil { return fmt.Errorf("rotation: failed to add generation.json: %w", err) diff --git a/cmd/entire/cli/checkpoint/v2_read.go b/cmd/entire/cli/checkpoint/v2_read.go index b823ce795..0a251eb61 100644 --- a/cmd/entire/cli/checkpoint/v2_read.go +++ b/cmd/entire/cli/checkpoint/v2_read.go @@ -165,7 +165,7 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI // Search newly fetched refs only newArchived, err := s.ListArchivedGenerations() if err != nil { - return nil, 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 { diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index c963527ed..46e84f69b 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -370,12 +370,12 @@ func fetchV2MainRefIfMissing(ctx context.Context, remoteURL string) error { "GIT_TERMINAL_PROMPT=0", ) if err := fetchCmd.Run(); err != nil { - return nil //nolint:nilerr // Fetch failure is not fatal + return nil } fetchedRef, err := repo.Reference(plumbing.ReferenceName(tmpRef), true) if err != nil { - return nil //nolint:nilerr // Ref not found after fetch + return nil } newRef := plumbing.NewHashReference(refName, fetchedRef.Hash()) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index 9be17320a..8452f3690 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -132,7 +132,7 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer remoteOnlyArchives, detectErr := detectRemoteOnlyArchives(ctx, target, repo) if detectErr == nil && len(remoteOnlyArchives) > 0 { err := handleRotationConflict(ctx, target, repo, refName, tmpRefName, remoteOnlyArchives) - _ = repo.Storer.RemoveReference(tmpRefName) //nolint:errcheck + _ = repo.Storer.RemoveReference(tmpRefName) //nolint:errcheck // cleanup is best-effort return err } } @@ -250,7 +250,7 @@ func handleRotationConflict(ctx context.Context, target string, repo *git.Reposi return fmt.Errorf("fetch archived generation failed: %s", output) } defer func() { - _ = repo.Storer.RemoveReference(archiveTmpRef) //nolint:errcheck + _ = repo.Storer.RemoveReference(archiveTmpRef) //nolint:errcheck // cleanup is best-effort }() // Get archived generation state @@ -343,22 +343,22 @@ func handleRotationConflict(ctx context.Context, target string, repo *git.Reposi 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{}, err + return object.TreeEntry{}, fmt.Errorf("failed to read generation blob: %w", err) } reader, err := blob.Reader() if err != nil { - return object.TreeEntry{}, err + 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{}, err + 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{}, err + return object.TreeEntry{}, fmt.Errorf("failed to parse generation.json: %w", err) } if newestFromLocal.After(gen.NewestCheckpointAt) { @@ -367,12 +367,12 @@ func updateGenerationTimestamps(repo *git.Repository, genBlobHash plumbing.Hash, updatedData, err := json.Marshal(gen) if err != nil { - return object.TreeEntry{}, err + return object.TreeEntry{}, fmt.Errorf("failed to marshal generation.json: %w", err) } newBlobHash, err := checkpoint.CreateBlobFromContent(repo, updatedData) if err != nil { - return object.TreeEntry{}, err + return object.TreeEntry{}, fmt.Errorf("failed to create generation blob: %w", err) } return object.TreeEntry{ @@ -386,8 +386,8 @@ func updateGenerationTimestamps(repo *git.Repository, genBlobHash plumbing.Hash, // 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)) - pushRefIfNeeded(ctx, target, plumbing.ReferenceName(paths.V2FullCurrentRefName)) + _ = 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) @@ -400,7 +400,7 @@ func pushV2Refs(ctx context.Context, target string) { return } latest := archived[len(archived)-1] - pushRefIfNeeded(ctx, target, plumbing.ReferenceName(paths.V2FullRefPrefix+latest)) + _ = 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. From 05ee59e5ba64a7def6ef097ffb9d16c8c2f33025 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 14:18:06 -0700 Subject: [PATCH 12/27] refactor: deduplicate generationRefPattern across packages Export GenerationRefPattern from checkpoint package and reuse in strategy/push_v2.go instead of defining an identical regex in both. Entire-Checkpoint: 3c0dac7843a9 --- cmd/entire/cli/checkpoint/v2_generation.go | 6 +++--- cmd/entire/cli/strategy/push_v2.go | 6 +----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_generation.go b/cmd/entire/cli/checkpoint/v2_generation.go index 8cafa719d..620afc78e 100644 --- a/cmd/entire/cli/checkpoint/v2_generation.go +++ b/cmd/entire/cli/checkpoint/v2_generation.go @@ -191,8 +191,8 @@ func (s *V2GitStore) computeGenerationTimestamps() GenerationMetadata { // 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. @@ -209,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) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index 8452f3690..fe6a3d05d 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -8,7 +8,6 @@ import ( "io" "os" "os/exec" - "regexp" "sort" "strings" "time" @@ -193,9 +192,6 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return nil } -// generationRefPattern matches the 13-digit archived generation ref suffix format. -var generationRefPattern = regexp.MustCompile(`^\d{13}$`) - // 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) { @@ -219,7 +215,7 @@ func detectRemoteOnlyArchives(ctx context.Context, target string, repo *git.Repo } refName := parts[1] suffix := strings.TrimPrefix(refName, paths.V2FullRefPrefix) - if suffix == "current" || !generationRefPattern.MatchString(suffix) { + if suffix == "current" || !checkpoint.GenerationRefPattern.MatchString(suffix) { continue } // Only check for existence, not hash equality. A locally-present archive From 13fd367cf47c6145a2302d7c43c738793938ae60 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 14:51:42 -0700 Subject: [PATCH 13/27] fix: log warning when generation timestamp update fails during rotation recovery Entire-Checkpoint: dbea736f4572 --- cmd/entire/cli/strategy/push_v2.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index fe6a3d05d..fd29b4da5 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log/slog" "os" "os/exec" "sort" @@ -13,6 +14,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v6" @@ -293,6 +295,10 @@ func handleRotationConflict(ctx context.Context, target string, repo *git.Reposi 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()), + ) } } From ca7d12b82ba3a262759c13bb33a21f3e192a55e7 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 14:54:46 -0700 Subject: [PATCH 14/27] fix: document why fetchRemoteFullRefs uses origin directly Entire-Checkpoint: ef2eca715f94 --- cmd/entire/cli/checkpoint/v2_read.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/entire/cli/checkpoint/v2_read.go b/cmd/entire/cli/checkpoint/v2_read.go index 0a251eb61..47d7cb914 100644 --- a/cmd/entire/cli/checkpoint/v2_read.go +++ b/cmd/entire/cli/checkpoint/v2_read.go @@ -192,6 +192,8 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI } // fetchRemoteFullRefs discovers and fetches /full/* refs from origin that aren't local. +// Uses "origin" directly — checkpoint_remote is not accessible from the checkpoint +// package without an import cycle, and the push path handles checkpoint_remote separately. func (s *V2GitStore) fetchRemoteFullRefs(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() From 750571d0439a491c2cf358f580599432cdae219a Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 15:17:02 -0700 Subject: [PATCH 15/27] fix: respect checkpoint_remote in all V2GitStore operations NewV2GitStore now requires a fetchRemote parameter used for fetch-on-demand operations. All callers resolve the checkpoint remote consistently via resolveV2FetchRemote (strategy package) or ResolveCheckpointURL (exported for resume.go). getV2CheckpointStore now accepts context so settings are loaded from the correct working directory. Entire-Checkpoint: e976eeced1a6 --- .../cli/checkpoint/v2_generation_test.go | 36 +++++++-------- cmd/entire/cli/checkpoint/v2_read.go | 9 ++-- cmd/entire/cli/checkpoint/v2_read_test.go | 12 ++--- cmd/entire/cli/checkpoint/v2_resolve_test.go | 6 +-- cmd/entire/cli/checkpoint/v2_store.go | 14 ++++-- cmd/entire/cli/checkpoint/v2_store_test.go | 46 +++++++++---------- cmd/entire/cli/resume.go | 6 ++- cmd/entire/cli/strategy/checkpoint_remote.go | 31 +++++++++++++ cmd/entire/cli/strategy/manual_commit.go | 7 +-- .../strategy/manual_commit_condensation.go | 2 +- .../cli/strategy/manual_commit_hooks.go | 2 +- .../cli/strategy/manual_commit_rewind.go | 2 +- cmd/entire/cli/strategy/push_v2.go | 2 +- cmd/entire/cli/strategy/push_v2_test.go | 6 +-- 14 files changed, 112 insertions(+), 69 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_generation_test.go b/cmd/entire/cli/checkpoint/v2_generation_test.go index 76d467f0a..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{}) @@ -36,7 +36,7 @@ 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{ @@ -62,7 +62,7 @@ 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{ @@ -91,7 +91,7 @@ func TestWriteGeneration_RoundTrips(t *testing.T) { 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) @@ -122,7 +122,7 @@ func TestReadGenerationFromRef(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{} @@ -164,7 +164,7 @@ func TestAddGenerationJSONToTree(t *testing.T) { func TestCountCheckpointsInTree_EmptyTree(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") count, err := store.CountCheckpointsInTree(plumbing.ZeroHash) require.NoError(t, err) @@ -174,7 +174,7 @@ func TestCountCheckpointsInTree_EmptyTree(t *testing.T) { func TestCountCheckpointsInTree_CountsShardDirectories(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() // Write 3 checkpoints to /full/current @@ -209,7 +209,7 @@ func TestCountCheckpointsInTree_CountsShardDirectories(t *testing.T) { func TestWriteCommittedFull_NoGenerationJSON(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("d1e2f3a4b5c6") @@ -239,7 +239,7 @@ func TestWriteCommittedFull_NoGenerationJSON(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") @@ -282,7 +282,7 @@ func TestUpdateCommitted_DoesNotAddGenerationJSON(t *testing.T) { // 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() @@ -306,7 +306,7 @@ 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() require.NoError(t, err) @@ -316,7 +316,7 @@ 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) @@ -329,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))) @@ -345,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) @@ -355,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) @@ -390,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 @@ -439,7 +439,7 @@ func TestRotateGeneration_ArchivesCurrentAndCreatesNewOrphan(t *testing.T) { func TestRotateGeneration_SequentialNumbering(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") store.maxCheckpointsPerGeneration = 2 ctx := context.Background() @@ -479,7 +479,7 @@ func TestRotateGeneration_SequentialNumbering(t *testing.T) { func TestReadGeneration_BackwardCompatible(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") // Simulate old format with a checkpoints field oldJSON := `{ diff --git a/cmd/entire/cli/checkpoint/v2_read.go b/cmd/entire/cli/checkpoint/v2_read.go index 47d7cb914..0d528c1ce 100644 --- a/cmd/entire/cli/checkpoint/v2_read.go +++ b/cmd/entire/cli/checkpoint/v2_read.go @@ -191,14 +191,13 @@ func (s *V2GitStore) readTranscriptFromFullRefs(ctx context.Context, checkpointI return nil, nil } -// fetchRemoteFullRefs discovers and fetches /full/* refs from origin that aren't local. -// Uses "origin" directly — checkpoint_remote is not accessible from the checkpoint -// package without an import cycle, and the push path handles checkpoint_remote separately. +// 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", "origin", paths.V2FullRefPrefix+"*") + 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) @@ -227,7 +226,7 @@ func (s *V2GitStore) fetchRemoteFullRefs(ctx context.Context) error { return nil } - args := append([]string{"fetch", "origin"}, refSpecs...) + 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) diff --git a/cmd/entire/cli/checkpoint/v2_read_test.go b/cmd/entire/cli/checkpoint/v2_read_test.go index e51c79f52..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", 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 76f999c46..64358d391 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,13 @@ 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 { return &V2GitStore{ - repo: repo, - gs: &GitStore{repo: repo}, + repo: repo, + gs: &GitStore{repo: repo}, + FetchRemote: fetchRemote, } } diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index a29fd7361..f0507df02 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,7 +115,7 @@ 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)) @@ -130,7 +130,7 @@ 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) @@ -140,7 +140,7 @@ func TestV2GitStore_GetRefState_ErrorsOnMissingRef(t *testing.T) { 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)) @@ -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_MultiSession(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") ctx := context.Background() cpID := id.MustCheckpointID("e5f6a1b2c3d4") @@ -366,7 +366,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") @@ -395,7 +395,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") @@ -434,7 +434,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") @@ -462,7 +462,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") @@ -503,7 +503,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") @@ -545,7 +545,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") @@ -570,7 +570,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") @@ -617,7 +617,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") @@ -662,7 +662,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") @@ -702,7 +702,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") @@ -720,7 +720,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() @@ -781,7 +781,7 @@ func TestWriteCommitted_TriggersRotationAtThreshold(t *testing.T) { func TestWriteCommitted_NoRotationBelowThreshold(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2GitStore(repo) + store := NewV2GitStore(repo, "origin") store.maxCheckpointsPerGeneration = 5 ctx := context.Background() diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 755693c30..9f1f24f58 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -818,7 +818,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/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index 46e84f69b..4a664b63c 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -141,6 +141,37 @@ func resolvePushSettings(ctx context.Context, pushRemoteName string) pushSetting 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 { + return "" + } + url, err := deriveCheckpointURL(pushRemoteURL, config) + if err != nil { + 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"). 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 2e80e270e..0ba0cf268 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -921,7 +921,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 868da3729..4d0385210 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2304,7 +2304,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_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index ddf5a2afa..1b3de31c8 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -638,7 +638,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 index fd29b4da5..eb7952670 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -396,7 +396,7 @@ func pushV2Refs(ctx context.Context, target string) { if err != nil { return } - store := checkpoint.NewV2GitStore(repo) + store := checkpoint.NewV2GitStore(repo, resolveV2FetchRemote(ctx)) archived, err := store.ListArchivedGenerations() if err != nil || len(archived) == 0 { return diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go index d2ff1bf19..eadfb9412 100644 --- a/cmd/entire/cli/strategy/push_v2_test.go +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -132,7 +132,7 @@ func TestFetchV2MainRefIfMissing_SkipsWhenExists(t *testing.T) { // 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) + store := checkpoint.NewV2GitStore(repo, "origin") err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ CheckpointID: cpID, SessionID: sessionID, @@ -309,7 +309,7 @@ func TestFetchAndMergeRef_RotationConflict(t *testing.T) { writeV2Checkpoint(t, remoteRepo, id.MustCheckpointID("112233445566"), "remote-session") // Manually rotate: archive /full/current, create fresh orphan - remoteStore := checkpoint.NewV2GitStore(remoteRepo) + remoteStore := checkpoint.NewV2GitStore(remoteRepo, "origin") currentRef, err := remoteRepo.Reference(fullCurrentRef, true) require.NoError(t, err) @@ -359,7 +359,7 @@ func TestFetchAndMergeRef_RotationConflict(t *testing.T) { // 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) + localStore := checkpoint.NewV2GitStore(localRepo, "origin") _, freshTreeHash, err := localStore.GetRefState(fullCurrentRef) require.NoError(t, err) freshCount, err := localStore.CountCheckpointsInTree(freshTreeHash) From dd1310162ff825f01975fbee8dccf60e4e1cc9e7 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 15:20:25 -0700 Subject: [PATCH 16/27] fix: use plumbing.ReferenceName equality instead of string cast Entire-Checkpoint: 551f42342598 --- cmd/entire/cli/strategy/push_v2.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index eb7952670..ef2302c16 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -129,7 +129,7 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer } // Check for rotation conflict on /full/current - if string(refName) == paths.V2FullCurrentRefName { + if refName == plumbing.ReferenceName(paths.V2FullCurrentRefName) { remoteOnlyArchives, detectErr := detectRemoteOnlyArchives(ctx, target, repo) if detectErr == nil && len(remoteOnlyArchives) > 0 { err := handleRotationConflict(ctx, target, repo, refName, tmpRefName, remoteOnlyArchives) From edca0a90161e18f5b1e86877c4041e6185b66735 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 15:20:56 -0700 Subject: [PATCH 17/27] fix: add nolint explanations for intentional nil returns in fetchV2MainRefIfMissing Entire-Checkpoint: da8bea2a421e --- cmd/entire/cli/strategy/checkpoint_remote.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index 4a664b63c..ec7df9d63 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -401,12 +401,12 @@ func fetchV2MainRefIfMissing(ctx context.Context, remoteURL string) error { "GIT_TERMINAL_PROMPT=0", ) if err := fetchCmd.Run(); err != nil { - return nil + return nil //nolint:nilerr // Fetch failure is not fatal — ref may not exist on remote yet } fetchedRef, err := repo.Reference(plumbing.ReferenceName(tmpRef), true) if err != nil { - return nil + return nil //nolint:nilerr // Ref not found after fetch — remote may not have it } newRef := plumbing.NewHashReference(refName, fetchedRef.Hash()) From 6829af1472c12d6e2a0e870ec5fea57172b5e7d4 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 15:43:42 -0700 Subject: [PATCH 18/27] fix: resolve lint warnings (errcheck, nilerr, testifylint) Entire-Checkpoint: 3f733012225d --- cmd/entire/cli/checkpoint/committed.go | 4 ++-- cmd/entire/cli/strategy/push_v2_test.go | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 16a499542..8708db808 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -889,10 +889,10 @@ func (s *GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error) { var checkpoints []CommittedInfo // Scan sharded structure: <2-char-prefix>//metadata.json - _ = WalkCheckpointShards(s.repo, tree, func(checkpointID id.CheckpointID, cpTreeHash plumbing.Hash) error { + _ = 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 // skip unreadable entries + return nil //nolint:nilerr // skip unreadable entries, continue walking } info := CommittedInfo{ diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go index eadfb9412..a544e75bd 100644 --- a/cmd/entire/cli/strategy/push_v2_test.go +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -77,7 +77,7 @@ func TestPushRefIfNeeded_LocalBareRepo_PushesSuccessfully(t *testing.T) { require.NoError(t, initCmd.Run()) err := pushRefIfNeeded(ctx, bareDir, plumbing.ReferenceName(paths.V2MainRefName)) - assert.NoError(t, err) + require.NoError(t, err) // Verify ref exists in bare repo bareRepo, err := git.PlainOpen(bareDir) @@ -248,13 +248,13 @@ func TestPushV2Refs_PushesAllRefs(t *testing.T) { require.NoError(t, err) _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true) - assert.NoError(t, err, "/main ref should exist in bare repo") + require.NoError(t, err, "/main ref should exist in bare repo") _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) - assert.NoError(t, err, "/full/current ref should exist in bare repo") + require.NoError(t, err, "/full/current ref should exist in bare repo") _, err = bareRepo.Reference(plumbing.ReferenceName(paths.V2FullRefPrefix+"0000000000002"), true) - assert.NoError(t, err, "latest archived generation should exist in bare repo") + 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") @@ -376,11 +376,11 @@ func TestFetchAndMergeRef_RotationConflict(t *testing.T) { // Check that the local-only checkpoint (ffeeddccbbaa) is in the archive _, err = archiveTree.Tree("ff/eeddccbbaa") - assert.NoError(t, err, "archived generation should contain local-only checkpoint ffeeddccbbaa") + 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") - assert.NoError(t, err, "archived generation should contain shared checkpoint aabbccddeeff") + 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") From 09ad6c7adbd074de73174f70ac7eb7ee36ea4aea Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 16:11:17 -0700 Subject: [PATCH 19/27] review: disconnect stdin and disable terminal prompts on hook-context git commands Entire-Checkpoint: 5a39329befe8 --- cmd/entire/cli/strategy/push_v2.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index ef2302c16..c1e565591 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -119,6 +119,8 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer refSpec := fmt.Sprintf("+%s:%s", refName, tmpRefName) fetchCmd := exec.CommandContext(ctx, "git", "fetch", target, refSpec) + fetchCmd.Stdin = nil + fetchCmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") if output, err := fetchCmd.CombinedOutput(); err != nil { return fmt.Errorf("fetch failed: %s", output) } @@ -201,6 +203,8 @@ func detectRemoteOnlyArchives(ctx context.Context, target string, repo *git.Repo defer cancel() cmd := exec.CommandContext(ctx, "git", "ls-remote", target, paths.V2FullRefPrefix+"*") + cmd.Stdin = nil + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("ls-remote failed: %w", err) @@ -244,6 +248,8 @@ func handleRotationConflict(ctx context.Context, target string, repo *git.Reposi archiveTmpRef := plumbing.ReferenceName("refs/entire-fetch-tmp/archive-" + latestArchive) archiveRefSpec := fmt.Sprintf("+%s:%s", archiveRefName, archiveTmpRef) fetchCmd := exec.CommandContext(ctx, "git", "fetch", target, archiveRefSpec) + fetchCmd.Stdin = nil + fetchCmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") if output, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil { return fmt.Errorf("fetch archived generation failed: %s", output) } From 55538f5c387b8e19f6b1bef2e9c9eb7aecb6b1f7 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 16:13:24 -0700 Subject: [PATCH 20/27] review: defer temp ref cleanup in fetchAndMergeRef to cover all error paths Entire-Checkpoint: ca27580dd7cc --- cmd/entire/cli/strategy/push_v2.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index c1e565591..fceb87176 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -129,14 +129,15 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer 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 { - err := handleRotationConflict(ctx, target, repo, refName, tmpRefName, remoteOnlyArchives) - _ = repo.Storer.RemoveReference(tmpRefName) //nolint:errcheck // cleanup is best-effort - return err + return handleRotationConflict(ctx, target, repo, refName, tmpRefName, remoteOnlyArchives) } } @@ -192,7 +193,6 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return fmt.Errorf("failed to update ref: %w", err) } - _ = repo.Storer.RemoveReference(tmpRefName) //nolint:errcheck // cleanup is best-effort return nil } From cf0e6431f55b91adf81a48b038d0e602cdff8373 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 16:23:07 -0700 Subject: [PATCH 21/27] review: use jsonutil.MarshalIndentWithNewline for consistent generation.json formatting Entire-Checkpoint: b9153880ad4c --- cmd/entire/cli/strategy/push_v2.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index fceb87176..b39b6b8ab 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -14,6 +14,7 @@ import ( "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" @@ -373,7 +374,7 @@ func updateGenerationTimestamps(repo *git.Repository, genBlobHash plumbing.Hash, gen.NewestCheckpointAt = newestFromLocal } - updatedData, err := json.Marshal(gen) + updatedData, err := jsonutil.MarshalIndentWithNewline(gen, "", " ") if err != nil { return object.TreeEntry{}, fmt.Errorf("failed to marshal generation.json: %w", err) } From 664b8432fd8f51bd90acb945cf84cad845376120 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 16:24:31 -0700 Subject: [PATCH 22/27] review: default fetchRemote to origin when empty in NewV2GitStore Entire-Checkpoint: fbfec65420f9 --- cmd/entire/cli/checkpoint/v2_store.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/entire/cli/checkpoint/v2_store.go b/cmd/entire/cli/checkpoint/v2_store.go index 64358d391..7a63fe01f 100644 --- a/cmd/entire/cli/checkpoint/v2_store.go +++ b/cmd/entire/cli/checkpoint/v2_store.go @@ -43,6 +43,9 @@ func (s *V2GitStore) maxCheckpoints() int { // 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}, From 61d16489ac20e2b2ba350c3f2c5c2b93c9a1e9ad Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 31 Mar 2026 16:45:28 -0700 Subject: [PATCH 23/27] fix: remove unused nolint:nilerr directives in fetchV2MainRefIfMissing Entire-Checkpoint: b24d67ac16b5 --- cmd/entire/cli/strategy/checkpoint_remote.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index 5dd65081f..e924f6c81 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -403,12 +403,12 @@ func fetchV2MainRefIfMissing(ctx context.Context, remoteURL string) error { "GIT_TERMINAL_PROMPT=0", ) if err := fetchCmd.Run(); err != nil { - return nil //nolint:nilerr // Fetch failure is not fatal — ref may not exist on remote yet + return nil } fetchedRef, err := repo.Reference(plumbing.ReferenceName(tmpRef), true) if err != nil { - return nil //nolint:nilerr // Ref not found after fetch — remote may not have it + return nil } newRef := plumbing.NewHashReference(refName, fetchedRef.Hash()) From 7fc22bd750e58e0139c7a83ce3209ac8edcc41fb Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 13:12:54 -0700 Subject: [PATCH 24/27] refactor: use CheckpointGitCommand for v2 push/fetch/ls-remote Aligns with v1 push path which uses CheckpointGitCommand to inject ENTIRE_CHECKPOINT_TOKEN for authenticated git operations. Previously v2 used raw exec.CommandContext which skipped token injection. Entire-Checkpoint: ef14af5c9122 --- cmd/entire/cli/strategy/push_v2.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index b39b6b8ab..fcb23ac17 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -8,7 +8,6 @@ import ( "io" "log/slog" "os" - "os/exec" "sort" "strings" "time" @@ -48,8 +47,7 @@ func tryPushRef(ctx context.Context, target string, refName plumbing.ReferenceNa // Use --no-verify to prevent recursive hook calls (this runs inside pre-push) refSpec := fmt.Sprintf("%s:%s", refName, refName) - cmd := exec.CommandContext(ctx, "git", "push", "--no-verify", target, refSpec) - cmd.Stdin = nil // Disconnect stdin to prevent hanging in hook context + cmd := CheckpointGitCommand(ctx, target, "push", "--no-verify", target, refSpec) output, err := cmd.CombinedOutput() if err != nil { @@ -119,9 +117,8 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer tmpRefName := plumbing.ReferenceName("refs/entire-fetch-tmp/" + tmpRefSuffix) refSpec := fmt.Sprintf("+%s:%s", refName, tmpRefName) - fetchCmd := exec.CommandContext(ctx, "git", "fetch", target, refSpec) - fetchCmd.Stdin = nil - fetchCmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + 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) } @@ -203,9 +200,8 @@ func detectRemoteOnlyArchives(ctx context.Context, target string, repo *git.Repo ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "git", "ls-remote", target, paths.V2FullRefPrefix+"*") - cmd.Stdin = nil - cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + 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) @@ -248,9 +244,8 @@ func handleRotationConflict(ctx context.Context, target string, repo *git.Reposi // Fetch the latest archived generation archiveTmpRef := plumbing.ReferenceName("refs/entire-fetch-tmp/archive-" + latestArchive) archiveRefSpec := fmt.Sprintf("+%s:%s", archiveRefName, archiveTmpRef) - fetchCmd := exec.CommandContext(ctx, "git", "fetch", target, archiveRefSpec) - fetchCmd.Stdin = nil - fetchCmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + 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) } From e9d0009665a2946d5f1c1f202627abed67cd893a Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 14:44:05 -0700 Subject: [PATCH 25/27] refactor: simplify fetchV2MainRefIfMissing to delegate to FetchV2MainFromURL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes duplicated fetch logic — now checks if ref exists locally and delegates to FetchV2MainFromURL (from resume branch) which uses CheckpointGitCommand for token injection. Entire-Checkpoint: 6e0818a62512 --- cmd/entire/cli/strategy/checkpoint_remote.go | 28 +++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index eb68d1be6..13bd38be9 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -459,8 +459,7 @@ func fetchMetadataBranchIfMissing(ctx context.Context, remoteURL string) error { } // fetchV2MainRefIfMissing fetches the v2 /main ref from a URL only if it doesn't -// exist locally. Same pattern as fetchMetadataBranchIfMissing but for custom refs -// under refs/entire/ (uses explicit refspec instead of refs/heads/). +// exist locally. Delegates to FetchV2MainFromURL for the actual fetch. func fetchV2MainRefIfMissing(ctx context.Context, remoteURL string) error { repo, err := OpenRepository(ctx) if err != nil { @@ -472,31 +471,10 @@ func fetchV2MainRefIfMissing(ctx context.Context, remoteURL string) error { return nil // Ref exists locally, skip fetch } - fetchCtx, cancel := context.WithTimeout(ctx, checkpointRemoteFetchTimeout) - defer cancel() - - tmpRef := "refs/entire-fetch-tmp/v2-main" - refSpec := fmt.Sprintf("+%s:%s", paths.V2MainRefName, tmpRef) - fetchCmd := exec.CommandContext(fetchCtx, "git", "fetch", "--no-tags", remoteURL, refSpec) - fetchCmd.Env = append(os.Environ(), - "GIT_TERMINAL_PROMPT=0", - ) - if err := fetchCmd.Run(); err != nil { - return nil - } - - fetchedRef, err := repo.Reference(plumbing.ReferenceName(tmpRef), true) - if err != nil { - return nil - } - - newRef := plumbing.NewHashReference(refName, fetchedRef.Hash()) - if err := repo.Storer.SetReference(newRef); err != nil { - return fmt.Errorf("failed to create local ref from fetched: %w", err) + if err := FetchV2MainFromURL(ctx, remoteURL); err != nil { + return nil //nolint:nilerr // Fetch failure is not fatal — ref may not exist on remote yet } - _ = repo.Storer.RemoveReference(plumbing.ReferenceName(tmpRef)) //nolint:errcheck // cleanup is best-effort - logging.Info(ctx, "checkpoint-remote: fetched v2 /main ref from URL") return nil } From 2bea35f627572500e180be892e339ae7237c367a Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 15:45:14 -0700 Subject: [PATCH 26/27] review: add debug logging for checkpoint_remote URL resolution failures Entire-Checkpoint: 97687717cb39 --- cmd/entire/cli/strategy/checkpoint_remote.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index 13bd38be9..3268981e1 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -154,10 +154,18 @@ func ResolveCheckpointURL(ctx context.Context, pushRemoteName string) string { } 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 From d3531cec9aac59a186c04bf1989eae15e7afccaa Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Fri, 3 Apr 2026 13:50:38 -0700 Subject: [PATCH 27/27] fix: add missing fetchRemote arg to NewV2GitStore calls from merged main Entire-Checkpoint: 95e4f0b990a0 --- cmd/entire/cli/checkpoint/v2_store_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index 74313ed7a..3e4887713 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -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")