From 83bbbfcc0d5c20745e7a11b3e2dc449ab9c9401a Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 23 Jun 2026 14:34:16 -0700 Subject: [PATCH 1/4] enforce checkpoint policy during writes Read the repo checkpoint policy before committed checkpoint writes and skip unsupported hook writes without failing ordinary git operations. Refresh policy during pre-push, warn user-driven commands when the local policy requires a newer CLI, and document the offline/online behavior. Entire-Checkpoint: 8712032f8b90 --- cmd/entire/cli/attach.go | 4 + cmd/entire/cli/attach_test.go | 34 ++++++ cmd/entire/cli/checkpoint_policy_warning.go | 51 +++++++++ .../cli/checkpoint_policy_warning_test.go | 54 +++++++++ cmd/entire/cli/checkpoint_policy_write.go | 26 +++++ cmd/entire/cli/checkpointpolicy/policy.go | 22 ++++ .../cli/checkpointpolicy/warning_test.go | 47 ++++++++ cmd/entire/cli/explain.go | 7 +- cmd/entire/cli/explain_test.go | 94 ++++++++++++---- cmd/entire/cli/strategy/checkpoint_policy.go | 100 +++++++++++++++++ .../cli/strategy/checkpoint_policy_test.go | 104 ++++++++++++++++++ .../strategy/manual_commit_condensation.go | 3 + .../cli/strategy/manual_commit_hooks.go | 4 + cmd/entire/cli/strategy/manual_commit_push.go | 3 + cmd/entire/cli/versioncheck/versioncheck.go | 7 ++ .../cli/versioncheck/versioncheck_test.go | 52 ++++++++- cmd/entire/main.go | 3 + docs/architecture/sessions-and-checkpoints.md | 35 ++++++ 18 files changed, 626 insertions(+), 24 deletions(-) create mode 100644 cmd/entire/cli/checkpoint_policy_warning.go create mode 100644 cmd/entire/cli/checkpoint_policy_warning_test.go create mode 100644 cmd/entire/cli/checkpoint_policy_write.go create mode 100644 cmd/entire/cli/checkpointpolicy/warning_test.go create mode 100644 cmd/entire/cli/strategy/checkpoint_policy.go create mode 100644 cmd/entire/cli/strategy/checkpoint_policy_test.go diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 985d3ee3f4..226fc5152c 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -228,6 +228,10 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ return nil } + if err := ensureCommittedCheckpointWritePolicy(ctx, repo); err != nil { + return err + } + // Resolve agent and transcript path. ag, transcriptPath, err := resolveAgentAndTranscript(logCtx, w, sessionID, agentName, existingState) if err != nil { diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index a2556b3cfe..a2ba9b1355 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -21,6 +21,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/types" cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" "github.com/entireio/cli/cmd/entire/cli/paths" cliReview "github.com/entireio/cli/cmd/entire/cli/review" "github.com/entireio/cli/cmd/entire/cli/session" @@ -68,6 +69,39 @@ func TestAttach_TranscriptNotFound(t *testing.T) { } } +func TestAttachRejectsUnsupportedCheckpointWritePolicy(t *testing.T) { + setupAttachTestRepo(t) + + repoRoot := mustGetwd(t) + repo, err := git.PlainOpen(repoRoot) + if err != nil { + t.Fatal(err) + } + if _, err := checkpointpolicy.WriteLocal(context.Background(), repo, plumbing.ZeroHash, checkpointpolicy.Policy{ + CheckpointVersion: "refs-v1", + CheckpointMinVersion: "branch-v1", + }); err != nil { + t.Fatal(err) + } + + sessionID := "test-attach-policy-unsupported" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"create a file"},"uuid":"uuid-1"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done"}]},"uuid":"uuid-2"} +`) + + var out bytes.Buffer + err = runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, attachOptions{Force: true}) + if err == nil { + t.Fatal("expected unsupported checkpoint policy error") + } + if !strings.Contains(err.Error(), `checkpoint_version "refs-v1"`) { + t.Fatalf("error = %v, want checkpoint policy version", err) + } + if _, refErr := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true); refErr == nil { + t.Fatal("metadata branch exists after rejected attach") + } +} + func TestAttach_Success(t *testing.T) { setupAttachTestRepo(t) diff --git a/cmd/entire/cli/checkpoint_policy_warning.go b/cmd/entire/cli/checkpoint_policy_warning.go new file mode 100644 index 0000000000..387dc18566 --- /dev/null +++ b/cmd/entire/cli/checkpoint_policy_warning.go @@ -0,0 +1,51 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" + "github.com/entireio/cli/cmd/entire/cli/gitrepo" + "github.com/entireio/cli/cmd/entire/cli/versioncheck" + "github.com/spf13/cobra" +) + +func ShouldCheckCheckpointPolicyWarning(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + for c := cmd; c != nil; c = c.Parent() { + if isCheckpointPolicyWarningExcludedCommand(c.Name()) { + return false + } + } + return true +} + +func isCheckpointPolicyWarningExcludedCommand(name string) bool { + switch name { + case "hooks", "__send_analytics", "curl-bash-post-install": + return true + default: + return false + } +} + +func WarnCheckpointPolicyIfNeeded(ctx context.Context, w io.Writer, currentVersion string) { + repo, err := gitrepo.OpenCurrent(ctx) + if err != nil { + return + } + defer repo.Close() + + state, err := checkpointpolicy.ReadLocal(ctx, repo) + if err != nil { + return + } + if !checkpointpolicy.RequiresUpgrade(state.Policy) && !checkpointpolicy.UnsupportedWrite(state.Policy) { + return + } + + fmt.Fprint(w, checkpointpolicy.UpgradeWarning(versioncheck.UpdateCommandForCurrentBinary(currentVersion))) +} diff --git a/cmd/entire/cli/checkpoint_policy_warning_test.go b/cmd/entire/cli/checkpoint_policy_warning_test.go new file mode 100644 index 0000000000..59985b91f3 --- /dev/null +++ b/cmd/entire/cli/checkpoint_policy_warning_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "bytes" + "context" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestWarnCheckpointPolicyIfNeeded(t *testing.T) { + _, _ = setupPolicyCheckpointRepo(t) + repo, err := git.PlainOpen(".") + require.NoError(t, err) + t.Cleanup(func() { + _ = repo.Close() + }) + _, err = checkpointpolicy.WriteLocal(t.Context(), repo, plumbing.ZeroHash, checkpointpolicy.Policy{ + CheckpointVersion: "refs-v1", + CheckpointMinVersion: "refs-v1", + }) + require.NoError(t, err) + + var buf bytes.Buffer + WarnCheckpointPolicyIfNeeded(context.Background(), &buf, "1.0.0") + + require.Contains(t, buf.String(), "requires checkpoint support newer than this Entire CLI") +} + +func TestShouldCheckCheckpointPolicyWarning(t *testing.T) { + root := &cobra.Command{Use: "entire"} + visible := &cobra.Command{Use: "status"} + root.AddCommand(visible) + + hooks := &cobra.Command{Use: "hooks", Hidden: true} + gitHook := &cobra.Command{Use: "git"} + hooks.AddCommand(gitHook) + root.AddCommand(hooks) + + hiddenAlias := &cobra.Command{Use: "explain", Hidden: true} + root.AddCommand(hiddenAlias) + + sendAnalytics := &cobra.Command{Use: "__send_analytics", Hidden: true} + root.AddCommand(sendAnalytics) + + require.True(t, ShouldCheckCheckpointPolicyWarning(visible)) + require.True(t, ShouldCheckCheckpointPolicyWarning(hiddenAlias)) + require.False(t, ShouldCheckCheckpointPolicyWarning(gitHook)) + require.False(t, ShouldCheckCheckpointPolicyWarning(sendAnalytics)) +} diff --git a/cmd/entire/cli/checkpoint_policy_write.go b/cmd/entire/cli/checkpoint_policy_write.go new file mode 100644 index 0000000000..85a3954b92 --- /dev/null +++ b/cmd/entire/cli/checkpoint_policy_write.go @@ -0,0 +1,26 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" + "github.com/entireio/cli/cmd/entire/cli/versioncheck" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" + "github.com/go-git/go-git/v6" +) + +func ensureCommittedCheckpointWritePolicy(ctx context.Context, repo *git.Repository) error { + state, err := checkpointpolicy.ReadLocal(ctx, repo) + if err != nil { + return fmt.Errorf("read checkpoint policy: %w", err) + } + if !checkpointpolicy.UnsupportedWrite(state.Policy) { + return nil + } + return fmt.Errorf( + "checkpoint policy requires checkpoint_version %q, which this Entire CLI cannot write; upgrade Entire and rerun the command: %s", + state.Policy.CheckpointVersion, + versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version), + ) +} diff --git a/cmd/entire/cli/checkpointpolicy/policy.go b/cmd/entire/cli/checkpointpolicy/policy.go index 26461f7940..584c94c139 100644 --- a/cmd/entire/cli/checkpointpolicy/policy.go +++ b/cmd/entire/cli/checkpointpolicy/policy.go @@ -52,3 +52,25 @@ func ValidatePolicy(policy Policy) error { return nil } + +func RequiresUpgrade(policy Policy) bool { + policy = Normalize(policy) + minVersion, err := ParseFormat(policy.CheckpointMinVersion) + if err != nil { + return true + } + return !CanRead(minVersion) +} + +func UnsupportedWrite(policy Policy) bool { + policy = Normalize(policy) + version, err := ParseFormat(policy.CheckpointVersion) + if err != nil { + return true + } + return !CanWrite(version) +} + +func UpgradeWarning(updateCommand string) string { + return fmt.Sprintf("[entire] This repository requires checkpoint support newer than this Entire CLI.\n[entire] Upgrade Entire, then rerun the command:\n[entire] %s\n", updateCommand) +} diff --git a/cmd/entire/cli/checkpointpolicy/warning_test.go b/cmd/entire/cli/checkpointpolicy/warning_test.go new file mode 100644 index 0000000000..0995f28d3b --- /dev/null +++ b/cmd/entire/cli/checkpointpolicy/warning_test.go @@ -0,0 +1,47 @@ +package checkpointpolicy_test + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" + "github.com/stretchr/testify/require" +) + +func TestRequiresUpgrade(t *testing.T) { + t.Parallel() + + require.False(t, checkpointpolicy.RequiresUpgrade(checkpointpolicy.DefaultPolicy())) + require.True(t, checkpointpolicy.RequiresUpgrade(checkpointpolicy.Policy{ + CheckpointVersion: checkpoint.CheckpointVersionBranchV1, + CheckpointMinVersion: "refs-v1", + })) + require.True(t, checkpointpolicy.RequiresUpgrade(checkpointpolicy.Policy{ + CheckpointVersion: checkpoint.CheckpointVersionBranchV1, + CheckpointMinVersion: "invalid", + })) +} + +func TestUnsupportedWrite(t *testing.T) { + t.Parallel() + + require.False(t, checkpointpolicy.UnsupportedWrite(checkpointpolicy.DefaultPolicy())) + require.True(t, checkpointpolicy.UnsupportedWrite(checkpointpolicy.Policy{ + CheckpointVersion: "refs-v1", + CheckpointMinVersion: checkpoint.CheckpointVersionBranchV1, + })) + require.True(t, checkpointpolicy.UnsupportedWrite(checkpointpolicy.Policy{ + CheckpointVersion: "invalid", + CheckpointMinVersion: checkpoint.CheckpointVersionBranchV1, + })) +} + +func TestUpgradeWarning(t *testing.T) { + t.Parallel() + + got := checkpointpolicy.UpgradeWarning("brew upgrade entire") + + require.Contains(t, got, "[entire] This repository requires checkpoint support newer than this Entire CLI.") + require.Contains(t, got, "[entire] Upgrade Entire, then rerun the command:") + require.Contains(t, got, "[entire] brew upgrade entire") +} diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 18fcac6736..ebd1400202 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -687,7 +687,7 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec if openErr != nil { return fmt.Errorf("open checkpoint store: %w", openErr) } - if err := generateCheckpointSummary(ctx, w, errW, writeStores.Primary, fullCheckpointID, summary, content, force, summaryTimeoutSeconds); err != nil { + if err := generateCheckpointSummary(ctx, w, errW, lookup.repo, writeStores.Primary, fullCheckpointID, summary, content, force, summaryTimeoutSeconds); err != nil { return err } // Reload to get the updated summary. @@ -899,7 +899,7 @@ func newExplainCheckpointLookup(ctx context.Context) (*explainCheckpointLookup, // summaryTimeoutSeconds is the per-invocation --summary-timeout-seconds flag // value (0 = unset). Effective precedence for the deadline: flag > settings > // package default. See resolveSummaryTimeout for the resolution. -func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, store checkpoint.Writer, checkpointID id.CheckpointID, cpSummary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, force bool, summaryTimeoutSeconds int) error { +func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, repo *git.Repository, store checkpoint.Writer, checkpointID id.CheckpointID, cpSummary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, force bool, summaryTimeoutSeconds int) error { // Check if summary already exists if content.Metadata.Summary != nil && !force { return renderExplainFailure(errW, "Summary already exists", []explainRow{ @@ -922,6 +922,9 @@ func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, store che {Label: "id", Value: checkpointID.String()}, }, fmt.Errorf("checkpoint %s has no transcript content for this checkpoint (scoped)", checkpointID)) } + if err := ensureCommittedCheckpointWritePolicy(ctx, repo); err != nil { + return err + } provider, err := resolveCheckpointSummaryProvider(ctx, w) if err != nil { return fmt.Errorf("failed to resolve summary provider: %w", err) diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index d36acf7fd6..29eb139ed1 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -21,6 +21,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -994,26 +995,19 @@ func TestGenerateCheckpointAISummary_PreservesClaudeErrorWhenCtxIsDone(t *testin } } -func TestLoadCheckpointForExplainRejectsUnsupportedCheckpointVersion(t *testing.T) { - repo := setupExportRepo(t) - - cpID := id.MustCheckpointID("bbbbccccdddd") - writeCheckpointForExport(t, repo, cpID, checkpoint.WriteCommittedOptions{ - SessionID: "session-explain-unsupported", - Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hi"}]}}` + "\n")), - }) - rewriteExportCheckpointVersion(t, repo, cpID, "refs-v1") - - lookup, err := newExplainCheckpointLookup(context.Background()) - require.NoError(t, err) - defer lookup.Close() - - _, _, err = loadCheckpointForExplain(context.Background(), lookup, cpID) - require.ErrorContains(t, err, `checkpoint bbbbccccdddd uses unsupported checkpoint_version "refs-v1"`) +// Not parallel: uses t.Chdir() and package-level var stubs. +type generateSummaryFixture struct { + ctx context.Context + repo *git.Repository + store checkpoint.CommittedStore + cpID id.CheckpointID + cpSummary *checkpoint.CheckpointSummary + content *checkpoint.SessionContent + v1Hash plumbing.Hash } -// Not parallel: uses t.Chdir() and package-level var stubs. -func TestGenerateCheckpointSummary_AdvancesV1Metadata(t *testing.T) { +func setupGenerateSummaryFixture(t *testing.T) generateSummaryFixture { + t.Helper() ctx := context.Background() tmpDir := t.TempDir() testutil.InitRepo(t, tmpDir) @@ -1047,6 +1041,20 @@ func TestGenerateCheckpointSummary_AdvancesV1Metadata(t *testing.T) { v1Before, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) require.NoError(t, err) + return generateSummaryFixture{ + ctx: ctx, + repo: repo, + store: store, + cpID: cpID, + cpSummary: cpSummary, + content: content, + v1Hash: v1Before.Hash(), + } +} + +func stubSummaryProviderForTest(t *testing.T) { + t.Helper() + origLoad := loadSummarySettings origGet := getSummaryAgent origCLI := isSummaryCLIAvailable @@ -1072,13 +1080,57 @@ func TestGenerateCheckpointSummary_AdvancesV1Metadata(t *testing.T) { generateTranscriptSummary = func(context.Context, redact.RedactedBytes, []string, types.AgentType, summarize.Generator) (*checkpoint.Summary, error) { return &checkpoint.Summary{Intent: "i", Outcome: "o"}, nil } +} + +func TestGenerateCheckpointSummary_AdvancesV1Metadata(t *testing.T) { + fixture := setupGenerateSummaryFixture(t) + stubSummaryProviderForTest(t) var stdout, stderr bytes.Buffer - require.NoError(t, generateCheckpointSummary(ctx, &stdout, &stderr, stores.Primary, cpID, cpSummary, content, false, 0)) + require.NoError(t, generateCheckpointSummary( + fixture.ctx, + &stdout, + &stderr, + fixture.repo, + fixture.store, + fixture.cpID, + fixture.cpSummary, + fixture.content, + false, + 0, + )) - v1After, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + v1After, err := fixture.repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) require.NoError(t, err) - require.NotEqual(t, v1Before.Hash(), v1After.Hash(), "v1 metadata branch must advance after UpdateSummary") + require.NotEqual(t, fixture.v1Hash, v1After.Hash(), "v1 metadata branch must advance after UpdateSummary") +} + +func TestGenerateCheckpointSummaryRejectsUnsupportedCheckpointWritePolicy(t *testing.T) { + fixture := setupGenerateSummaryFixture(t) + _, err := checkpointpolicy.WriteLocal(fixture.ctx, fixture.repo, plumbing.ZeroHash, checkpointpolicy.Policy{ + CheckpointVersion: "refs-v1", + CheckpointMinVersion: "branch-v1", + }) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + err = generateCheckpointSummary( + fixture.ctx, + &stdout, + &stderr, + fixture.repo, + fixture.store, + fixture.cpID, + fixture.cpSummary, + fixture.content, + false, + 0, + ) + require.ErrorContains(t, err, `checkpoint_version "refs-v1"`) + + v1After, refErr := fixture.repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, refErr) + require.Equal(t, fixture.v1Hash, v1After.Hash(), "v1 metadata branch must not advance after rejected summary write") } func TestGenerateCheckpointAISummary_ClampsLongParentDeadlineToDefaultTimeout(t *testing.T) { diff --git a/cmd/entire/cli/strategy/checkpoint_policy.go b/cmd/entire/cli/strategy/checkpoint_policy.go new file mode 100644 index 0000000000..50fa365322 --- /dev/null +++ b/cmd/entire/cli/strategy/checkpoint_policy.go @@ -0,0 +1,100 @@ +package strategy + +import ( + "context" + "fmt" + "log/slog" + + "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/versioncheck" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" + "github.com/go-git/go-git/v6" +) + +func committedCheckpointWriteAllowed(ctx context.Context, repo *git.Repository) bool { + state, err := checkpointpolicy.ReadLocal(ctx, repo) + if err != nil { + logging.Warn(ctx, "checkpoint policy read failed; allowing checkpoint write", + slog.String("error", err.Error()), + ) + return true + } + if !checkpointpolicy.UnsupportedWrite(state.Policy) { + return true + } + warnOrLogUnsupportedCheckpointWrite(ctx, state.Policy) + return false +} + +func syncCheckpointPolicyForPrePush(ctx context.Context) bool { + repo, err := OpenRepository(ctx) + if err != nil { + logging.Warn(ctx, "checkpoint policy pre-push: failed to open repository; allowing checkpoint push", + slog.String("error", err.Error()), + ) + return true + } + defer repo.Close() + + target, err := checkpointpolicy.ResolveTarget(ctx) + if err != nil { + logging.Warn(ctx, "checkpoint policy pre-push: failed to resolve policy remote; allowing checkpoint push", + slog.String("error", err.Error()), + ) + return true + } + state, err := checkpointpolicy.Sync(ctx, repo, target) + if err != nil { + warnOrLogCheckpointPolicySyncFailure(ctx, err) + return true + } + if state.Source == checkpointpolicy.SourceLocalDiverged { + warnOrLogCheckpointPolicyDiverged(ctx, state) + return false + } + if !checkpointpolicy.UnsupportedWrite(state.Policy) { + return true + } + warnOrLogUnsupportedCheckpointWrite(ctx, state.Policy) + return false +} + +func warnOrLogCheckpointPolicySyncFailure(ctx context.Context, err error) { + if interactive.CanPromptInteractively() { + fmt.Fprintf(stderrWriter, "[entire] Could not refresh checkpoint policy: %v\n", err) + return + } + logging.Warn(ctx, "checkpoint policy sync failed", + slog.String("error", err.Error()), + ) +} + +func warnOrLogCheckpointPolicyDiverged(ctx context.Context, state checkpointpolicy.State) { + if interactive.CanPromptInteractively() { + fmt.Fprintf( + stderrWriter, + "[entire] Could not reconcile checkpoint policy: local checkpoint policy %s diverges from remote %s\n", + state.Hash, + state.RemoteHash, + ) + return + } + logging.Warn(ctx, "checkpoint policy diverged; skipping checkpoint push", + slog.String("local_hash", state.Hash.String()), + slog.String("remote_hash", state.RemoteHash.String()), + ) +} + +func warnOrLogUnsupportedCheckpointWrite(ctx context.Context, policy checkpointpolicy.Policy) { + warning := checkpointpolicy.UpgradeWarning(versioncheck.UpdateCommandForCurrentBinary(versioninfo.Version)) + if interactive.CanPromptInteractively() { + fmt.Fprint(stderrWriter, warning) + return + } + logging.Warn(ctx, "checkpoint write skipped by policy", + slog.String("checkpoint_version", policy.CheckpointVersion), + slog.String("checkpoint_min_version", policy.CheckpointMinVersion), + ) +} diff --git a/cmd/entire/cli/strategy/checkpoint_policy_test.go b/cmd/entire/cli/strategy/checkpoint_policy_test.go new file mode 100644 index 0000000000..70c81faa2f --- /dev/null +++ b/cmd/entire/cli/strategy/checkpoint_policy_test.go @@ -0,0 +1,104 @@ +package strategy + +import ( + "bytes" + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/stretchr/testify/require" +) + +func TestPrePushSkipsCheckpointPushWhenPolicyWriteUnsupported(t *testing.T) { + workDir := setupRepoWithCheckpointBranch(t) + bareDir := filepath.Join(t.TempDir(), "remote.git") + _, err := git.PlainInit(bareDir, true) + require.NoError(t, err) + runCheckpointPolicyGit(t, workDir, "remote", "add", "origin", bareDir) + + repo, err := git.PlainOpen(workDir) + require.NoError(t, err) + t.Cleanup(func() { + _ = repo.Close() + }) + _, err = checkpointpolicy.WriteLocal(t.Context(), repo, plumbing.ZeroHash, checkpointpolicy.Policy{ + CheckpointVersion: "refs-v1", + CheckpointMinVersion: "branch-v1", + }) + require.NoError(t, err) + + t.Chdir(workDir) + paths.ClearWorktreeRootCache() + t.Setenv(interactive.EnvTestTTY, "1") + oldWriter := stderrWriter + var stderr bytes.Buffer + stderrWriter = &stderr + t.Cleanup(func() { stderrWriter = oldWriter }) + + err = NewManualCommitStrategy().PrePush(context.Background(), "origin") + require.NoError(t, err) + require.Contains(t, stderr.String(), "requires checkpoint support newer than this Entire CLI") + + out := runCheckpointPolicyGit(t, workDir, "ls-remote", bareDir, "refs/heads/"+paths.MetadataBranchName) + require.Empty(t, strings.TrimSpace(out)) +} + +func TestPrePushSkipsCheckpointPushWhenPolicyDiverged(t *testing.T) { + workDir := setupRepoWithCheckpointBranch(t) + bareDir := filepath.Join(t.TempDir(), "remote.git") + _, err := git.PlainInit(bareDir, true) + require.NoError(t, err) + runCheckpointPolicyGit(t, workDir, "remote", "add", "origin", bareDir) + + repo, err := git.PlainOpen(workDir) + require.NoError(t, err) + t.Cleanup(func() { + _ = repo.Close() + }) + baseHash, err := checkpointpolicy.WriteLocal(t.Context(), repo, plumbing.ZeroHash, checkpointpolicy.DefaultPolicy()) + require.NoError(t, err) + runCheckpointPolicyGit(t, workDir, "push", bareDir, checkpointpolicy.RefName.String()+":"+checkpointpolicy.RefName.String()) + + localHash, err := checkpointpolicy.WriteLocal(t.Context(), repo, baseHash, checkpointpolicy.DefaultPolicy()) + require.NoError(t, err) + _, err = checkpointpolicy.WriteLocal(t.Context(), repo, baseHash, checkpointpolicy.Policy{ + CheckpointVersion: "refs-v1", + CheckpointMinVersion: "branch-v1", + }) + require.NoError(t, err) + runCheckpointPolicyGit(t, workDir, "push", bareDir, checkpointpolicy.RefName.String()+":"+checkpointpolicy.RefName.String()) + require.NoError(t, checkpointpolicy.SetRef(repo, checkpointpolicy.RefName, localHash)) + + t.Chdir(workDir) + paths.ClearWorktreeRootCache() + t.Setenv(interactive.EnvTestTTY, "1") + oldWriter := stderrWriter + var stderr bytes.Buffer + stderrWriter = &stderr + t.Cleanup(func() { stderrWriter = oldWriter }) + + err = NewManualCommitStrategy().PrePush(context.Background(), "origin") + require.NoError(t, err) + require.Contains(t, stderr.String(), "Could not reconcile checkpoint policy") + + out := runCheckpointPolicyGit(t, workDir, "ls-remote", bareDir, "refs/heads/"+paths.MetadataBranchName) + require.Empty(t, strings.TrimSpace(out)) +} + +func runCheckpointPolicyGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", args...) + cmd.Dir = dir + cmd.Env = testutil.GitIsolatedEnv() + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + return string(output) +} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index ef675e795e..e161be0392 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -147,6 +147,9 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re } logCtx := logging.WithComponent(ctx, "checkpoint") condenseStart := time.Now() + if !committedCheckpointWriteAllowed(ctx, repo) { + return newSkippedResult(checkpointID, state.SessionID), nil + } shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) ref, hasShadowBranch := resolveShadowRef(repo, shadowBranchName, o.shadowRef) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index c648904266..d5ddd2bc56 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2785,6 +2785,10 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s return 1 // Count as error - all checkpoints will be skipped } defer repo.Close() + if !committedCheckpointWriteAllowed(ctx, repo) { + state.TurnCheckpointIDs = nil + return 0 + } prompts := readPromptsFromShadowBranch(ctx, repo, state) if len(prompts) == 0 { diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index e1a7d30685..aca149a64e 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -44,6 +44,9 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error } refs := checkpoint.ResolveCommittedRefs(ctx) + if !syncCheckpointPolicyForPrePush(ctx) { + return nil + } // OPF pre-push rewrite: if OPF is configured, resolve the user's // decision (env > settings > prompt > non-TTY auto-run), then diff --git a/cmd/entire/cli/versioncheck/versioncheck.go b/cmd/entire/cli/versioncheck/versioncheck.go index 1d53015b89..2f63be69e5 100644 --- a/cmd/entire/cli/versioncheck/versioncheck.go +++ b/cmd/entire/cli/versioncheck/versioncheck.go @@ -410,6 +410,13 @@ func updateCommand(currentVersion string) string { return "curl -fsSL https://entire.io/install.sh | bash" } +func UpdateCommandForCurrentBinary(currentVersion string) string { + if !canAutoInstall() { + return downloadsURL + } + return updateCommand(currentVersion) +} + // printNotification prints the version update notification to the user. func printNotification(w io.Writer, current, latest string) { fmt.Fprintf(w, "\nUpdate available! %s -> %s\nRelease notes: %s\n", diff --git a/cmd/entire/cli/versioncheck/versioncheck_test.go b/cmd/entire/cli/versioncheck/versioncheck_test.go index 2dd45ac433..5963a38f21 100644 --- a/cmd/entire/cli/versioncheck/versioncheck_test.go +++ b/cmd/entire/cli/versioncheck/versioncheck_test.go @@ -346,6 +346,8 @@ func TestParseGitHubRelease(t *testing.T) { // it without tripping goconst on repeated string literals. const brewUpgradeCmd = "brew upgrade entire" +const scoopExecutablePath = `C:\Users\test\scoop\apps\cli\current\entire.exe` + func TestUpdateCommand(t *testing.T) { const plainBinPath = "/usr/local/bin/entire" tests := []struct { @@ -387,7 +389,7 @@ func TestUpdateCommand(t *testing.T) { { name: "scoop path", currentVersion: "1.0.0", - execPath: func() (string, error) { return `C:\Users\test\scoop\apps\cli\current\entire.exe`, nil }, + execPath: func() (string, error) { return scoopExecutablePath, nil }, want: "scoop update entire/cli", }, { @@ -423,6 +425,54 @@ func TestUpdateCommand(t *testing.T) { } } +func TestUpdateCommandForCurrentBinary(t *testing.T) { + tests := []struct { + name string + currentVersion string + goos string + execPath func() (string, error) + want string + }{ + { + name: "known installer returns command", + currentVersion: "1.2.3", + goos: goosWindows, + execPath: func() (string, error) { return scoopExecutablePath, nil }, + want: "scoop update entire/cli", + }, + { + name: "windows unknown installer returns releases URL", + currentVersion: "1.2.3", + goos: goosWindows, + execPath: func() (string, error) { return `C:\Program Files\Entire\entire.exe`, nil }, + want: downloadsURL, + }, + { + name: "non-windows unknown installer returns curl command", + currentVersion: "1.2.3", + goos: "linux", + execPath: func() (string, error) { return "/usr/local/bin/entire", nil }, + want: "curl -fsSL https://entire.io/install.sh | bash", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalExecPath := executablePath + executablePath = tt.execPath + t.Cleanup(func() { executablePath = originalExecPath }) + + originalGOOS := goos + goos = tt.goos + t.Cleanup(func() { goos = originalGOOS }) + + if got := UpdateCommandForCurrentBinary(tt.currentVersion); got != tt.want { + t.Errorf("UpdateCommandForCurrentBinary() = %q, want %q", got, tt.want) + } + }) + } +} + // setupCheckAndNotifyTest points the global config dir at a per-test temp // dir and overrides githubAPIURL. Returns a cobra.Command with captured // stdout and a cleanup function. diff --git a/cmd/entire/main.go b/cmd/entire/main.go index 9bffaef553..32cd6d0054 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -87,6 +87,9 @@ func main() { cancel() os.Exit(1) } + if cli.ShouldCheckCheckpointPolicyWarning(executed) { + cli.WarnCheckpointPolicyIfNeeded(ctx, rootCmd.ErrOrStderr(), versioninfo.Version) + } cancel() // Cleanup on successful exit } diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index a48c6519c8..b3a5205e38 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -278,6 +278,41 @@ When condensing multiple concurrent sessions: - `sessions` array in `CheckpointSummary` maps each session to its file paths - `files_touched` is merged from all sessions +### Checkpoint Policy + +Repo-wide checkpoint policy lives at `refs/entire/policies/checkpoint`. The ref +points at a commit whose tree contains `policy.json`: + +```json +{ + "checkpoint_version": "branch-v1", + "checkpoint_min_version": "branch-v1" +} +``` + +`checkpoint_version` is the checkpoint format new writes should use. +`checkpoint_min_version` is the oldest checkpoint format clients must be able +to read for this repo. Missing policy fields default to `branch-v1`. + +Policy follows the configured checkpoint remote. `entire policy checkpoint` +fetches the latest remote policy before validating requested changes, updates +the local policy ref, and pushes only `refs/entire/policies/checkpoint`. +Policy commits use the same signing settings as checkpoint commits. + +Hooks that run while ordinary git operations must keep working offline: +post-commit and agent lifecycle hooks read only the local policy ref. If the +local policy requires checkpoint writes this CLI does not support, they skip +writing checkpoint data and warn only when running in an interactive terminal. +The pre-push hook is the regular online sync point: it compares the remote +policy ref with the local ref, fetches updated policy when needed, and evaluates +the refreshed policy before pushing `entire/checkpoints/v1`. If policy refresh +fails, the hook warns or logs the failure and lets the normal push continue. + +User-driven commands warn when the local policy indicates the CLI should be +upgraded. Commands that need to decode checkpoint contents, such as +`entire checkpoint explain` and `entire session resume`, fail when the target +checkpoint uses an unsupported `checkpoint_version`. + ### Checkpoint ID Linking The checkpoint ID is the **stable identifier** that links user commits to metadata across branches. From 8a30eb4a71808b060fc8f7114c840a37538c5301 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Wed, 24 Jun 2026 15:10:32 -0700 Subject: [PATCH 2/4] align checkpoint policy push target Sync pre-push checkpoint policy from the same resolved push target used for checkpoint refs so configured checkpoint remotes are enforced consistently. Thread the checkpoint logging context through policy checks that can block condensation or finalization. Entire-Checkpoint: e77a0d40ac05 --- cmd/entire/cli/strategy/checkpoint_policy.go | 8 ++-- .../cli/strategy/checkpoint_policy_test.go | 41 +++++++++++++++++++ .../strategy/manual_commit_condensation.go | 2 +- .../cli/strategy/manual_commit_hooks.go | 2 +- cmd/entire/cli/strategy/manual_commit_push.go | 2 +- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/strategy/checkpoint_policy.go b/cmd/entire/cli/strategy/checkpoint_policy.go index 0b21277766..e34084e89f 100644 --- a/cmd/entire/cli/strategy/checkpoint_policy.go +++ b/cmd/entire/cli/strategy/checkpoint_policy.go @@ -9,6 +9,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpointpolicy" "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/versioncheck" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/go-git/go-git/v6" @@ -31,7 +32,7 @@ func checkCommittedCheckpointWritePolicy(ctx context.Context, repo *git.Reposito return errCommittedCheckpointWriteBlocked } -func syncCheckpointPolicyForPrePush(ctx context.Context) bool { +func syncCheckpointPolicyForPrePush(ctx context.Context, ps pushSettings) bool { repo, err := OpenRepository(ctx) if err != nil { logging.Warn(ctx, "checkpoint policy pre-push: failed to open repository; allowing checkpoint push", @@ -41,13 +42,14 @@ func syncCheckpointPolicyForPrePush(ctx context.Context) bool { } defer repo.Close() - target, err := checkpointpolicy.ResolveTarget(ctx) + dir, err := paths.WorktreeRoot(ctx) if err != nil { - logging.Warn(ctx, "checkpoint policy pre-push: failed to resolve policy remote; allowing checkpoint push", + logging.Warn(ctx, "checkpoint policy pre-push: failed to resolve worktree root; allowing checkpoint push", slog.String("error", err.Error()), ) return true } + target := checkpointpolicy.Target{Remote: ps.pushTarget(), Dir: dir} state, err := checkpointpolicy.Sync(ctx, repo, target) if err != nil { warnOrLogCheckpointPolicySyncFailure(ctx, err) diff --git a/cmd/entire/cli/strategy/checkpoint_policy_test.go b/cmd/entire/cli/strategy/checkpoint_policy_test.go index a0d3b6356f..2ecc02644a 100644 --- a/cmd/entire/cli/strategy/checkpoint_policy_test.go +++ b/cmd/entire/cli/strategy/checkpoint_policy_test.go @@ -185,6 +185,47 @@ func TestPrePushSkipsCheckpointPushWhenPolicyDiverged(t *testing.T) { require.Empty(t, strings.TrimSpace(out)) } +func TestSyncCheckpointPolicyForPrePushUsesPushTarget(t *testing.T) { + workDir := setupGitRepo(t) + originBareDir := filepath.Join(t.TempDir(), "origin.git") + _, err := git.PlainInit(originBareDir, true) + require.NoError(t, err) + runCheckpointPolicyGit(t, workDir, "remote", "add", "origin", originBareDir) + + pushTargetDir := filepath.Join(t.TempDir(), "push-target.git") + _, err = git.PlainInit(pushTargetDir, true) + require.NoError(t, err) + + repo, err := git.PlainOpen(workDir) + require.NoError(t, err) + t.Cleanup(func() { + _ = repo.Close() + }) + + _, err = checkpointpolicy.WriteLocal(t.Context(), repo, plumbing.ZeroHash, checkpointpolicy.Policy{ + CheckpointVersion: "refs-v1", + CheckpointMinVersion: "branch-v1", + }) + require.NoError(t, err) + runCheckpointPolicyGit(t, workDir, "push", originBareDir, checkpointpolicy.RefName.String()+":"+checkpointpolicy.RefName.String()) + + targetHash, err := checkpointpolicy.WriteLocal(t.Context(), repo, plumbing.ZeroHash, checkpointpolicy.DefaultPolicy()) + require.NoError(t, err) + runCheckpointPolicyGit(t, workDir, "push", pushTargetDir, checkpointpolicy.RefName.String()+":"+checkpointpolicy.RefName.String()) + require.NoError(t, repo.Storer.RemoveReference(checkpointpolicy.RefName)) + + t.Chdir(workDir) + paths.ClearWorktreeRootCache() + + require.True(t, syncCheckpointPolicyForPrePush(context.Background(), pushSettings{ + remote: "origin", + checkpointURL: pushTargetDir, + })) + state, err := checkpointpolicy.ReadLocal(t.Context(), repo) + require.NoError(t, err) + require.Equal(t, targetHash, state.Hash) +} + func writeUnsupportedCheckpointPolicy(t *testing.T, repo *git.Repository) { t.Helper() _, err := checkpointpolicy.WriteLocal(t.Context(), repo, plumbing.ZeroHash, checkpointpolicy.Policy{ diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 8ebcb2f2a6..62d8e04ce5 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -147,7 +147,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re } logCtx := logging.WithComponent(ctx, "checkpoint") condenseStart := time.Now() - if err := checkCommittedCheckpointWritePolicy(ctx, repo); err != nil { + if err := checkCommittedCheckpointWritePolicy(logCtx, repo); err != nil { return nil, err } diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index bb1e6d13e1..093a66c2e5 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2785,7 +2785,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s return 1 // Count as error - all checkpoints will be skipped } defer repo.Close() - if err := checkCommittedCheckpointWritePolicy(ctx, repo); err != nil { + if err := checkCommittedCheckpointWritePolicy(logCtx, repo); err != nil { return 1 } diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index 70edaeb464..3a4ad2e98d 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -44,7 +44,7 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error } refs := checkpoint.ResolveRefs(ctx) - if !syncCheckpointPolicyForPrePush(ctx) { + if !syncCheckpointPolicyForPrePush(ctx, ps) { return nil } From c6529b2f0d351485097c98919875ba11a337e278 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Wed, 24 Jun 2026 17:00:23 -0700 Subject: [PATCH 3/4] enforce local policy after sync failure When pre-push cannot refresh checkpoint policy from the remote, still honor an already-local policy that blocks checkpoint writes. This avoids pushing checkpoint metadata with an unsupported writer just because the remote policy check failed. Entire-Checkpoint: 98531e973f39 --- cmd/entire/cli/strategy/checkpoint_policy.go | 5 ++++ .../cli/strategy/checkpoint_policy_test.go | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/cmd/entire/cli/strategy/checkpoint_policy.go b/cmd/entire/cli/strategy/checkpoint_policy.go index e34084e89f..ee91d684a8 100644 --- a/cmd/entire/cli/strategy/checkpoint_policy.go +++ b/cmd/entire/cli/strategy/checkpoint_policy.go @@ -53,6 +53,11 @@ func syncCheckpointPolicyForPrePush(ctx context.Context, ps pushSettings) bool { state, err := checkpointpolicy.Sync(ctx, repo, target) if err != nil { warnOrLogCheckpointPolicySyncFailure(ctx, err) + localState, readErr := checkpointpolicy.ReadLocal(ctx, repo) + if readErr == nil && checkpointpolicy.UnsupportedWrite(localState.Policy) { + warnOrLogUnsupportedCheckpointWrite(ctx, localState.Policy) + return false + } return true } if state.Source == checkpointpolicy.SourceLocalDiverged { diff --git a/cmd/entire/cli/strategy/checkpoint_policy_test.go b/cmd/entire/cli/strategy/checkpoint_policy_test.go index 2ecc02644a..c00265ba68 100644 --- a/cmd/entire/cli/strategy/checkpoint_policy_test.go +++ b/cmd/entire/cli/strategy/checkpoint_policy_test.go @@ -185,6 +185,36 @@ func TestPrePushSkipsCheckpointPushWhenPolicyDiverged(t *testing.T) { require.Empty(t, strings.TrimSpace(out)) } +func TestPrePushSkipsCheckpointPushWhenSyncFailsAndLocalPolicyWriteUnsupported(t *testing.T) { + workDir := setupRepoWithCheckpointBranch(t) + bareDir := filepath.Join(t.TempDir(), "remote.git") + _, err := git.PlainInit(bareDir, true) + require.NoError(t, err) + runCheckpointPolicyGit(t, workDir, "remote", "add", "origin", bareDir) + + repo, err := git.PlainOpen(workDir) + require.NoError(t, err) + t.Cleanup(func() { + _ = repo.Close() + }) + writeUnsupportedCheckpointPolicy(t, repo) + + t.Chdir(workDir) + paths.ClearWorktreeRootCache() + t.Setenv(interactive.EnvTestTTY, "1") + oldWriter := stderrWriter + var stderr bytes.Buffer + stderrWriter = &stderr + t.Cleanup(func() { stderrWriter = oldWriter }) + + require.NoError(t, os.RemoveAll(bareDir)) + + err = NewManualCommitStrategy().PrePush(context.Background(), "origin") + require.NoError(t, err) + require.Contains(t, stderr.String(), "Could not refresh checkpoint policy") + require.Contains(t, stderr.String(), "requires checkpoint support newer than this Entire CLI") +} + func TestSyncCheckpointPolicyForPrePushUsesPushTarget(t *testing.T) { workDir := setupGitRepo(t) originBareDir := filepath.Join(t.TempDir(), "origin.git") From d23a5cf5d67a4ed9c7d49266119eaa197780dc03 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 25 Jun 2026 17:08:52 -0700 Subject: [PATCH 4/4] clear turn checkpoint IDs on policy block Clear pending turn checkpoint IDs when checkpoint finalization is blocked by local checkpoint policy. This matches the other best-effort finalization failures and allows ended sessions to release their shadow branches. Entire-Checkpoint: eff17371d4cd --- cmd/entire/cli/strategy/checkpoint_policy_test.go | 4 ++-- cmd/entire/cli/strategy/manual_commit_hooks.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/strategy/checkpoint_policy_test.go b/cmd/entire/cli/strategy/checkpoint_policy_test.go index c00265ba68..7040fb7535 100644 --- a/cmd/entire/cli/strategy/checkpoint_policy_test.go +++ b/cmd/entire/cli/strategy/checkpoint_policy_test.go @@ -79,7 +79,7 @@ func TestCondenseAndMarkFullyCondensedPolicyBlockLeavesSessionRetryable(t *testi require.Positive(t, state.StepCount) } -func TestFinalizeAllTurnCheckpointsPolicyBlockKeepsTurnCheckpointIDs(t *testing.T) { +func TestFinalizeAllTurnCheckpointsPolicyBlockClearsTurnCheckpointIDs(t *testing.T) { workDir := setupGitRepo(t) t.Chdir(workDir) paths.ClearWorktreeRootCache() @@ -106,7 +106,7 @@ func TestFinalizeAllTurnCheckpointsPolicyBlockKeepsTurnCheckpointIDs(t *testing. errCount := NewManualCommitStrategy().finalizeAllTurnCheckpoints(context.Background(), state) require.Equal(t, 1, errCount) - require.Equal(t, []string{"a1b2c3d4e5f6"}, state.TurnCheckpointIDs) + require.Empty(t, state.TurnCheckpointIDs) } func TestPrePushSkipsCheckpointPushWhenPolicyWriteUnsupported(t *testing.T) { diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index a300dff1c0..b58273369d 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2787,6 +2787,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s } defer repo.Close() if err := checkCommittedCheckpointWritePolicy(logCtx, repo); err != nil { + state.TurnCheckpointIDs = nil return 1 }