From e8304762a4410f97456d1441245313ecdc68aa70 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:33:09 -0700 Subject: [PATCH 1/7] Add a v2 only option --- .entire/settings.json | 2 + cmd/entire/cli/attach.go | 31 +++---- .../integration_test/v2_dual_write_test.go | 56 ++++++++++++ .../cli/integration_test/v2_push_test.go | 44 +++++++++ cmd/entire/cli/settings/settings.go | 47 +++++++++- cmd/entire/cli/settings/settings_test.go | 69 ++++++++++++++ cmd/entire/cli/strategy/checkpoint_remote.go | 20 +++-- .../strategy/manual_commit_condensation.go | 36 +++++--- .../cli/strategy/manual_commit_hooks.go | 43 ++++++--- cmd/entire/cli/strategy/manual_commit_push.go | 15 ++-- cmd/entire/cli/strategy/push_common.go | 32 +++++++ cmd/entire/cli/strategy/push_common_test.go | 89 +++++++++++++++++++ 12 files changed, 430 insertions(+), 54 deletions(-) diff --git a/.entire/settings.json b/.entire/settings.json index 75d97108dd..826c00a434 100644 --- a/.entire/settings.json +++ b/.entire/settings.json @@ -3,6 +3,8 @@ "local_dev": true, "strategy": "manual-commit", "strategy_options": { + "checkpoints_v2": true, + "push_v2_refs": true, "checkpoint_remote": { "provider": "github", "repo": "entireio/cli-checkpoints" diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 57e2f8d658..e4f990e4f3 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -169,11 +169,19 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ writeOpts.CompactTranscript = compacted } - if err := store.WriteCommitted(ctx, writeOpts); err != nil { - return fmt.Errorf("failed to write checkpoint: %w", err) - } - if settings.IsCheckpointsV2Enabled(logCtx) { - writeAttachCheckpointV2(logCtx, repo, writeOpts) + if settings.IsCheckpointsV2OnlyEnabled(logCtx) { + if err := writeAttachCheckpointV2(logCtx, repo, writeOpts); err != nil { + return fmt.Errorf("failed to write checkpoint to v2: %w", err) + } + } else { + if err := store.WriteCommitted(ctx, writeOpts); err != nil { + return fmt.Errorf("failed to write checkpoint: %w", err) + } + if settings.IsCheckpointsV2Enabled(logCtx) { + if err := writeAttachCheckpointV2(logCtx, repo, writeOpts); err != nil { + logging.Warn(logCtx, "attach v2 dual-write failed", "error", err) + } + } } // Create or update session state. @@ -197,17 +205,10 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ return nil } -// writeAttachCheckpointV2 mirrors attach-created checkpoints into the v2 refs. -// The caller is responsible for checking whether checkpoints_v2 is enabled. -// v2 failures are logged and do not fail attach. -func writeAttachCheckpointV2(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) { +// writeAttachCheckpointV2 writes attach-created checkpoints into the v2 refs. +func writeAttachCheckpointV2(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) error { v2Store := cpkg.NewV2GitStore(repo, strategy.ResolveCheckpointURL(ctx, "origin")) - if err := v2Store.WriteCommitted(ctx, opts); err != nil { - logging.Warn(ctx, "attach v2 dual-write failed", - "checkpoint_id", opts.CheckpointID.String(), - "error", err, - ) - } + return v2Store.WriteCommitted(ctx, opts) } // getHeadCommit returns the HEAD commit object. diff --git a/cmd/entire/cli/integration_test/v2_dual_write_test.go b/cmd/entire/cli/integration_test/v2_dual_write_test.go index 5d40d1c6d4..85b0cc154c 100644 --- a/cmd/entire/cli/integration_test/v2_dual_write_test.go +++ b/cmd/entire/cli/integration_test/v2_dual_write_test.go @@ -244,3 +244,59 @@ func TestV2DualWrite_StopTimeFinalization(t *testing.T) { require.True(t, found, "transcript.jsonl should exist on v2 /main after finalization") assert.Contains(t, compactTranscript, `"v":1`) } + +func TestV2Only_FullWorkflow(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-only-test") + + env.InitEntireWithOptions(map[string]any{ + "checkpoints_v2_only": true, + }) + + session := env.NewSession() + err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add greeting function") + require.NoError(t, err) + + env.WriteFile("greet.go", "package main\n\nfunc Greet() string { return \"hello\" }") + session.CreateTranscript( + "Add greeting function", + []FileChange{{Path: "greet.go", Content: "package main\n\nfunc Greet() string { return \"hello\" }"}}, + ) + err = env.SimulateStop(session.ID, session.TranscriptPath) + require.NoError(t, err) + + env.GitCommitWithShadowHooks("Add greeting function", "greet.go") + + cpIDStr := env.GetLatestCheckpointIDFromHistory() + require.NotEmpty(t, cpIDStr, "checkpoint ID should be in commit trailer") + + cpID, err := id.NewCheckpointID(cpIDStr) + require.NoError(t, err) + cpPath := cpID.Path() + + _, found := env.ReadFileFromBranch(paths.MetadataBranchName, cpPath+"/"+paths.MetadataFileName) + assert.False(t, found, + "v1 committed checkpoint metadata should NOT exist when checkpoints_v2_only is enabled") + + assert.True(t, env.RefExists(paths.V2MainRefName), + "v2 /main ref should exist") + assert.True(t, env.RefExists(paths.V2FullCurrentRefName), + "v2 /full/current ref should exist") + + mainSummary, found := env.ReadFileFromRef(paths.V2MainRefName, cpPath+"/"+paths.MetadataFileName) + require.True(t, found, "v2 /main root metadata.json should exist") + assert.Contains(t, mainSummary, cpIDStr) + + fullTranscript, found := env.ReadFileFromRef(paths.V2FullCurrentRefName, cpPath+"/0/"+paths.V2RawTranscriptFileName) + require.True(t, found, "raw_transcript should exist on v2 /full/current") + assert.Contains(t, fullTranscript, "Greet") +} diff --git a/cmd/entire/cli/integration_test/v2_push_test.go b/cmd/entire/cli/integration_test/v2_push_test.go index f47d8d730a..4482f634d8 100644 --- a/cmd/entire/cli/integration_test/v2_push_test.go +++ b/cmd/entire/cli/integration_test/v2_push_test.go @@ -123,3 +123,47 @@ func TestV2Push_Disabled_NoV2Refs(t *testing.T) { assert.True(t, bareRefExists(t, bareDir, "refs/heads/"+paths.MetadataBranchName), "v1 metadata branch should still exist on remote") } + +func TestV2Push_V2OnlySkipsV1Branch(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-only-push-test") + + env.InitEntireWithOptions(map[string]any{ + "checkpoints_v2_only": 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") + + 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") + assert.False(t, bareRefExists(t, bareDir, "refs/heads/"+paths.MetadataBranchName), + "v1 metadata branch should NOT exist on remote when checkpoints_v2_only is enabled") +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 3452c434a7..56dce02d47 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -422,6 +422,16 @@ func IsCheckpointsV2Enabled(ctx context.Context) bool { return settings.IsCheckpointsV2Enabled() } +// IsCheckpointsV2OnlyEnabled checks if checkpoints should be written and pushed +// only via v2 refs. +func IsCheckpointsV2OnlyEnabled(ctx context.Context) bool { + s, err := Load(ctx) + if err != nil { + return false + } + return s.IsCheckpointsV2OnlyEnabled() +} + // 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 { @@ -432,6 +442,15 @@ func IsPushV2RefsEnabled(ctx context.Context) bool { return s.IsPushV2RefsEnabled() } +// IsCheckpointsV1WriteEnabled checks if v1 checkpoint writes should still happen. +func IsCheckpointsV1WriteEnabled(ctx context.Context) bool { + s, err := Load(ctx) + if err != nil { + return true + } + return s.IsCheckpointsV1WriteEnabled() +} + // IsFilteredFetchesEnabled checks if filtered fetches should be used. // When enabled, filtered fetches always resolve remote names to URLs first so // git does not persist promisor settings onto named remotes in local config. @@ -513,9 +532,12 @@ func (s *EntireSettings) GetCheckpointRemote() *CheckpointRemoteConfig { return &CheckpointRemoteConfig{Provider: provider, Repo: repo} } -// IsCheckpointsV2Enabled checks if checkpoints v2 (dual-write to refs/entire/) is enabled. -// Returns false by default if the key is missing or not a bool. +// IsCheckpointsV2Enabled checks if checkpoints v2 is enabled. +// Returns true when either checkpoints_v2 or checkpoints_v2_only is enabled. func (s *EntireSettings) IsCheckpointsV2Enabled() bool { + if s.IsCheckpointsV2OnlyEnabled() { + return true + } if s.StrategyOptions == nil { return false } @@ -523,9 +545,22 @@ func (s *EntireSettings) IsCheckpointsV2Enabled() bool { return ok && val } +// IsCheckpointsV2OnlyEnabled checks if checkpoints should be written and pushed +// only via v2 refs, with no v1 dual-write. +func (s *EntireSettings) IsCheckpointsV2OnlyEnabled() bool { + if s.StrategyOptions == nil { + return false + } + val, ok := s.StrategyOptions["checkpoints_v2_only"].(bool) + return ok && val +} + // IsPushV2RefsEnabled checks if pushing v2 refs is enabled. -// Requires both checkpoints_v2 and push_v2_refs to be true. +// checkpoints_v2_only forces v2 ref pushes on, regardless of push_v2_refs. func (s *EntireSettings) IsPushV2RefsEnabled() bool { + if s.IsCheckpointsV2OnlyEnabled() { + return true + } if !s.IsCheckpointsV2Enabled() { return false } @@ -536,6 +571,12 @@ func (s *EntireSettings) IsPushV2RefsEnabled() bool { return ok && val } +// IsCheckpointsV1WriteEnabled reports whether v1 checkpoint writes should still +// happen. checkpoints_v2_only disables the v1 path entirely. +func (s *EntireSettings) IsCheckpointsV1WriteEnabled() bool { + return !s.IsCheckpointsV2OnlyEnabled() +} + // IsFilteredFetchesEnabled checks if fetches should use --filter=blob:none. // When enabled, filtered fetches always use resolved URLs rather than remote // names to avoid persisting promisor settings onto named remotes. diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index 449ae92984..138d9802c2 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -474,6 +474,17 @@ func TestIsCheckpointsV2Enabled_True(t *testing.T) { } } +func TestIsCheckpointsV2Enabled_V2Only(t *testing.T) { + t.Parallel() + s := &EntireSettings{ + Enabled: true, + StrategyOptions: map[string]any{"checkpoints_v2_only": true}, + } + if !s.IsCheckpointsV2Enabled() { + t.Error("expected IsCheckpointsV2Enabled to be true when checkpoints_v2_only is enabled") + } +} + func TestIsCheckpointsV2Enabled_ExplicitlyFalse(t *testing.T) { t.Parallel() s := &EntireSettings{ @@ -559,6 +570,36 @@ func TestIsCheckpointsV2Enabled_LocalOverride(t *testing.T) { } } +func TestIsCheckpointsV2OnlyEnabled_DefaultsFalse(t *testing.T) { + t.Parallel() + s := &EntireSettings{Enabled: true} + if s.IsCheckpointsV2OnlyEnabled() { + t.Error("expected IsCheckpointsV2OnlyEnabled to default to false") + } +} + +func TestIsCheckpointsV2OnlyEnabled_True(t *testing.T) { + t.Parallel() + s := &EntireSettings{ + Enabled: true, + StrategyOptions: map[string]any{"checkpoints_v2_only": true}, + } + if !s.IsCheckpointsV2OnlyEnabled() { + t.Error("expected IsCheckpointsV2OnlyEnabled to be true") + } +} + +func TestIsCheckpointsV2OnlyEnabled_WrongType(t *testing.T) { + t.Parallel() + s := &EntireSettings{ + Enabled: true, + StrategyOptions: map[string]any{"checkpoints_v2_only": "yes"}, + } + if s.IsCheckpointsV2OnlyEnabled() { + t.Error("expected IsCheckpointsV2OnlyEnabled to be false for non-bool value") + } +} + func TestIsPushV2RefsEnabled_DefaultsFalse(t *testing.T) { t.Parallel() s := &EntireSettings{Enabled: true} @@ -575,6 +616,7 @@ func TestIsPushV2RefsEnabled_RequiresBothFlags(t *testing.T) { opts map[string]any expected bool }{ + {"v2 only supersedes both", map[string]any{"checkpoints_v2": false, "push_v2_refs": false, "checkpoints_v2_only": true}, true}, {"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}, @@ -597,6 +639,33 @@ func TestIsPushV2RefsEnabled_RequiresBothFlags(t *testing.T) { } } +func TestIsCheckpointsV1WriteEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts map[string]any + expected bool + }{ + {"default on", nil, true}, + {"dual write still writes v1", map[string]any{"checkpoints_v2": true}, true}, + {"v2 only disables v1", map[string]any{"checkpoints_v2_only": true}, 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.IsCheckpointsV1WriteEnabled(); got != tt.expected { + t.Errorf("IsCheckpointsV1WriteEnabled() = %v, want %v", got, tt.expected) + } + }) + } +} + func TestIsFilteredFetchesEnabled_DefaultsFalse(t *testing.T) { t.Parallel() s := &EntireSettings{Enabled: true} diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index 55eb37f2cf..91759f077c 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -119,17 +119,19 @@ func resolvePushSettings(ctx context.Context, pushRemoteName string) pushSetting ps.checkpointURL = checkpointURL - // If the checkpoint branch doesn't exist locally, try to fetch it from the URL. - // This is a one-time operation — once the branch exists locally, subsequent pushes - // skip the fetch entirely. Only fetch the metadata branch; trails are always pushed - // to the user's push remote, not the checkpoint remote. - if err := fetchMetadataBranchIfMissing(ctx, checkpointURL); err != nil { - logging.Warn(ctx, "checkpoint-remote: failed to fetch metadata branch", - slog.String("error", err.Error()), - ) + if s.IsCheckpointsV1WriteEnabled() { + // If the checkpoint branch doesn't exist locally, try to fetch it from the URL. + // This is a one-time operation — once the branch exists locally, subsequent pushes + // skip the fetch entirely. Only fetch the metadata branch; trails are always pushed + // to the user's push remote, not the checkpoint remote. + if err := fetchMetadataBranchIfMissing(ctx, checkpointURL); err != nil { + logging.Warn(ctx, "checkpoint-remote: failed to fetch metadata branch", + slog.String("error", err.Error()), + ) + } } - // Also fetch v2 /main ref if push_v2_refs is enabled + // Also fetch v2 /main ref if v2 refs are enabled if s.IsPushV2RefsEnabled() { if err := fetchV2MainRefIfMissing(ctx, checkpointURL); err != nil { logging.Warn(ctx, "checkpoint-remote: failed to fetch v2 /main ref", diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index c5a2830ab7..37b23f137e 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -253,21 +253,34 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re compactTranscriptDuration := buildCompactTranscript(ctx, ag, redactedTranscript, state, &writeOpts) - // Write checkpoint metadata to v1 branch + v2Only := settings.IsCheckpointsV2OnlyEnabled(ctx) + + // Write checkpoint metadata to the primary store. writeV1Start := time.Now() writeCtx, writeCommittedSpan := perf.Start(ctx, "write_committed_v1") - if err := store.WriteCommitted(writeCtx, writeOpts); err != nil { - writeCommittedSpan.RecordError(err) - writeCommittedSpan.End() - return nil, fmt.Errorf("failed to write checkpoint metadata: %w", err) + if settings.IsCheckpointsV1WriteEnabled(ctx) { + if err := store.WriteCommitted(writeCtx, writeOpts); err != nil { + writeCommittedSpan.RecordError(err) + writeCommittedSpan.End() + return nil, fmt.Errorf("failed to write checkpoint metadata: %w", err) + } } writeCommittedSpan.End() writeV1Duration := time.Since(writeV1Start) writeV2Start := time.Now() writeV2Ctx, writeCommittedV2Span := perf.Start(ctx, "write_committed_v2") - writeCommittedV2IfEnabled(writeV2Ctx, repo, writeOpts) - writeTaskMetadataV2IfEnabled(writeV2Ctx, repo, checkpointID, state.SessionID, ref) + if v2Only { + if err := writeCommittedV2(writeV2Ctx, repo, writeOpts); err != nil { + writeCommittedV2Span.RecordError(err) + writeCommittedV2Span.End() + return nil, fmt.Errorf("failed to write checkpoint metadata to v2: %w", err) + } + writeTaskMetadataV2IfEnabled(writeV2Ctx, repo, checkpointID, state.SessionID, ref) + } else { + writeCommittedV2IfEnabled(writeV2Ctx, repo, writeOpts) + writeTaskMetadataV2IfEnabled(writeV2Ctx, repo, checkpointID, state.SessionID, ref) + } writeCommittedV2Span.End() writeV2Duration := time.Since(writeV2Start) @@ -1348,13 +1361,16 @@ func computeCompactTranscriptStart(ctx context.Context, ag agent.Agent, state *S // writeCommittedV2IfEnabled writes checkpoint data to v2 refs when checkpoints_v2 // is enabled in settings. Failures are logged as warnings — v2 writes are // best-effort during the dual-write period and must not block the v1 path. +func writeCommittedV2(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) error { + v2Store := cpkg.NewV2GitStore(repo, ResolveCheckpointURL(ctx, "origin")) + return v2Store.WriteCommitted(ctx, opts) +} + func writeCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) { if !settings.IsCheckpointsV2Enabled(ctx) { return } - - v2Store := cpkg.NewV2GitStore(repo, ResolveCheckpointURL(ctx, "origin")) - if err := v2Store.WriteCommitted(ctx, opts); err != nil { + if err := writeCommittedV2(ctx, repo, opts); err != nil { logging.Warn(ctx, "v2 dual-write failed", slog.String("checkpoint_id", opts.CheckpointID.String()), slog.String("error", err.Error()), diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index fcbc9c94fe..e0c42b6d0c 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2661,6 +2661,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s } store := checkpoint.NewGitStore(repo) + v2Only := settings.IsCheckpointsV2OnlyEnabled(logCtx) // Evaluate v2 flag once before the loop to avoid re-reading settings per checkpoint var v2Store *checkpoint.V2GitStore @@ -2692,7 +2693,18 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s if v2Store != nil && redactedTranscript.Len() > 0 { finalAg, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil for unknown agent types; compactTranscriptForV2 handles nil startLine := 0 - if content, readErr := store.ReadSessionContentByID(ctx, cpID, state.SessionID); readErr == nil && content != nil { + if v2Only { + if summary, readErr := v2Store.ReadCommitted(ctx, cpID); readErr == nil && summary != nil { + for sessionIndex := range summary.Sessions { + if content, contentErr := v2Store.ReadSessionContent(ctx, cpID, sessionIndex); contentErr == nil && content != nil { + if content.Metadata.SessionID == state.SessionID { + startLine = content.Metadata.GetTranscriptStart() + break + } + } + } + } + } else if content, readErr := store.ReadSessionContentByID(ctx, cpID, state.SessionID); readErr == nil && content != nil { startLine = content.Metadata.GetTranscriptStart() } else { errMsg := "unknown" @@ -2708,23 +2720,32 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s updateOpts.CompactTranscript = compactTranscriptForV2(logCtx, finalAg, redactedTranscript, startLine) } - updateErr := store.UpdateCommitted(ctx, updateOpts) - if updateErr != nil { - logging.Warn(logCtx, "finalize: failed to update checkpoint", - slog.String("checkpoint_id", cpIDStr), - slog.String("error", updateErr.Error()), - ) - errCount++ - continue + if settings.IsCheckpointsV1WriteEnabled(ctx) { + updateErr := store.UpdateCommitted(ctx, updateOpts) + if updateErr != nil { + logging.Warn(logCtx, "finalize: failed to update checkpoint", + slog.String("checkpoint_id", cpIDStr), + slog.String("error", updateErr.Error()), + ) + errCount++ + continue + } } - // Dual-write: update v2 refs when enabled if v2Store != nil { if v2Err := v2Store.UpdateCommitted(logCtx, updateOpts); v2Err != nil { - logging.Warn(logCtx, "v2 dual-write update failed", + label := "v2 dual-write update failed" + if v2Only { + label = "finalize: failed to update checkpoint in v2" + errCount++ + } + logging.Warn(logCtx, label, slog.String("checkpoint_id", cpIDStr), slog.String("error", v2Err.Error()), ) + if v2Only { + continue + } } } diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index 36c873f2b6..423ef4ea2a 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -27,14 +27,17 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error return nil } - _, pushCheckpointsSpan := perf.Start(ctx, "push_checkpoints_branch") - err := pushBranchIfNeeded(ctx, ps.pushTarget(), paths.MetadataBranchName) - if err != nil { - pushCheckpointsSpan.RecordError(err) + var err error + if settings.IsCheckpointsV1WriteEnabled(ctx) { + _, pushCheckpointsSpan := perf.Start(ctx, "push_checkpoints_branch") + err = pushBranchIfNeeded(ctx, ps.pushTarget(), paths.MetadataBranchName) + if err != nil { + pushCheckpointsSpan.RecordError(err) + } + pushCheckpointsSpan.End() } - pushCheckpointsSpan.End() - // Push v2 refs when both checkpoints_v2 and push_v2_refs are enabled + // Push v2 refs when enabled. if settings.IsPushV2RefsEnabled(ctx) { _, pushV2Span := perf.Start(ctx, "push_v2_refs") pushV2Refs(ctx, ps.pushTarget()) diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 889e203fd5..bf178645e9 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -120,6 +120,9 @@ func printCheckpointRemoteHint(target string) { // settingsHintOnce ensures the settings commit hint prints at most once per process. var settingsHintOnce sync.Once +// v2OnlyMigrationHintOnce ensures the v2-only migration hint prints at most once per process. +var v2OnlyMigrationHintOnce sync.Once + // printSettingsCommitHint prints a hint after a successful checkpoint remote push // when the committed .entire/settings.json does not contain a checkpoint_remote config. // entire.io discovers the external checkpoint repo by reading the committed project @@ -139,6 +142,19 @@ func printSettingsCommitHint(ctx context.Context, target string) { }) } +// printCheckpointsV2OnlyMigrationHint prints a hint when the committed project +// settings enable checkpoints_v2_only. That mode disables v1 dual-write, so +// existing v1 checkpoints should be migrated to v2. +func printCheckpointsV2OnlyMigrationHint(ctx context.Context) { + v2OnlyMigrationHintOnce.Do(func() { + if !isCheckpointsV2OnlyCommitted(ctx) { + return + } + fmt.Fprintln(os.Stderr, `[entire] Note: .entire/settings.json enables checkpoints_v2_only. Run 'entire migrate --checkpoints "v2"' to migrate existing checkpoints to v2.`) + fmt.Fprintln(os.Stderr, `[entire] Use 'entire migrate --checkpoints "v2" --force' to rewrite all checkpoints in v2.`) + }) +} + // isCheckpointRemoteCommitted returns true if the committed .entire/settings.json // at HEAD contains a valid checkpoint_remote configuration. This is the true // discoverability check: entire.io reads from committed project settings, not from @@ -157,6 +173,21 @@ func isCheckpointRemoteCommitted(ctx context.Context) bool { return committed.GetCheckpointRemote() != nil } +// isCheckpointsV2OnlyCommitted returns true if the committed .entire/settings.json +// at HEAD enables checkpoints_v2_only. +func isCheckpointsV2OnlyCommitted(ctx context.Context) bool { + cmd := exec.CommandContext(ctx, "git", "show", "HEAD:.entire/settings.json") + output, err := cmd.Output() + if err != nil { + return false + } + committed, err := settings.LoadFromBytes(output) + if err != nil { + return false + } + return committed.IsCheckpointsV2OnlyEnabled() +} + // pushResult describes what happened during a push attempt. type pushResult struct { // upToDate is true when the remote already had all commits (nothing transferred). @@ -189,6 +220,7 @@ func finishPush(ctx context.Context, stop func(string), result pushResult, targe stop(" done") printSettingsCommitHint(ctx, target) } + printCheckpointsV2OnlyMigrationHint(ctx) } // tryPushSessionsCommon attempts to push the sessions branch. diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index 5c132ae26a..4659521807 100644 --- a/cmd/entire/cli/strategy/push_common_test.go +++ b/cmd/entire/cli/strategy/push_common_test.go @@ -1203,6 +1203,95 @@ func TestPrintSettingsCommitHint(t *testing.T) { }) } +func TestIsCheckpointsV2OnlyCommitted(t *testing.T) { + t.Run("false when settings.json not committed", func(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") + + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(`{"strategy_options":{"checkpoints_v2_only":true}}`), 0o644)) + + t.Chdir(tmpDir) + assert.False(t, isCheckpointsV2OnlyCommitted(context.Background())) + }) + + t.Run("true when checkpoints_v2_only is committed", func(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") + + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(`{"strategy_options":{"checkpoints_v2_only":true}}`), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "enable checkpoints_v2_only") + + t.Chdir(tmpDir) + assert.True(t, isCheckpointsV2OnlyCommitted(context.Background())) + }) +} + +func TestPrintCheckpointsV2OnlyMigrationHint(t *testing.T) { + t.Run("prints when checkpoints_v2_only is committed", func(t *testing.T) { + v2OnlyMigrationHintOnce = sync.Once{} + + 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") + + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(`{"strategy_options":{"checkpoints_v2_only":true}}`), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "enable checkpoints_v2_only") + t.Chdir(tmpDir) + + restore := captureStderr(t) + printCheckpointsV2OnlyMigrationHint(context.Background()) + output := restore() + + assert.Contains(t, output, `entire migrate --checkpoints "v2"`) + assert.Contains(t, output, `entire migrate --checkpoints "v2" --force`) + }) + + t.Run("prints only once per process", func(t *testing.T) { + v2OnlyMigrationHintOnce = sync.Once{} + + 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") + + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(`{"strategy_options":{"checkpoints_v2_only":true}}`), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "enable checkpoints_v2_only") + t.Chdir(tmpDir) + + restore := captureStderr(t) + printCheckpointsV2OnlyMigrationHint(context.Background()) + printCheckpointsV2OnlyMigrationHint(context.Background()) + output := restore() + + count := bytes.Count([]byte(output), []byte(`entire migrate --checkpoints "v2"`)) + assert.Equal(t, 2, count, "expected both migrate and migrate --force lines exactly once") + }) +} + // captureStderr redirects os.Stderr to a pipe and returns a function that restores // stderr and returns the captured output. Must be called on the main goroutine // (not parallel-safe). Uses t.Cleanup as a safety net to restore stderr and close From 5486301240ddf3e5e08b1f57553ffc8d9cc3bd82 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:20:11 -0700 Subject: [PATCH 2/7] Clean up v2 only check Entire-Checkpoint: c3583ee7d00f --- cmd/entire/cli/attach.go | 18 +++++----- cmd/entire/cli/checkpoint/v2_read.go | 26 ++++++++++++++ .../integration_test/v2_dual_write_test.go | 27 ++++++--------- .../cli/integration_test/v2_push_test.go | 16 ++++----- cmd/entire/cli/settings/settings.go | 4 ++- cmd/entire/cli/settings/settings_test.go | 27 --------------- cmd/entire/cli/strategy/checkpoint_remote.go | 4 ++- .../strategy/manual_commit_condensation.go | 11 +++--- .../cli/strategy/manual_commit_hooks.go | 34 ++++++++----------- cmd/entire/cli/strategy/push_common.go | 4 +-- cmd/entire/cli/strategy/push_common_test.go | 10 +++--- 11 files changed, 89 insertions(+), 92 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index a0b5d82be0..e726a6a08a 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -170,18 +170,20 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ writeOpts.CompactTranscript = compacted } - if settings.IsCheckpointsV2OnlyEnabled(logCtx) { - if err := writeAttachCheckpointV2(logCtx, repo, writeOpts); err != nil { - return fmt.Errorf("failed to write checkpoint to v2: %w", err) - } - } else { + v2Only := settings.IsCheckpointsV2OnlyEnabled(logCtx) + if !v2Only { if err := store.WriteCommitted(ctx, writeOpts); err != nil { return fmt.Errorf("failed to write checkpoint: %w", err) } - if settings.IsCheckpointsV2Enabled(logCtx) { - if err := writeAttachCheckpointV2(logCtx, repo, writeOpts); err != nil { - logging.Warn(logCtx, "attach v2 dual-write failed", "error", err) + } + // IsCheckpointsV2Enabled is true whenever v2Only is true, so this covers both + // the v2-only and dual-write paths. Only v2-only propagates the error. + if settings.IsCheckpointsV2Enabled(logCtx) { + if err := writeAttachCheckpointV2(logCtx, repo, writeOpts); err != nil { + if v2Only { + return fmt.Errorf("failed to write checkpoint to v2: %w", err) } + logging.Warn(logCtx, "attach v2 dual-write failed", "error", err) } } diff --git a/cmd/entire/cli/checkpoint/v2_read.go b/cmd/entire/cli/checkpoint/v2_read.go index ec011ea5c9..62fe9c82d9 100644 --- a/cmd/entire/cli/checkpoint/v2_read.go +++ b/cmd/entire/cli/checkpoint/v2_read.go @@ -495,6 +495,32 @@ func readTranscriptFromObjectTree(tree *object.Tree, agentType types.AgentType) return nil, nil } +// ReadSessionContentByID finds the session with the given sessionID in a checkpoint +// and returns its content. Mirrors GitStore.ReadSessionContentByID for v2 refs. +// Returns ErrCheckpointNotFound if the checkpoint doesn't exist; returns a wrapped +// error if no session in the checkpoint matches sessionID. +func (s *V2GitStore) ReadSessionContentByID(ctx context.Context, checkpointID id.CheckpointID, sessionID string) (*SessionContent, error) { + summary, err := s.ReadCommitted(ctx, checkpointID) + if err != nil { + return nil, err + } + if summary == nil { + return nil, ErrCheckpointNotFound + } + + for i := range summary.Sessions { + content, readErr := s.ReadSessionContent(ctx, checkpointID, i) + if readErr != nil { + continue + } + if content != nil && content.Metadata.SessionID == sessionID { + return content, nil + } + } + + return nil, fmt.Errorf("session %q not found in checkpoint %s", sessionID, checkpointID) +} + // GetSessionLog reads the latest session's raw transcript and session ID from v2 refs. // Convenience wrapper matching the GitStore.GetSessionLog signature. func (s *V2GitStore) GetSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string, error) { diff --git a/cmd/entire/cli/integration_test/v2_dual_write_test.go b/cmd/entire/cli/integration_test/v2_dual_write_test.go index 85b0cc154c..d491fcc944 100644 --- a/cmd/entire/cli/integration_test/v2_dual_write_test.go +++ b/cmd/entire/cli/integration_test/v2_dual_write_test.go @@ -245,7 +245,10 @@ func TestV2DualWrite_StopTimeFinalization(t *testing.T) { assert.Contains(t, compactTranscript, `"v":1`) } -func TestV2Only_FullWorkflow(t *testing.T) { +// TestV2Only_SkipsV1Write verifies the v2-only specific deltas: v1 metadata is +// not written and v2 refs still exist. The full v2 payload shape is already +// covered by TestV2DualWrite_FullWorkflow. +func TestV2Only_SkipsV1Write(t *testing.T) { t.Parallel() env := NewTestEnv(t) defer env.Cleanup() @@ -263,16 +266,14 @@ func TestV2Only_FullWorkflow(t *testing.T) { }) session := env.NewSession() - err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add greeting function") - require.NoError(t, err) + require.NoError(t, env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add greeting function")) env.WriteFile("greet.go", "package main\n\nfunc Greet() string { return \"hello\" }") session.CreateTranscript( "Add greeting function", []FileChange{{Path: "greet.go", Content: "package main\n\nfunc Greet() string { return \"hello\" }"}}, ) - err = env.SimulateStop(session.ID, session.TranscriptPath) - require.NoError(t, err) + require.NoError(t, env.SimulateStop(session.ID, session.TranscriptPath)) env.GitCommitWithShadowHooks("Add greeting function", "greet.go") @@ -283,20 +284,12 @@ func TestV2Only_FullWorkflow(t *testing.T) { require.NoError(t, err) cpPath := cpID.Path() + // v1: should NOT be written. _, found := env.ReadFileFromBranch(paths.MetadataBranchName, cpPath+"/"+paths.MetadataFileName) assert.False(t, found, "v1 committed checkpoint metadata should NOT exist when checkpoints_v2_only is enabled") - assert.True(t, env.RefExists(paths.V2MainRefName), - "v2 /main ref should exist") - assert.True(t, env.RefExists(paths.V2FullCurrentRefName), - "v2 /full/current ref should exist") - - mainSummary, found := env.ReadFileFromRef(paths.V2MainRefName, cpPath+"/"+paths.MetadataFileName) - require.True(t, found, "v2 /main root metadata.json should exist") - assert.Contains(t, mainSummary, cpIDStr) - - fullTranscript, found := env.ReadFileFromRef(paths.V2FullCurrentRefName, cpPath+"/0/"+paths.V2RawTranscriptFileName) - require.True(t, found, "raw_transcript should exist on v2 /full/current") - assert.Contains(t, fullTranscript, "Greet") + // v2: smoke check that the checkpoint still landed. + assert.True(t, env.RefExists(paths.V2MainRefName), "v2 /main ref should exist") + assert.True(t, env.RefExists(paths.V2FullCurrentRefName), "v2 /full/current ref should exist") } diff --git a/cmd/entire/cli/integration_test/v2_push_test.go b/cmd/entire/cli/integration_test/v2_push_test.go index 4482f634d8..d42d22358e 100644 --- a/cmd/entire/cli/integration_test/v2_push_test.go +++ b/cmd/entire/cli/integration_test/v2_push_test.go @@ -124,6 +124,9 @@ func TestV2Push_Disabled_NoV2Refs(t *testing.T) { "v1 metadata branch should still exist on remote") } +// TestV2Push_V2OnlySkipsV1Branch verifies that the v1 metadata branch is not +// pushed when checkpoints_v2_only is enabled; v2 ref pushing itself is covered +// by TestV2Push_FullCycle. func TestV2Push_V2OnlySkipsV1Branch(t *testing.T) { t.Parallel() env := NewTestEnv(t) @@ -144,26 +147,23 @@ func TestV2Push_V2OnlySkipsV1Branch(t *testing.T) { bareDir := env.SetupBareRemote() session := env.NewSession() - err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add feature") - require.NoError(t, err) + require.NoError(t, env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add feature")) 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) + require.NoError(t, env.SimulateStop(session.ID, session.TranscriptPath)) env.GitAdd("feature.go") env.GitCommitWithShadowHooks("Add feature") env.RunPrePush("origin") - 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") assert.False(t, bareRefExists(t, bareDir, "refs/heads/"+paths.MetadataBranchName), "v1 metadata branch should NOT exist on remote when checkpoints_v2_only is enabled") + // Smoke: v2 refs still land; full payload asserted in TestV2Push_FullCycle. + assert.True(t, bareRefExists(t, bareDir, paths.V2MainRefName), + "v2 /main ref should exist on remote after push") } diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index b3ebeab7d0..b9fdf2cfbb 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -560,7 +560,9 @@ func IsPushV2RefsEnabled(ctx context.Context) bool { return s.IsPushV2RefsEnabled() } -// IsCheckpointsV1WriteEnabled checks if v1 checkpoint writes should still happen. +// IsCheckpointsV1WriteEnabled reports whether v1 checkpoint writes should still +// happen. Defaults to true (fail-safe: keep writing v1) when settings cannot be +// loaded, so a misconfigured settings file does not silently drop checkpoints. func IsCheckpointsV1WriteEnabled(ctx context.Context) bool { s, err := Load(ctx) if err != nil { diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index f5a801fc3b..45d7d0696f 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -852,33 +852,6 @@ func TestIsPushV2RefsEnabled_RequiresBothFlags(t *testing.T) { } } -func TestIsCheckpointsV1WriteEnabled(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts map[string]any - expected bool - }{ - {"default on", nil, true}, - {"dual write still writes v1", map[string]any{"checkpoints_v2": true}, true}, - {"v2 only disables v1", map[string]any{"checkpoints_v2_only": true}, 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.IsCheckpointsV1WriteEnabled(); got != tt.expected { - t.Errorf("IsCheckpointsV1WriteEnabled() = %v, want %v", got, tt.expected) - } - }) - } -} - func TestIsFilteredFetchesEnabled_DefaultsFalse(t *testing.T) { t.Parallel() s := &EntireSettings{Enabled: true} diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index e35a709d86..790284841a 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -119,8 +119,10 @@ func resolvePushSettings(ctx context.Context, pushRemoteName string) pushSetting ps.checkpointURL = checkpointURL + // Skip the v1 metadata-branch fetch entirely in v2-only mode — there is no + // v1 branch being written or pushed, so there is nothing to sync. if s.IsCheckpointsV1WriteEnabled() { - // If the checkpoint branch doesn't exist locally, try to fetch it from the URL. + // If the v1 checkpoint branch doesn't exist locally, try to fetch it from the URL. // This is a one-time operation — once the branch exists locally, subsequent pushes // skip the fetch entirely. Only fetch the metadata branch; trails are always pushed // to the user's push remote, not the checkpoint remote. diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 37e5d83d4e..b99c5df010 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -276,11 +276,10 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re writeCommittedV2Span.End() return nil, fmt.Errorf("failed to write checkpoint metadata to v2: %w", err) } - writeTaskMetadataV2IfEnabled(writeV2Ctx, repo, checkpointID, state.SessionID, ref) } else { writeCommittedV2IfEnabled(writeV2Ctx, repo, writeOpts) - writeTaskMetadataV2IfEnabled(writeV2Ctx, repo, checkpointID, state.SessionID, ref) } + writeTaskMetadataV2IfEnabled(writeV2Ctx, repo, checkpointID, state.SessionID, ref) writeCommittedV2Span.End() writeV2Duration := time.Since(writeV2Start) @@ -1410,14 +1409,16 @@ func computeCompactTranscriptStart(ctx context.Context, ag agent.Agent, state *S return offset } -// writeCommittedV2IfEnabled writes checkpoint data to v2 refs when checkpoints_v2 -// is enabled in settings. Failures are logged as warnings — v2 writes are -// best-effort during the dual-write period and must not block the v1 path. +// writeCommittedV2 writes checkpoint data to v2 refs unconditionally. +// Callers decide whether to propagate or swallow the error (v2-only vs dual-write). func writeCommittedV2(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) error { v2Store := cpkg.NewV2GitStore(repo, ResolveCheckpointURL(ctx, "origin")) return v2Store.WriteCommitted(ctx, opts) } +// writeCommittedV2IfEnabled writes checkpoint data to v2 refs when checkpoints_v2 +// is enabled. Failures are logged as warnings — in dual-write mode v2 writes are +// best-effort and must not block the v1 path. func writeCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) { if !settings.IsCheckpointsV2Enabled(ctx) { return diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index e0c42b6d0c..b0e4a9c15f 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2692,19 +2692,17 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s // Generate compact transcript for v2 /main if v2Store != nil && redactedTranscript.Len() > 0 { finalAg, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil for unknown agent types; compactTranscriptForV2 handles nil - startLine := 0 + var ( + content *checkpoint.SessionContent + readErr error + ) if v2Only { - if summary, readErr := v2Store.ReadCommitted(ctx, cpID); readErr == nil && summary != nil { - for sessionIndex := range summary.Sessions { - if content, contentErr := v2Store.ReadSessionContent(ctx, cpID, sessionIndex); contentErr == nil && content != nil { - if content.Metadata.SessionID == state.SessionID { - startLine = content.Metadata.GetTranscriptStart() - break - } - } - } - } - } else if content, readErr := store.ReadSessionContentByID(ctx, cpID, state.SessionID); readErr == nil && content != nil { + content, readErr = v2Store.ReadSessionContentByID(ctx, cpID, state.SessionID) + } else { + content, readErr = store.ReadSessionContentByID(ctx, cpID, state.SessionID) + } + startLine := 0 + if readErr == nil && content != nil { startLine = content.Metadata.GetTranscriptStart() } else { errMsg := "unknown" @@ -2734,18 +2732,16 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s if v2Store != nil { if v2Err := v2Store.UpdateCommitted(logCtx, updateOpts); v2Err != nil { - label := "v2 dual-write update failed" - if v2Only { - label = "finalize: failed to update checkpoint in v2" - errCount++ - } - logging.Warn(logCtx, label, + attrs := []any{ slog.String("checkpoint_id", cpIDStr), slog.String("error", v2Err.Error()), - ) + } if v2Only { + logging.Warn(logCtx, "finalize: failed to update checkpoint in v2", attrs...) + errCount++ continue } + logging.Warn(logCtx, "v2 dual-write update failed", attrs...) } } diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index bf178645e9..a93c8f6085 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -150,8 +150,8 @@ func printCheckpointsV2OnlyMigrationHint(ctx context.Context) { if !isCheckpointsV2OnlyCommitted(ctx) { return } - fmt.Fprintln(os.Stderr, `[entire] Note: .entire/settings.json enables checkpoints_v2_only. Run 'entire migrate --checkpoints "v2"' to migrate existing checkpoints to v2.`) - fmt.Fprintln(os.Stderr, `[entire] Use 'entire migrate --checkpoints "v2" --force' to rewrite all checkpoints in v2.`) + fmt.Fprintln(os.Stderr, "[entire] Note: .entire/settings.json enables checkpoints_v2_only. Run 'entire migrate --checkpoints v2' to migrate existing checkpoints to v2.") + fmt.Fprintln(os.Stderr, "[entire] Use 'entire migrate --checkpoints v2 --force' to rewrite all checkpoints in v2.") }) } diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index 4659521807..197cd720ac 100644 --- a/cmd/entire/cli/strategy/push_common_test.go +++ b/cmd/entire/cli/strategy/push_common_test.go @@ -1261,8 +1261,8 @@ func TestPrintCheckpointsV2OnlyMigrationHint(t *testing.T) { printCheckpointsV2OnlyMigrationHint(context.Background()) output := restore() - assert.Contains(t, output, `entire migrate --checkpoints "v2"`) - assert.Contains(t, output, `entire migrate --checkpoints "v2" --force`) + assert.Contains(t, output, "entire migrate --checkpoints v2") + assert.Contains(t, output, "entire migrate --checkpoints v2 --force") }) t.Run("prints only once per process", func(t *testing.T) { @@ -1287,8 +1287,10 @@ func TestPrintCheckpointsV2OnlyMigrationHint(t *testing.T) { printCheckpointsV2OnlyMigrationHint(context.Background()) output := restore() - count := bytes.Count([]byte(output), []byte(`entire migrate --checkpoints "v2"`)) - assert.Equal(t, 2, count, "expected both migrate and migrate --force lines exactly once") + // --force appears in exactly one line, so its count equals the number of + // invocations that actually emitted output. + forceCount := bytes.Count([]byte(output), []byte("--force")) + assert.Equal(t, 1, forceCount, "hint should print exactly once per process") }) } From 1e40c9510342f855d33501a9bce05aa678cfe996 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:22:00 -0700 Subject: [PATCH 3/7] Remove changes to settings.json --- .entire/settings.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/.entire/settings.json b/.entire/settings.json index 826c00a434..75d97108dd 100644 --- a/.entire/settings.json +++ b/.entire/settings.json @@ -3,8 +3,6 @@ "local_dev": true, "strategy": "manual-commit", "strategy_options": { - "checkpoints_v2": true, - "push_v2_refs": true, "checkpoint_remote": { "provider": "github", "repo": "entireio/cli-checkpoints" From 869b877bede2260ef87a132f2268ed1868e5c721 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:40:04 -0700 Subject: [PATCH 4/7] Fix settings rereads and conditional checks Entire-Checkpoint: f65e8e778e0c --- cmd/entire/cli/checkpoint/v2_read.go | 5 +++-- cmd/entire/cli/strategy/manual_commit_condensation.go | 2 +- cmd/entire/cli/strategy/manual_commit_hooks.go | 2 +- cmd/entire/cli/strategy/manual_commit_push.go | 7 +++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_read.go b/cmd/entire/cli/checkpoint/v2_read.go index 62fe9c82d9..95dd305ec9 100644 --- a/cmd/entire/cli/checkpoint/v2_read.go +++ b/cmd/entire/cli/checkpoint/v2_read.go @@ -497,8 +497,9 @@ func readTranscriptFromObjectTree(tree *object.Tree, agentType types.AgentType) // ReadSessionContentByID finds the session with the given sessionID in a checkpoint // and returns its content. Mirrors GitStore.ReadSessionContentByID for v2 refs. -// Returns ErrCheckpointNotFound if the checkpoint doesn't exist; returns a wrapped -// error if no session in the checkpoint matches sessionID. +// Returns ErrCheckpointNotFound if the checkpoint doesn't exist; returns a +// non-wrapped error (containing the session ID and checkpoint ID for context) +// if no session in the checkpoint matches sessionID. func (s *V2GitStore) ReadSessionContentByID(ctx context.Context, checkpointID id.CheckpointID, sessionID string) (*SessionContent, error) { summary, err := s.ReadCommitted(ctx, checkpointID) if err != nil { diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index b99c5df010..2d677c17ea 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -258,7 +258,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re // Write checkpoint metadata to the primary store. writeV1Start := time.Now() writeCtx, writeCommittedSpan := perf.Start(ctx, "write_committed_v1") - if settings.IsCheckpointsV1WriteEnabled(ctx) { + if !v2Only { if err := store.WriteCommitted(writeCtx, writeOpts); err != nil { writeCommittedSpan.RecordError(err) writeCommittedSpan.End() diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index b0e4a9c15f..5ab099e370 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2718,7 +2718,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s updateOpts.CompactTranscript = compactTranscriptForV2(logCtx, finalAg, redactedTranscript, startLine) } - if settings.IsCheckpointsV1WriteEnabled(ctx) { + if !v2Only { updateErr := store.UpdateCommitted(ctx, updateOpts) if updateErr != nil { logging.Warn(logCtx, "finalize: failed to update checkpoint", diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index 423ef4ea2a..4e25ae814d 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -9,8 +9,10 @@ import ( ) // 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, -// and v2 refs when both checkpoints_v2 and push_v2_refs are enabled. +// It pushes the entire/checkpoints/v1 branch alongside the user's push (unless +// v1 writes are disabled by checkpoints_v2_only), and pushes v2 refs whenever +// IsPushV2RefsEnabled is true — i.e. either checkpoints_v2 + push_v2_refs, or +// checkpoints_v2_only. // // If a checkpoint_remote is configured in settings, checkpoint branches/refs // are pushed to the derived URL instead of the user's push remote. @@ -19,6 +21,7 @@ import ( // - 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) +// - checkpoints_v2_only: true to skip the v1 metadata branch entirely and force v2 ref pushes on func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error { // Load settings once for remote resolution and push_sessions check ps := resolvePushSettings(ctx, remote) From 7b4b240de35d0a524d7f0a91fb6c56d9d9c9913c Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:49:16 -0700 Subject: [PATCH 5/7] Fix linter errors Entire-Checkpoint: 18fdaa7a417f --- cmd/entire/cli/attach.go | 5 ++++- cmd/entire/cli/hooks_git_cmd_test.go | 1 + cmd/entire/cli/strategy/manual_commit_condensation.go | 5 ++++- cmd/entire/cli/strategy/push_common_test.go | 3 ++- cmd/entire/cli/summarize/summarize_test.go | 4 +--- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index e726a6a08a..e75a01f1c2 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -211,7 +211,10 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ // writeAttachCheckpointV2 writes attach-created checkpoints into the v2 refs. func writeAttachCheckpointV2(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) error { v2Store := cpkg.NewV2GitStore(repo, strategy.ResolveCheckpointURL(ctx, "origin")) - return v2Store.WriteCommitted(ctx, opts) + if err := v2Store.WriteCommitted(ctx, opts); err != nil { + return fmt.Errorf("v2 write committed: %w", err) + } + return nil } // getHeadCommit returns the HEAD commit object. diff --git a/cmd/entire/cli/hooks_git_cmd_test.go b/cmd/entire/cli/hooks_git_cmd_test.go index 790beb30f9..a15f374549 100644 --- a/cmd/entire/cli/hooks_git_cmd_test.go +++ b/cmd/entire/cli/hooks_git_cmd_test.go @@ -256,6 +256,7 @@ func TestHooksGitCmd_ExposesPostRewriteSubcommand(t *testing.T) { } if found == nil { t.Fatal("expected post-rewrite subcommand, got nil") + return } if found.Use != "post-rewrite " { t.Fatalf("post-rewrite Use = %q, want %q", found.Use, "post-rewrite ") diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 2d677c17ea..c051492292 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -1413,7 +1413,10 @@ func computeCompactTranscriptStart(ctx context.Context, ag agent.Agent, state *S // Callers decide whether to propagate or swallow the error (v2-only vs dual-write). func writeCommittedV2(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) error { v2Store := cpkg.NewV2GitStore(repo, ResolveCheckpointURL(ctx, "origin")) - return v2Store.WriteCommitted(ctx, opts) + if err := v2Store.WriteCommitted(ctx, opts); err != nil { + return fmt.Errorf("v2 write committed: %w", err) + } + return nil } // writeCommittedV2IfEnabled writes checkpoint data to v2 refs when checkpoints_v2 diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index 197cd720ac..6d46335ee9 100644 --- a/cmd/entire/cli/strategy/push_common_test.go +++ b/cmd/entire/cli/strategy/push_common_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "testing" @@ -1289,7 +1290,7 @@ func TestPrintCheckpointsV2OnlyMigrationHint(t *testing.T) { // --force appears in exactly one line, so its count equals the number of // invocations that actually emitted output. - forceCount := bytes.Count([]byte(output), []byte("--force")) + forceCount := strings.Count(output, "--force") assert.Equal(t, 1, forceCount, "hint should print exactly once per process") }) } diff --git a/cmd/entire/cli/summarize/summarize_test.go b/cmd/entire/cli/summarize/summarize_test.go index 6ace809356..5238c07234 100644 --- a/cmd/entire/cli/summarize/summarize_test.go +++ b/cmd/entire/cli/summarize/summarize_test.go @@ -790,9 +790,7 @@ func TestBuildCondensedTranscriptFromBytes_Codex_ExecCommandDetail(t *testing.T) break } } - if toolEntry == nil { - t.Fatalf("no tool entry found in entries: %#v", entries) - } + require.NotNil(t, toolEntry, "no tool entry found in entries: %#v", entries) if toolEntry.ToolName != "exec_command" { t.Fatalf("expected exec_command, got %q", toolEntry.ToolName) } From bdc303ec0842d6b87e7433cce7be63f54fbd07c4 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:59:31 -0700 Subject: [PATCH 6/7] Clean up functions Entire-Checkpoint: 626dbb2b44ff --- cmd/entire/cli/settings/settings.go | 17 ----------------- cmd/entire/cli/strategy/checkpoint_remote.go | 2 +- cmd/entire/cli/strategy/manual_commit_push.go | 2 +- 3 files changed, 2 insertions(+), 19 deletions(-) diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index b9fdf2cfbb..3c3c4ff3ee 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -560,17 +560,6 @@ func IsPushV2RefsEnabled(ctx context.Context) bool { return s.IsPushV2RefsEnabled() } -// IsCheckpointsV1WriteEnabled reports whether v1 checkpoint writes should still -// happen. Defaults to true (fail-safe: keep writing v1) when settings cannot be -// loaded, so a misconfigured settings file does not silently drop checkpoints. -func IsCheckpointsV1WriteEnabled(ctx context.Context) bool { - s, err := Load(ctx) - if err != nil { - return true - } - return s.IsCheckpointsV1WriteEnabled() -} - // IsFilteredFetchesEnabled checks if filtered fetches should be used. // When enabled, filtered fetches always resolve remote names to URLs first so // git does not persist promisor settings onto named remotes in local config. @@ -691,12 +680,6 @@ func (s *EntireSettings) IsPushV2RefsEnabled() bool { return ok && val } -// IsCheckpointsV1WriteEnabled reports whether v1 checkpoint writes should still -// happen. checkpoints_v2_only disables the v1 path entirely. -func (s *EntireSettings) IsCheckpointsV1WriteEnabled() bool { - return !s.IsCheckpointsV2OnlyEnabled() -} - // IsFilteredFetchesEnabled checks if fetches should use --filter=blob:none. // When enabled, filtered fetches always use resolved URLs rather than remote // names to avoid persisting promisor settings onto named remotes. diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index 790284841a..e8411fd2e4 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -121,7 +121,7 @@ func resolvePushSettings(ctx context.Context, pushRemoteName string) pushSetting // Skip the v1 metadata-branch fetch entirely in v2-only mode — there is no // v1 branch being written or pushed, so there is nothing to sync. - if s.IsCheckpointsV1WriteEnabled() { + if !s.IsCheckpointsV2OnlyEnabled() { // If the v1 checkpoint branch doesn't exist locally, try to fetch it from the URL. // This is a one-time operation — once the branch exists locally, subsequent pushes // skip the fetch entirely. Only fetch the metadata branch; trails are always pushed diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index 4e25ae814d..c59595620f 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -31,7 +31,7 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error } var err error - if settings.IsCheckpointsV1WriteEnabled(ctx) { + if !settings.IsCheckpointsV2OnlyEnabled(ctx) { _, pushCheckpointsSpan := perf.Start(ctx, "push_checkpoints_branch") err = pushBranchIfNeeded(ctx, ps.pushTarget(), paths.MetadataBranchName) if err != nil { From c6eaba496777f79e5eedbac425c0291f0f9cb04e Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:08:06 -0700 Subject: [PATCH 7/7] Only display the v2 migration tip if there are missing v1 checkpoints in v2 Entire-Checkpoint: 901bb19dd002 --- cmd/entire/cli/strategy/push_common.go | 39 +++++- cmd/entire/cli/strategy/push_common_test.go | 124 ++++++++++++++++---- 2 files changed, 135 insertions(+), 28 deletions(-) diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index a93c8f6085..387269c8db 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/go-git/go-git/v6" @@ -143,18 +144,52 @@ func printSettingsCommitHint(ctx context.Context, target string) { } // printCheckpointsV2OnlyMigrationHint prints a hint when the committed project -// settings enable checkpoints_v2_only. That mode disables v1 dual-write, so -// existing v1 checkpoints should be migrated to v2. +// settings enable checkpoints_v2_only AND there are v1 checkpoints that have +// not yet been mirrored into v2. Suppressed when v2 already has every v1 +// checkpoint (nothing to migrate) so the hint does not become noise once the +// migration is done. func printCheckpointsV2OnlyMigrationHint(ctx context.Context) { v2OnlyMigrationHintOnce.Do(func() { if !isCheckpointsV2OnlyCommitted(ctx) { return } + if !hasUnmigratedV1Checkpoints(ctx) { + return + } fmt.Fprintln(os.Stderr, "[entire] Note: .entire/settings.json enables checkpoints_v2_only. Run 'entire migrate --checkpoints v2' to migrate existing checkpoints to v2.") fmt.Fprintln(os.Stderr, "[entire] Use 'entire migrate --checkpoints v2 --force' to rewrite all checkpoints in v2.") }) } +// hasUnmigratedV1Checkpoints reports whether any v1 checkpoint has no matching +// entry in v2. Any failure opening the repo or listing either store is treated +// as "no migration needed" so we stay silent instead of printing a speculative +// hint — the hint is advisory and should never be the reason a push gets noisy. +func hasUnmigratedV1Checkpoints(ctx context.Context) bool { + repo, err := OpenRepository(ctx) + if err != nil { + return false + } + v1List, err := checkpoint.NewGitStore(repo).ListCommitted(ctx) + if err != nil || len(v1List) == 0 { + return false + } + v2List, err := checkpoint.NewV2GitStore(repo, "").ListCommitted(ctx) + if err != nil { + return false + } + v2Set := make(map[string]struct{}, len(v2List)) + for _, info := range v2List { + v2Set[info.CheckpointID.String()] = struct{}{} + } + for _, info := range v1List { + if _, ok := v2Set[info.CheckpointID.String()]; !ok { + return true + } + } + return false +} + // isCheckpointRemoteCommitted returns true if the committed .entire/settings.json // at HEAD contains a valid checkpoint_remote configuration. This is the true // discoverability check: entire.io reads from committed project settings, not from diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index 6d46335ee9..ae8c279a27 100644 --- a/cmd/entire/cli/strategy/push_common_test.go +++ b/cmd/entire/cli/strategy/push_common_test.go @@ -11,8 +11,10 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/redact" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" @@ -1240,23 +1242,77 @@ func TestIsCheckpointsV2OnlyCommitted(t *testing.T) { }) } +// setupV2OnlyCommittedRepo creates a temp repo with checkpoints_v2_only enabled +// in the committed .entire/settings.json and chdirs into it. Returns an opened +// *git.Repository for populating checkpoints. +func setupV2OnlyCommittedRepo(t *testing.T) *git.Repository { + 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") + + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(`{"strategy_options":{"checkpoints_v2_only":true}}`), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "enable checkpoints_v2_only") + t.Chdir(tmpDir) + + repo, err := git.PlainOpen(tmpDir) + require.NoError(t, err) + return repo +} + +// writeV1Checkpoint writes a minimal checkpoint to the v1 metadata branch. +func writeV1Checkpoint(t *testing.T, repo *git.Repository, cpID id.CheckpointID, sessionID string) { + t.Helper() + err := checkpoint.NewGitStore(repo).WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: sessionID, + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte(`{"from":"` + sessionID + `"}`)), + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) +} + func TestPrintCheckpointsV2OnlyMigrationHint(t *testing.T) { - t.Run("prints when checkpoints_v2_only is committed", func(t *testing.T) { + t.Run("suppressed when no v1 checkpoints exist", func(t *testing.T) { v2OnlyMigrationHintOnce = sync.Once{} + setupV2OnlyCommittedRepo(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") + restore := captureStderr(t) + printCheckpointsV2OnlyMigrationHint(context.Background()) + output := restore() - entireDir := filepath.Join(tmpDir, ".entire") - require.NoError(t, os.MkdirAll(entireDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), - []byte(`{"strategy_options":{"checkpoints_v2_only":true}}`), 0o644)) - testutil.GitAdd(t, tmpDir, ".entire/settings.json") - testutil.GitCommit(t, tmpDir, "enable checkpoints_v2_only") - t.Chdir(tmpDir) + assert.Empty(t, output, "hint should not print when there are no v1 checkpoints to migrate") + }) + + t.Run("suppressed when every v1 checkpoint is already in v2", func(t *testing.T) { + v2OnlyMigrationHintOnce = sync.Once{} + repo := setupV2OnlyCommittedRepo(t) + + cpID := id.MustCheckpointID("aabbccddeeff") + writeV1Checkpoint(t, repo, cpID, "session-1") + writeV2Checkpoint(t, repo, cpID, "session-1") + + restore := captureStderr(t) + printCheckpointsV2OnlyMigrationHint(context.Background()) + output := restore() + + assert.Empty(t, output, "hint should not print once v2 already mirrors every v1 checkpoint") + }) + + t.Run("prints when v1 has checkpoints not in v2", func(t *testing.T) { + v2OnlyMigrationHintOnce = sync.Once{} + repo := setupV2OnlyCommittedRepo(t) + + writeV1Checkpoint(t, repo, id.MustCheckpointID("111111111111"), "session-1") restore := captureStderr(t) printCheckpointsV2OnlyMigrationHint(context.Background()) @@ -1268,20 +1324,9 @@ func TestPrintCheckpointsV2OnlyMigrationHint(t *testing.T) { t.Run("prints only once per process", func(t *testing.T) { v2OnlyMigrationHintOnce = sync.Once{} + repo := setupV2OnlyCommittedRepo(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") - - entireDir := filepath.Join(tmpDir, ".entire") - require.NoError(t, os.MkdirAll(entireDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), - []byte(`{"strategy_options":{"checkpoints_v2_only":true}}`), 0o644)) - testutil.GitAdd(t, tmpDir, ".entire/settings.json") - testutil.GitCommit(t, tmpDir, "enable checkpoints_v2_only") - t.Chdir(tmpDir) + writeV1Checkpoint(t, repo, id.MustCheckpointID("222222222222"), "session-2") restore := captureStderr(t) printCheckpointsV2OnlyMigrationHint(context.Background()) @@ -1295,6 +1340,33 @@ func TestPrintCheckpointsV2OnlyMigrationHint(t *testing.T) { }) } +func TestHasUnmigratedV1Checkpoints(t *testing.T) { + t.Run("false when no v1 checkpoints exist", func(t *testing.T) { + setupV2OnlyCommittedRepo(t) + assert.False(t, hasUnmigratedV1Checkpoints(context.Background())) + }) + + t.Run("false when every v1 checkpoint is in v2", func(t *testing.T) { + repo := setupV2OnlyCommittedRepo(t) + cpID := id.MustCheckpointID("333333333333") + writeV1Checkpoint(t, repo, cpID, "session-a") + writeV2Checkpoint(t, repo, cpID, "session-a") + + assert.False(t, hasUnmigratedV1Checkpoints(context.Background())) + }) + + t.Run("true when at least one v1 checkpoint is missing from v2", func(t *testing.T) { + repo := setupV2OnlyCommittedRepo(t) + mirrored := id.MustCheckpointID("444444444444") + missing := id.MustCheckpointID("555555555555") + writeV1Checkpoint(t, repo, mirrored, "session-b") + writeV2Checkpoint(t, repo, mirrored, "session-b") + writeV1Checkpoint(t, repo, missing, "session-c") + + assert.True(t, hasUnmigratedV1Checkpoints(context.Background())) + }) +} + // captureStderr redirects os.Stderr to a pipe and returns a function that restores // stderr and returns the captured output. Must be called on the main goroutine // (not parallel-safe). Uses t.Cleanup as a safety net to restore stderr and close