Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7201db1
Merge branch 'feat/checkpoints-v2-entire-resume' of github.com:entire…
pfleidi Mar 28, 2026
60fbfdc
Merge branch 'feat/checkpoints-v2-entire-resume' of github.com:entire…
pfleidi Mar 28, 2026
e71bfdb
feat: Introduce IsPushV2RefsEnabled()
pfleidi Mar 30, 2026
0d793fc
refactor: simplify GenerationMetadata to timestamps only, write at ar…
pfleidi Mar 30, 2026
4ebd0b4
refactor: extract WalkCheckpointShards helper for shard iteration
pfleidi Mar 30, 2026
8fa80cf
feat: add tryPushRef and pushRefIfNeeded for v2 custom refs
pfleidi Mar 30, 2026
92c4f7b
feat: implement fetchAndMergeRef for v2 custom ref merge recovery
pfleidi Mar 30, 2026
95c79fe
feat: integrate v2 push into PrePush hook
pfleidi Mar 31, 2026
d4773c3
feat: rotation conflict recovery for v2 /full/current push
pfleidi Mar 31, 2026
186271d
feat: fetch v2 /main ref from checkpoint remote when missing locally
pfleidi Mar 31, 2026
c0505b2
feat: fetch-on-demand for remote /full/* refs in entire resume
pfleidi Mar 31, 2026
5f79093
test: add integration tests for v2 push cycle
pfleidi Mar 31, 2026
0dbbf7d
fix: resolve lint warnings in v2 push logic
pfleidi Mar 31, 2026
05ee59e
refactor: deduplicate generationRefPattern across packages
pfleidi Mar 31, 2026
13fd367
fix: log warning when generation timestamp update fails during rotati…
pfleidi Mar 31, 2026
ca7d12b
fix: document why fetchRemoteFullRefs uses origin directly
pfleidi Mar 31, 2026
750571d
fix: respect checkpoint_remote in all V2GitStore operations
pfleidi Mar 31, 2026
dd13101
fix: use plumbing.ReferenceName equality instead of string cast
pfleidi Mar 31, 2026
edca0a9
fix: add nolint explanations for intentional nil returns in fetchV2Ma…
pfleidi Mar 31, 2026
ac79fb7
Merge branch 'feat/checkpoints-v2-entire-resume' into feat/checkpoint…
pfleidi Mar 31, 2026
6829af1
fix: resolve lint warnings (errcheck, nilerr, testifylint)
pfleidi Mar 31, 2026
09ad6c7
review: disconnect stdin and disable terminal prompts on hook-context…
pfleidi Mar 31, 2026
55538f5
review: defer temp ref cleanup in fetchAndMergeRef to cover all error…
pfleidi Mar 31, 2026
cf0e643
review: use jsonutil.MarshalIndentWithNewline for consistent generati…
pfleidi Mar 31, 2026
664b843
review: default fetchRemote to origin when empty in NewV2GitStore
pfleidi Mar 31, 2026
61d1648
fix: remove unused nolint:nilerr directives in fetchV2MainRefIfMissing
pfleidi Mar 31, 2026
aeadae0
Merge remote-tracking branch 'origin/main' into feat/checkpoints-v2-p…
pfleidi Apr 2, 2026
4945b1c
Merge remote-tracking branch 'origin/main' into feat/checkpoints-v2-p…
pfleidi Apr 2, 2026
7fc22bd
refactor: use CheckpointGitCommand for v2 push/fetch/ls-remote
pfleidi Apr 2, 2026
fc1aa48
Merge remote-tracking branch 'origin/feat/checkpoints-v2-entire-resum…
pfleidi Apr 2, 2026
e9d0009
refactor: simplify fetchV2MainRefIfMissing to delegate to FetchV2Main…
pfleidi Apr 2, 2026
2bea35f
review: add debug logging for checkpoint_remote URL resolution failures
pfleidi Apr 2, 2026
962675a
Merge branch 'main' into feat/checkpoints-v2-push-logic
pfleidi Apr 3, 2026
d3531ce
fix: add missing fetchRemote arg to NewV2GitStore calls from merged main
pfleidi Apr 3, 2026
22eb905
review: remove redundant resolveV2FetchRemote, use ResolveCheckpointU…
pfleidi Apr 3, 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
89 changes: 31 additions & 58 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -894,76 +894,49 @@ 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
}
}
}
}
}
}
}

checkpoints = append(checkpoints, info)
}
}

checkpoints = append(checkpoints, info)
return nil
})

// Sort by time (most recent first)
sort.Slice(checkpoints, func(i, j int) bool {
Expand Down
37 changes: 37 additions & 0 deletions cmd/entire/cli/checkpoint/parse_tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 14 additions & 18 deletions cmd/entire/cli/checkpoint/v2_committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading