Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0f8f14d
Merge branch 'main' of github.com:entireio/cli into fix/checkpoints-v…
pfleidi May 7, 2026
b575cfc
Merge branch 'main' of github.com:entireio/cli into fix/checkpoints-v…
pfleidi May 7, 2026
a5aac69
Fix v2 full generation rotation push
pfleidi May 8, 2026
600cbd4
Detect stale v2 full generation archives
pfleidi May 8, 2026
a599cde
Handle multiple remote v2 rotations
pfleidi May 8, 2026
43fa9dc
Cover post-rotation v2 current pushes
pfleidi May 8, 2026
bc82aaf
Cover repeated local v2 rotations
pfleidi May 8, 2026
aa9ac82
Push all v2 archived generation refs
pfleidi May 8, 2026
65d9a46
Use SplitSeq for v2 remote ref scans
pfleidi May 8, 2026
d654296
Publish v2 archives from pending records
pfleidi May 8, 2026
090dc30
Rollback unqueued migrated v2 archives
pfleidi May 8, 2026
6c48c47
Push pending v2 archives without active refs
pfleidi May 8, 2026
ed4eddd
Guard empty v2 ref pushes
pfleidi May 8, 2026
3dccb8d
Lock pending v2 full generation markers
pfleidi May 8, 2026
3a95e50
Avoid redundant v2 generation lookups
pfleidi May 8, 2026
004ab54
Simplify v2 pending publication push path
pfleidi May 8, 2026
9c73ee3
Merge branch 'main' of github.com:entireio/cli into fix/checkpoints-v…
pfleidi May 8, 2026
aed8210
Harden v2 rotation recovery fetches
pfleidi May 8, 2026
cdcdec6
Merge remote-tracking branch 'origin/main' into fix/checkpoints-v2-ro…
pfleidi May 8, 2026
3ea8f1c
Refine v2 pending publication push handling
pfleidi May 9, 2026
25e04b8
Validate pending v2 archive publication refs
pfleidi May 9, 2026
d014d6d
Clean up fetched v2 archive temp refs
pfleidi May 9, 2026
48c2096
Reuse v2 push repository state
pfleidi May 9, 2026
c2446ed
Require pending v2 rotation marker before reset
pfleidi May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cmd/entire/cli/checkpoint/v2_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,22 @@ func (s *V2GitStore) RotateCurrentGenerationIfNeeded(ctx context.Context, maxChe
return archiveRefName, false, nil
}

if err := s.AppendPendingFullRotation(ctx, PendingV2FullRotation{
ArchiveRefName: archiveRefName.String(),
PreviousFullCurrentHash: currentRef.Hash().String(),
ArchivedFullGenerationHash: archiveCommitHash.String(),
ResetFullCurrentRootHash: orphanCommitHash.String(),
RotatedAt: time.Now().UTC(),
}); err != nil {
logging.Warn(ctx, "rotation: failed to record pending full rotation; continuing with rotated refs",
"error", err,
slog.String("archive_ref", string(archiveRefName)),
slog.String("previous_full_current_hash", currentRef.Hash().String()),
slog.String("archived_full_generation_hash", archiveCommitHash.String()),
slog.String("reset_full_current_root_hash", orphanCommitHash.String()),
)
}

logging.Info(ctx, "generation rotation complete",
slog.Int("archived_generation", archiveNumber),
slog.String("archive_ref", string(archiveRefName)),
Expand Down
33 changes: 33 additions & 0 deletions cmd/entire/cli/checkpoint/v2_generation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -447,6 +449,37 @@ func TestRotateGeneration_ArchivesCurrentAndCreatesNewOrphan(t *testing.T) {
assert.Empty(t, freshTree.Entries, "fresh tree should be empty (no generation.json)")
}

func TestRotateGeneration_SucceedsWhenPendingMarkerCannotBeRecorded(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
store := NewV2GitStore(repo, "origin")
ctx := context.Background()

populateFullCurrent(t, store, 3, 0)

worktree, err := repo.Worktree()
require.NoError(t, err)
blockingPath := filepath.Join(worktree.Filesystem.Root(), ".git", pendingV2FullRotationDirName)
require.NoError(t, os.WriteFile(blockingPath, []byte("not a directory"), 0o600))

refName, rotated, err := store.RotateCurrentGenerationIfNeeded(ctx, 3)
require.NoError(t, err)
require.True(t, rotated)
require.Equal(t, ArchivedGenerationRefName(1), refName)

_, currentTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
require.NoError(t, err)
currentCount, err := store.CountCheckpointsInTree(currentTreeHash)
require.NoError(t, err)
assert.Equal(t, 0, currentCount)

_, archiveTreeHash, err := store.GetRefState(refName)
require.NoError(t, err)
archiveCount, err := store.CountCheckpointsInTree(archiveTreeHash)
require.NoError(t, err)
assert.Equal(t, 3, archiveCount)
}

func TestResetFullCurrentRefIfUnchangedRejectsConcurrentChange(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
Expand Down
159 changes: 159 additions & 0 deletions cmd/entire/cli/checkpoint/v2_pending_rotation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package checkpoint

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/entireio/cli/cmd/entire/cli/jsonutil"
)

const (
pendingV2FullRotationVersion = 1
pendingV2FullRotationDirName = "entire-v2-rotations"
pendingV2FullRotationFile = "pending.json"
)

type PendingV2FullRotation struct {
ArchiveRefName string `json:"archive_ref_name"`
PreviousFullCurrentHash string `json:"previous_full_current_hash"`
ArchivedFullGenerationHash string `json:"archived_full_generation_hash"`
ResetFullCurrentRootHash string `json:"reset_full_current_root_hash"`
RotatedAt time.Time `json:"rotated_at"`
}

type pendingV2FullRotationState struct {
Version int `json:"version"`
Rotations []PendingV2FullRotation `json:"rotations"`
}

func (s *V2GitStore) AppendPendingFullRotation(ctx context.Context, rotation PendingV2FullRotation) error {
state, err := s.readPendingFullRotationState(ctx)
if err != nil {
return err
}
state.Version = pendingV2FullRotationVersion
state.Rotations = append(state.Rotations, rotation)
return s.writePendingFullRotationState(ctx, state)
}

func (s *V2GitStore) ReadPendingFullRotations(ctx context.Context) ([]PendingV2FullRotation, error) {
state, err := s.readPendingFullRotationState(ctx)
if err != nil {
return nil, err
}
return state.Rotations, nil
}

func (s *V2GitStore) ClearPendingFullRotations(ctx context.Context) error {
path, err := s.pendingFullRotationFilePath(ctx)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove pending v2 full rotations: %w", err)
}
return nil
}

func (s *V2GitStore) readPendingFullRotationState(ctx context.Context) (pendingV2FullRotationState, error) {
path, err := s.pendingFullRotationFilePath(ctx)
if err != nil {
return pendingV2FullRotationState{}, err
}
data, err := os.ReadFile(path) //nolint:gosec // path is under git common dir
if os.IsNotExist(err) {
return pendingV2FullRotationState{Version: pendingV2FullRotationVersion}, nil
}
if err != nil {
return pendingV2FullRotationState{}, fmt.Errorf("read pending v2 full rotations: %w", err)
}

var state pendingV2FullRotationState
if err := json.Unmarshal(data, &state); err != nil {
return pendingV2FullRotationState{}, fmt.Errorf("parse pending v2 full rotations: %w", err)
}
if state.Version != pendingV2FullRotationVersion {
return pendingV2FullRotationState{}, fmt.Errorf("unsupported pending v2 full rotation version %d", state.Version)
}
return state, nil
}

func (s *V2GitStore) writePendingFullRotationState(ctx context.Context, state pendingV2FullRotationState) error {
path, err := s.pendingFullRotationFilePath(ctx)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create pending v2 full rotation dir: %w", err)
}

data, err := jsonutil.MarshalIndentWithNewline(state, "", " ")
if err != nil {
return fmt.Errorf("marshal pending v2 full rotations: %w", err)
}

tmpFile, err := os.CreateTemp(filepath.Dir(path), pendingV2FullRotationFile+".*.tmp")
if err != nil {
return fmt.Errorf("create pending v2 full rotation temp file: %w", err)
}
tmpName := tmpFile.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpName)
}
}()

if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("write pending v2 full rotations: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("close pending v2 full rotations: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("replace pending v2 full rotations: %w", err)
}
removeTmp = false
return nil
}

func (s *V2GitStore) pendingFullRotationFilePath(ctx context.Context) (string, error) {
commonDir, err := s.gitCommonDir(ctx)
if err != nil {
return "", err
}
return filepath.Join(commonDir, pendingV2FullRotationDirName, pendingV2FullRotationFile), nil
}

func (s *V2GitStore) gitCommonDir(ctx context.Context) (string, error) {
worktree, err := s.repo.Worktree()
if err != nil {
return "", fmt.Errorf("open worktree for pending v2 full rotations: %w", err)
}
root := worktree.Filesystem.Root()
if root == "" {
return "", errors.New("resolve worktree root for pending v2 full rotations")
}

cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "--git-common-dir")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("resolve git common dir for pending v2 full rotations: %w", err)
}
commonDir := strings.TrimSpace(string(output))
if commonDir == "" {
return "", errors.New("resolve git common dir for pending v2 full rotations: empty output")
}
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(root, commonDir)
}
return filepath.Clean(commonDir), nil
}
2 changes: 1 addition & 1 deletion cmd/entire/cli/checkpoint/v2_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ func (s *V2GitStore) fetchRemoteFullRefs(ctx context.Context) error {
}

var refSpecs []string
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
for line := range strings.SplitSeq(strings.TrimSpace(string(output)), "\n") {
if line == "" {
continue
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/strategy/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ func listRemoteArchivedV2GenerationRefs(ctx context.Context, target string) (map
}

refs := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
for line := range strings.SplitSeq(strings.TrimSpace(string(output)), "\n") {
if line == "" {
continue
}
Expand Down
Loading
Loading