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