From 0eb959929d7d1994d225e4831be5148d5e5efcd6 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 10:52:13 -0700 Subject: [PATCH 01/18] feat: add v2 checkpoint ref name constants --- cmd/entire/cli/paths/paths.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 98706ba2e..80d3e8761 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -34,6 +34,16 @@ const ( // MetadataBranchName is the orphan branch used by manual-commit strategy to store metadata const MetadataBranchName = "entire/checkpoints/v1" +// V2 ref names use custom refs under refs/entire/ (not refs/heads/). +// These are invisible in GitHub's branch UI and not fetched by default. +const ( + // V2MainRefName stores permanent metadata + compact transcripts. + V2MainRefName = "refs/entire/checkpoints/v2/main" + + // V2FullCurrentRefName stores the active generation of raw transcripts. + V2FullCurrentRefName = "refs/entire/checkpoints/v2/full/current" +) + // TrailsBranchName is the orphan branch used to store trail metadata. // Trails are branch-centric work tracking abstractions that link to checkpoints by branch name. const TrailsBranchName = "entire/trails/v1" From 594f54cc46208b2f83e829a8f043e16dea8f5be4 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 11:28:58 -0700 Subject: [PATCH 02/18] feat: add V2Store skeleton with ref management helpers --- cmd/entire/cli/checkpoint/v2_store.go | 82 ++++++++++ cmd/entire/cli/checkpoint/v2_store_test.go | 169 +++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 cmd/entire/cli/checkpoint/v2_store.go create mode 100644 cmd/entire/cli/checkpoint/v2_store_test.go diff --git a/cmd/entire/cli/checkpoint/v2_store.go b/cmd/entire/cli/checkpoint/v2_store.go new file mode 100644 index 000000000..29c9b4b58 --- /dev/null +++ b/cmd/entire/cli/checkpoint/v2_store.go @@ -0,0 +1,82 @@ +package checkpoint + +import ( + "fmt" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// V2Store provides checkpoint storage operations for the v2 ref layout. +// It writes to two custom refs under refs/entire/: +// - /main: permanent metadata + compact transcripts +// - /full/current: active generation of raw transcripts +// +// V2Store is separate from GitStore (v1) to keep concerns isolated +// and simplify future v1 removal. +type V2Store struct { + repo *git.Repository +} + +// NewV2Store creates a new v2 checkpoint store backed by the given git repository. +func NewV2Store(repo *git.Repository) *V2Store { + return &V2Store{repo: repo} +} + +// ensureRef ensures that a custom ref exists, creating an orphan commit +// with an empty tree if it does not. +func (s *V2Store) ensureRef(refName plumbing.ReferenceName) error { + _, err := s.repo.Reference(refName, true) + if err == nil { + return nil // Already exists + } + + emptyTreeHash, err := BuildTreeFromEntries(s.repo, make(map[string]object.TreeEntry)) + if err != nil { + return fmt.Errorf("failed to build empty tree: %w", err) + } + + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) + commitHash, err := CreateCommit(s.repo, emptyTreeHash, plumbing.ZeroHash, "Initialize v2 ref", authorName, authorEmail) + if err != nil { + return fmt.Errorf("failed to create initial commit: %w", err) + } + + ref := plumbing.NewHashReference(refName, commitHash) + if err := s.repo.Storer.SetReference(ref); err != nil { + return fmt.Errorf("failed to set ref %s: %w", refName, err) + } + + return nil +} + +// getRefState returns the parent commit hash and root tree hash for a ref. +func (s *V2Store) getRefState(refName plumbing.ReferenceName) (parentHash, treeHash plumbing.Hash, err error) { + ref, err := s.repo.Reference(refName, true) + if err != nil { + return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("ref %s not found: %w", refName, err) + } + + commit, err := s.repo.CommitObject(ref.Hash()) + if err != nil { + return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("failed to get commit for ref %s: %w", refName, err) + } + + return ref.Hash(), commit.TreeHash, nil +} + +// updateRef creates a new commit on a ref with the given tree, updating the ref to point to it. +func (s *V2Store) updateRef(refName plumbing.ReferenceName, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) error { + commitHash, err := CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) + if err != nil { + return fmt.Errorf("failed to create commit: %w", err) + } + + ref := plumbing.NewHashReference(refName, commitHash) + if err := s.repo.Storer.SetReference(ref); err != nil { + return fmt.Errorf("failed to update ref %s: %w", refName, err) + } + + return nil +} diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go new file mode 100644 index 000000000..acf220bcb --- /dev/null +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -0,0 +1,169 @@ +package checkpoint + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/stretchr/testify/require" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// initTestRepo creates a bare-minimum git repo with one commit (needed for HEAD). +func initTestRepo(t *testing.T) *git.Repository { + t.Helper() + dir := t.TempDir() + + repo, err := git.PlainInit(dir, false) + require.NoError(t, err) + + wt, err := repo.Worktree() + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("init"), 0o644)) + _, err = wt.Add("README.md") + require.NoError(t, err) + _, err = wt.Commit("initial", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com"}, + }) + require.NoError(t, err) + + return repo +} + +func TestNewV2Store(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2Store(repo) + require.NotNil(t, store) + require.Equal(t, repo, store.repo) +} + +func TestV2Store_EnsureRef_CreatesNewRef(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2Store(repo) + + refName := plumbing.ReferenceName(paths.V2MainRefName) + + // Ref should not exist yet + _, err := repo.Reference(refName, true) + require.Error(t, err) + + // Ensure creates it + require.NoError(t, store.ensureRef(refName)) + + // Ref should now exist and point to a valid commit with an empty tree + ref, err := repo.Reference(refName, true) + require.NoError(t, err) + + commit, err := repo.CommitObject(ref.Hash()) + require.NoError(t, err) + + tree, err := commit.Tree() + require.NoError(t, err) + require.Empty(t, tree.Entries, "initial tree should be empty") +} + +func TestV2Store_EnsureRef_Idempotent(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2Store(repo) + + refName := plumbing.ReferenceName(paths.V2MainRefName) + + require.NoError(t, store.ensureRef(refName)) + ref1, err := repo.Reference(refName, true) + require.NoError(t, err) + + // Second call should be a no-op — same commit hash + require.NoError(t, store.ensureRef(refName)) + ref2, err := repo.Reference(refName, true) + require.NoError(t, err) + require.Equal(t, ref1.Hash(), ref2.Hash()) +} + +func TestV2Store_EnsureRef_DifferentRefs(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2Store(repo) + + mainRef := plumbing.ReferenceName(paths.V2MainRefName) + fullRef := plumbing.ReferenceName(paths.V2FullCurrentRefName) + + require.NoError(t, store.ensureRef(mainRef)) + require.NoError(t, store.ensureRef(fullRef)) + + // Both should exist independently + _, err := repo.Reference(mainRef, true) + require.NoError(t, err) + _, err = repo.Reference(fullRef, true) + require.NoError(t, err) +} + +func TestV2Store_GetRefState_ReturnsParentAndTree(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2Store(repo) + + refName := plumbing.ReferenceName(paths.V2MainRefName) + require.NoError(t, store.ensureRef(refName)) + + parentHash, treeHash, err := store.getRefState(refName) + require.NoError(t, err) + require.NotEqual(t, plumbing.ZeroHash, parentHash, "parent hash should be non-zero") + // Tree hash can be zero hash for empty tree or a valid hash — just verify no error + _ = treeHash +} + +func TestV2Store_GetRefState_ErrorsOnMissingRef(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2Store(repo) + + refName := plumbing.ReferenceName("refs/entire/nonexistent") + _, _, err := store.getRefState(refName) + require.Error(t, err) +} + +func TestV2Store_UpdateRef_CreatesCommit(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2Store(repo) + + refName := plumbing.ReferenceName(paths.V2MainRefName) + require.NoError(t, store.ensureRef(refName)) + + parentHash, treeHash, err := store.getRefState(refName) + require.NoError(t, err) + + // Build a tree with one file + blobHash, err := CreateBlobFromContent(repo, []byte("hello")) + require.NoError(t, err) + + entries := map[string]object.TreeEntry{ + "test.txt": {Name: "test.txt", Mode: 0o100644, Hash: blobHash}, + } + newTreeHash, err := BuildTreeFromEntries(repo, entries) + require.NoError(t, err) + require.NotEqual(t, treeHash, newTreeHash) + + // Update the ref + require.NoError(t, store.updateRef(refName, newTreeHash, parentHash, "test commit", "Test", "test@test.com")) + + // Verify the ref now points to a commit with our tree + ref, err := repo.Reference(refName, true) + require.NoError(t, err) + require.NotEqual(t, parentHash, ref.Hash(), "ref should point to new commit") + + commit, err := repo.CommitObject(ref.Hash()) + require.NoError(t, err) + require.Equal(t, newTreeHash, commit.TreeHash) + require.Equal(t, "test commit", commit.Message) + require.Len(t, commit.ParentHashes, 1) + require.Equal(t, parentHash, commit.ParentHashes[0]) +} From f343da18ff90c6ecf14f060a6f09b1bb21789ab0 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 11:56:16 -0700 Subject: [PATCH 03/18] feat: add V2GitStore.writeCommittedMain for metadata writes to /main ref --- cmd/entire/cli/checkpoint/v2_committed.go | 221 +++++++++++++++++++ cmd/entire/cli/checkpoint/v2_store.go | 26 ++- cmd/entire/cli/checkpoint/v2_store_test.go | 237 +++++++++++++++++++-- 3 files changed, 460 insertions(+), 24 deletions(-) create mode 100644 cmd/entire/cli/checkpoint/v2_committed.go diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go new file mode 100644 index 000000000..125613adb --- /dev/null +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -0,0 +1,221 @@ +package checkpoint + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/validation" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" + "github.com/entireio/cli/redact" + + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// writeCommittedMain writes metadata entries to the /main ref. +// This includes session metadata, prompts, and content hash — but NOT the +// raw transcript (full.jsonl), which goes to /full/current. +func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommittedOptions) error { + if err := validateWriteOpts(opts); err != nil { + return err + } + + refName := plumbing.ReferenceName(paths.V2MainRefName) + if err := s.ensureRef(refName); err != nil { + return fmt.Errorf("failed to ensure /main ref: %w", err) + } + + parentHash, rootTreeHash, err := s.getRefState(refName) + if err != nil { + return err + } + + basePath := opts.CheckpointID.Path() + "/" + checkpointPath := opts.CheckpointID.Path() + + // Read existing entries at this checkpoint's shard path + entries, err := s.gs.flattenCheckpointEntries(rootTreeHash, checkpointPath) + if err != nil { + return err + } + + // Build main session entries (metadata, prompts, content hash — no transcript) + if err := s.writeMainCheckpointEntries(ctx, opts, basePath, entries); err != nil { + return err + } + + // Splice entries into root tree + newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) + if err != nil { + return err + } + + commitMsg := fmt.Sprintf("Checkpoint: %s\n", opts.CheckpointID) + return s.updateRef(refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail) +} + +// writeMainCheckpointEntries orchestrates writing session data to the /main ref. +// It mirrors GitStore.writeStandardCheckpointEntries but excludes raw transcript blobs. +func (s *V2GitStore) writeMainCheckpointEntries(ctx context.Context, opts WriteCommittedOptions, basePath string, entries map[string]object.TreeEntry) error { + // Read existing summary to get current session count + var existingSummary *CheckpointSummary + metadataPath := basePath + paths.MetadataFileName + if entry, exists := entries[metadataPath]; exists { + existing, err := readJSONFromBlob[CheckpointSummary](s.repo, entry.Hash) + if err == nil { + existingSummary = existing + } + } + + // Determine session index + sessionIndex := s.gs.findSessionIndex(ctx, basePath, existingSummary, entries, opts.SessionID) + + // Write session files (metadata, prompts, content hash — no transcript) + sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) + sessionFilePaths, err := s.writeMainSessionToSubdirectory(opts, sessionPath, entries) + if err != nil { + return err + } + + // Build the sessions array + var sessions []SessionFilePaths + if existingSummary != nil { + sessions = make([]SessionFilePaths, max(len(existingSummary.Sessions), sessionIndex+1)) + copy(sessions, existingSummary.Sessions) + } else { + sessions = make([]SessionFilePaths, 1) + } + sessions[sessionIndex] = sessionFilePaths + + // Write root CheckpointSummary + return s.gs.writeCheckpointSummary(opts, basePath, entries, sessions) +} + +// writeMainSessionToSubdirectory writes a single session's metadata, prompts, and +// content hash to a session subdirectory (0/, 1/, 2/, … indexed by session order +// within the checkpoint). Unlike the v1 equivalent, this does NOT write the raw +// transcript (full.jsonl) — that goes to /full/current. +func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions, sessionPath string, entries map[string]object.TreeEntry) (SessionFilePaths, error) { + filePaths := SessionFilePaths{} + + // Clear existing entries at this session path + for key := range entries { + if strings.HasPrefix(key, sessionPath) { + delete(entries, key) + } + } + + // Write content hash from transcript (but not the transcript itself) + if err := s.writeContentHash(opts, sessionPath, entries, &filePaths); err != nil { + return filePaths, err + } + + // Write prompts + if len(opts.Prompts) > 0 { + promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n")) + blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent)) + if err != nil { + return filePaths, err + } + entries[sessionPath+paths.PromptFileName] = object.TreeEntry{ + Name: sessionPath + paths.PromptFileName, + Mode: filemode.Regular, + Hash: blobHash, + } + filePaths.Prompt = "/" + sessionPath + paths.PromptFileName + } + + // Write session metadata + sessionMetadata := CommittedMetadata{ + CheckpointID: opts.CheckpointID, + SessionID: opts.SessionID, + Strategy: opts.Strategy, + CreatedAt: time.Now().UTC(), + Branch: opts.Branch, + CheckpointsCount: opts.CheckpointsCount, + FilesTouched: opts.FilesTouched, + Agent: opts.Agent, + Model: opts.Model, + TurnID: opts.TurnID, + IsTask: opts.IsTask, + ToolUseID: opts.ToolUseID, + TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart, + CheckpointTranscriptStart: opts.CheckpointTranscriptStart, + TranscriptLinesAtStart: opts.CheckpointTranscriptStart, + TokenUsage: opts.TokenUsage, + SessionMetrics: opts.SessionMetrics, + InitialAttribution: opts.InitialAttribution, + Summary: redactSummary(opts.Summary), + CLIVersion: versioninfo.Version, + } + + metadataJSON, err := jsonutil.MarshalIndentWithNewline(sessionMetadata, "", " ") + if err != nil { + return filePaths, fmt.Errorf("failed to marshal session metadata: %w", err) + } + metadataHash, err := CreateBlobFromContent(s.repo, metadataJSON) + if err != nil { + return filePaths, err + } + entries[sessionPath+paths.MetadataFileName] = object.TreeEntry{ + Name: sessionPath + paths.MetadataFileName, + Mode: filemode.Regular, + Hash: metadataHash, + } + filePaths.Metadata = "/" + sessionPath + paths.MetadataFileName + + return filePaths, nil +} + +// writeContentHash computes and writes the content hash for the transcript +// without writing the transcript blobs themselves. +func (s *V2GitStore) writeContentHash(opts WriteCommittedOptions, sessionPath string, entries map[string]object.TreeEntry, filePaths *SessionFilePaths) error { + transcript := opts.Transcript + if len(transcript) == 0 { + return nil + } + + // Redact before hashing so the hash matches what /full/current stores + redacted, err := redact.JSONLBytes(transcript) + if err != nil { + return fmt.Errorf("failed to redact transcript for content hash: %w", err) + } + + contentHash := fmt.Sprintf("sha256:%x", sha256.Sum256(redacted)) + hashBlob, err := CreateBlobFromContent(s.repo, []byte(contentHash)) + if err != nil { + return err + } + entries[sessionPath+paths.ContentHashFileName] = object.TreeEntry{ + Name: sessionPath + paths.ContentHashFileName, + Mode: filemode.Regular, + Hash: hashBlob, + } + filePaths.ContentHash = "/" + sessionPath + paths.ContentHashFileName + + return nil +} + +// validateWriteOpts validates identifiers in WriteCommittedOptions. +func validateWriteOpts(opts WriteCommittedOptions) error { + if opts.CheckpointID.IsEmpty() { + return errors.New("invalid checkpoint options: checkpoint ID is required") + } + if err := validation.ValidateSessionID(opts.SessionID); err != nil { + return fmt.Errorf("invalid checkpoint options: %w", err) + } + if err := validation.ValidateToolUseID(opts.ToolUseID); err != nil { + return fmt.Errorf("invalid checkpoint options: %w", err) + } + if err := validation.ValidateAgentID(opts.AgentID); err != nil { + return fmt.Errorf("invalid checkpoint options: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/checkpoint/v2_store.go b/cmd/entire/cli/checkpoint/v2_store.go index 29c9b4b58..5acccda75 100644 --- a/cmd/entire/cli/checkpoint/v2_store.go +++ b/cmd/entire/cli/checkpoint/v2_store.go @@ -8,25 +8,31 @@ import ( "github.com/go-git/go-git/v6/plumbing/object" ) -// V2Store provides checkpoint storage operations for the v2 ref layout. +// V2GitStore provides checkpoint storage operations for the v2 ref layout. // It writes to two custom refs under refs/entire/: // - /main: permanent metadata + compact transcripts // - /full/current: active generation of raw transcripts // -// V2Store is separate from GitStore (v1) to keep concerns isolated -// and simplify future v1 removal. -type V2Store struct { +// V2GitStore is separate from GitStore (v1) to keep concerns isolated +// and simplify future v1 removal. It composes GitStore internally to +// reuse ref-agnostic entry-building helpers (tree surgery, session +// indexing, summary aggregation). +type V2GitStore struct { repo *git.Repository + gs *GitStore // shared entry-building helpers (same package) } -// NewV2Store creates a new v2 checkpoint store backed by the given git repository. -func NewV2Store(repo *git.Repository) *V2Store { - return &V2Store{repo: repo} +// NewV2GitStore creates a new v2 checkpoint store backed by the given git repository. +func NewV2GitStore(repo *git.Repository) *V2GitStore { + return &V2GitStore{ + repo: repo, + gs: &GitStore{repo: repo}, + } } // ensureRef ensures that a custom ref exists, creating an orphan commit // with an empty tree if it does not. -func (s *V2Store) ensureRef(refName plumbing.ReferenceName) error { +func (s *V2GitStore) ensureRef(refName plumbing.ReferenceName) error { _, err := s.repo.Reference(refName, true) if err == nil { return nil // Already exists @@ -52,7 +58,7 @@ func (s *V2Store) ensureRef(refName plumbing.ReferenceName) error { } // getRefState returns the parent commit hash and root tree hash for a ref. -func (s *V2Store) getRefState(refName plumbing.ReferenceName) (parentHash, treeHash plumbing.Hash, err error) { +func (s *V2GitStore) getRefState(refName plumbing.ReferenceName) (parentHash, treeHash plumbing.Hash, err error) { ref, err := s.repo.Reference(refName, true) if err != nil { return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("ref %s not found: %w", refName, err) @@ -67,7 +73,7 @@ func (s *V2Store) getRefState(refName plumbing.ReferenceName) (parentHash, treeH } // updateRef creates a new commit on a ref with the given tree, updating the ref to point to it. -func (s *V2Store) updateRef(refName plumbing.ReferenceName, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) error { +func (s *V2GitStore) updateRef(refName plumbing.ReferenceName, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) error { commitHash, err := CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) if err != nil { return fmt.Errorf("failed to create commit: %w", err) diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index acf220bcb..968a98dd5 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -1,11 +1,17 @@ package checkpoint import ( + "context" + "encoding/json" "os" "path/filepath" + "strings" "testing" + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/go-git/go-git/v6" @@ -35,18 +41,18 @@ func initTestRepo(t *testing.T) *git.Repository { return repo } -func TestNewV2Store(t *testing.T) { +func TestNewV2GitStore(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2Store(repo) + store := NewV2GitStore(repo) require.NotNil(t, store) require.Equal(t, repo, store.repo) } -func TestV2Store_EnsureRef_CreatesNewRef(t *testing.T) { +func TestV2GitStore_EnsureRef_CreatesNewRef(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2Store(repo) + store := NewV2GitStore(repo) refName := plumbing.ReferenceName(paths.V2MainRefName) @@ -69,10 +75,10 @@ func TestV2Store_EnsureRef_CreatesNewRef(t *testing.T) { require.Empty(t, tree.Entries, "initial tree should be empty") } -func TestV2Store_EnsureRef_Idempotent(t *testing.T) { +func TestV2GitStore_EnsureRef_Idempotent(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2Store(repo) + store := NewV2GitStore(repo) refName := plumbing.ReferenceName(paths.V2MainRefName) @@ -87,10 +93,10 @@ func TestV2Store_EnsureRef_Idempotent(t *testing.T) { require.Equal(t, ref1.Hash(), ref2.Hash()) } -func TestV2Store_EnsureRef_DifferentRefs(t *testing.T) { +func TestV2GitStore_EnsureRef_DifferentRefs(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2Store(repo) + store := NewV2GitStore(repo) mainRef := plumbing.ReferenceName(paths.V2MainRefName) fullRef := plumbing.ReferenceName(paths.V2FullCurrentRefName) @@ -105,10 +111,10 @@ func TestV2Store_EnsureRef_DifferentRefs(t *testing.T) { require.NoError(t, err) } -func TestV2Store_GetRefState_ReturnsParentAndTree(t *testing.T) { +func TestV2GitStore_GetRefState_ReturnsParentAndTree(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2Store(repo) + store := NewV2GitStore(repo) refName := plumbing.ReferenceName(paths.V2MainRefName) require.NoError(t, store.ensureRef(refName)) @@ -120,20 +126,20 @@ func TestV2Store_GetRefState_ReturnsParentAndTree(t *testing.T) { _ = treeHash } -func TestV2Store_GetRefState_ErrorsOnMissingRef(t *testing.T) { +func TestV2GitStore_GetRefState_ErrorsOnMissingRef(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2Store(repo) + store := NewV2GitStore(repo) refName := plumbing.ReferenceName("refs/entire/nonexistent") _, _, err := store.getRefState(refName) require.Error(t, err) } -func TestV2Store_UpdateRef_CreatesCommit(t *testing.T) { +func TestV2GitStore_UpdateRef_CreatesCommit(t *testing.T) { t.Parallel() repo := initTestRepo(t) - store := NewV2Store(repo) + store := NewV2GitStore(repo) refName := plumbing.ReferenceName(paths.V2MainRefName) require.NoError(t, store.ensureRef(refName)) @@ -167,3 +173,206 @@ func TestV2Store_UpdateRef_CreatesCommit(t *testing.T) { require.Len(t, commit.ParentHashes, 1) require.Equal(t, parentHash, commit.ParentHashes[0]) } + +// v2MainTree returns the root tree from the /main ref for test assertions. +func v2MainTree(t *testing.T, repo *git.Repository) *object.Tree { + t.Helper() + ref, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true) + require.NoError(t, err) + commit, err := repo.CommitObject(ref.Hash()) + require.NoError(t, err) + tree, err := commit.Tree() + require.NoError(t, err) + return tree +} + +// v2ReadFile reads a file from a git tree by path. +func v2ReadFile(t *testing.T, tree *object.Tree, path string) string { + t.Helper() + file, err := tree.File(path) + require.NoError(t, err, "expected file at %s", path) + content, err := file.Contents() + require.NoError(t, err) + return content +} + +func TestV2GitStore_WriteCommittedMain_WritesMetadata(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("a1b2c3d4e5f6") + err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-001", + Strategy: "manual-commit", + Agent: agent.AgentTypeClaudeCode, + Transcript: []byte(`{"type":"human","message":"hello"}`), + Prompts: []string{"hello"}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + tree := v2MainTree(t, repo) + cpPath := cpID.Path() + + // Root CheckpointSummary should exist + summaryContent := v2ReadFile(t, tree, cpPath+"/"+paths.MetadataFileName) + var summary CheckpointSummary + require.NoError(t, json.Unmarshal([]byte(summaryContent), &summary)) + assert.Equal(t, cpID, summary.CheckpointID) + assert.Equal(t, "manual-commit", summary.Strategy) + assert.Len(t, summary.Sessions, 1) + + // Session metadata should exist in subdirectory 0/ + sessionMeta := v2ReadFile(t, tree, cpPath+"/0/"+paths.MetadataFileName) + var meta CommittedMetadata + require.NoError(t, json.Unmarshal([]byte(sessionMeta), &meta)) + assert.Equal(t, "test-session-001", meta.SessionID) + assert.Equal(t, agent.AgentTypeClaudeCode, meta.Agent) +} + +func TestV2GitStore_WriteCommittedMain_WritesPromptsAndContentHash(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("b2c3d4e5f6a1") + err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-002", + Strategy: "manual-commit", + Transcript: []byte(`{"line":"one"}`), + Prompts: []string{"do the thing", "also this"}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + tree := v2MainTree(t, repo) + cpPath := cpID.Path() + + // prompt.txt should contain both prompts joined by separator + promptContent := v2ReadFile(t, tree, cpPath+"/0/"+paths.PromptFileName) + assert.Contains(t, promptContent, "do the thing") + assert.Contains(t, promptContent, "also this") + + // content_hash.txt should be a sha256 hash of the (redacted) transcript + hashContent := v2ReadFile(t, tree, cpPath+"/0/"+paths.ContentHashFileName) + assert.True(t, strings.HasPrefix(hashContent, "sha256:"), "content hash should be sha256 prefixed") +} + +func TestV2GitStore_WriteCommittedMain_ExcludesTranscript(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("c3d4e5f6a1b2") + err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-003", + Strategy: "manual-commit", + Transcript: []byte(`{"line":"one"}` + "\n" + `{"line":"two"}`), + Prompts: []string{"hello"}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + tree := v2MainTree(t, repo) + cpPath := cpID.Path() + + // full.jsonl should NOT be in the /main tree + cpTree, err := tree.Tree(cpPath) + require.NoError(t, err) + + sessionTree, err := cpTree.Tree("0") + require.NoError(t, err) + + for _, entry := range sessionTree.Entries { + assert.NotEqual(t, paths.TranscriptFileName, entry.Name, + "raw transcript (full.jsonl) must not be on /main ref") + assert.False(t, strings.HasPrefix(entry.Name, paths.TranscriptFileName+"."), + "transcript chunks must not be on /main ref") + } +} + +func TestV2GitStore_WriteCommittedMain_NoTranscript_SkipsContentHash(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("d4e5f6a1b2c3") + err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-004", + Strategy: "manual-commit", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + tree := v2MainTree(t, repo) + cpPath := cpID.Path() + + // content_hash.txt should NOT exist when there's no transcript + cpTree, err := tree.Tree(cpPath) + require.NoError(t, err) + sessionTree, err := cpTree.Tree("0") + require.NoError(t, err) + + _, err = sessionTree.File(paths.ContentHashFileName) + assert.Error(t, err, "content_hash.txt should not exist without transcript") +} + +func TestV2GitStore_WriteCommittedMain_MultiSession(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("e5f6a1b2c3d4") + + // First session + err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-A", + Strategy: "manual-commit", + Transcript: []byte(`{"line":"a"}`), + CheckpointsCount: 3, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + // Second session (different session ID, same checkpoint) + err = store.writeCommittedMain(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-B", + Strategy: "manual-commit", + Transcript: []byte(`{"line":"b"}`), + CheckpointsCount: 2, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + tree := v2MainTree(t, repo) + cpPath := cpID.Path() + + // Root summary should list 2 sessions + summaryContent := v2ReadFile(t, tree, cpPath+"/"+paths.MetadataFileName) + var summary CheckpointSummary + require.NoError(t, json.Unmarshal([]byte(summaryContent), &summary)) + assert.Len(t, summary.Sessions, 2) + assert.Equal(t, 5, summary.CheckpointsCount, "aggregated count: 3+2") + + // Both session subdirectories should exist + _ = v2ReadFile(t, tree, cpPath+"/0/"+paths.MetadataFileName) + _ = v2ReadFile(t, tree, cpPath+"/1/"+paths.MetadataFileName) +} From 96aff8310cc1e0e1850e284316b2c257220d5813 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 13:53:03 -0700 Subject: [PATCH 04/18] feat: add V2GitStore.writeCommittedFullTranscript ... for transcript writes to /full/current ref --- cmd/entire/cli/checkpoint/v2_committed.go | 87 ++++++++++++ cmd/entire/cli/checkpoint/v2_store_test.go | 148 +++++++++++++++++++++ 2 files changed, 235 insertions(+) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index 125613adb..7be22d27f 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -5,9 +5,12 @@ import ( "crypto/sha256" "errors" "fmt" + "os" "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/validation" @@ -203,6 +206,90 @@ func (s *V2GitStore) writeContentHash(opts WriteCommittedOptions, sessionPath st return nil } +// writeCommittedFullTranscript writes the raw transcript to the /full/current ref. +// Each write replaces the entire tree — /full/current only ever contains the +// transcript for the most recently written checkpoint. Older transcripts are +// discarded; generation rotation (future work) will archive them before replacement. +// +// sessionIndex is the session slot (0-based), determined by the caller to stay +// consistent with the /main ref's session numbering. +// This is a no-op if opts.Transcript is empty (and opts.TranscriptPath is unset). +func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts WriteCommittedOptions, sessionIndex int) error { //nolint:unparam // sessionIndex will vary once WriteCommitted orchestrates both refs + transcript := opts.Transcript + if len(transcript) == 0 && opts.TranscriptPath != "" { + var readErr error + transcript, readErr = os.ReadFile(opts.TranscriptPath) + if readErr != nil { + transcript = nil + } + } + if len(transcript) == 0 { + return nil // No transcript to write + } + + if err := validateWriteOpts(opts); err != nil { + return err + } + + refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) + if err := s.ensureRef(refName); err != nil { + return fmt.Errorf("failed to ensure /full/current ref: %w", err) + } + + parentHash, _, err := s.getRefState(refName) + if err != nil { + return err + } + + // Build a fresh tree with only this checkpoint's transcript (no accumulation). + basePath := opts.CheckpointID.Path() + "/" + sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) + + entries := make(map[string]object.TreeEntry) + if err := s.writeTranscriptBlobs(ctx, transcript, opts.Agent, sessionPath, entries); err != nil { + return err + } + + newTreeHash, err := BuildTreeFromEntries(s.repo, entries) + if err != nil { + return fmt.Errorf("failed to build /full/current tree: %w", err) + } + + commitMsg := fmt.Sprintf("Checkpoint: %s\n", opts.CheckpointID) + return s.updateRef(refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail) +} + +// writeTranscriptBlobs writes redacted, chunked transcript blobs to entries. +// Unlike GitStore.writeTranscript, this does NOT write content_hash.txt — that +// belongs on the /main ref. +func (s *V2GitStore) writeTranscriptBlobs(ctx context.Context, transcript []byte, agentType types.AgentType, sessionPath string, entries map[string]object.TreeEntry) error { + // Redact secrets before chunking + redacted, err := redact.JSONLBytes(transcript) + if err != nil { + return fmt.Errorf("failed to redact transcript: %w", err) + } + + chunks, err := agent.ChunkTranscript(ctx, redacted, agentType) + if err != nil { + return fmt.Errorf("failed to chunk transcript: %w", err) + } + + for i, chunk := range chunks { + chunkPath := sessionPath + agent.ChunkFileName(paths.TranscriptFileName, i) + blobHash, err := CreateBlobFromContent(s.repo, chunk) + if err != nil { + return err + } + entries[chunkPath] = object.TreeEntry{ + Name: chunkPath, + Mode: filemode.Regular, + Hash: blobHash, + } + } + + return nil +} + // validateWriteOpts validates identifiers in WriteCommittedOptions. func validateWriteOpts(opts WriteCommittedOptions) error { if opts.CheckpointID.IsEmpty() { diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index 968a98dd5..3129aec0a 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -376,3 +376,151 @@ func TestV2GitStore_WriteCommittedMain_MultiSession(t *testing.T) { _ = v2ReadFile(t, tree, cpPath+"/0/"+paths.MetadataFileName) _ = v2ReadFile(t, tree, cpPath+"/1/"+paths.MetadataFileName) } + +// v2FullTree returns the root tree from the /full/current ref for test assertions. +func v2FullTree(t *testing.T, repo *git.Repository) *object.Tree { + t.Helper() + ref, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) + require.NoError(t, err) + commit, err := repo.CommitObject(ref.Hash()) + require.NoError(t, err) + tree, err := commit.Tree() + require.NoError(t, err) + return tree +} + +func TestV2GitStore_WriteCommittedFull_WritesTranscript(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("f1a2b3c4d5e6") + transcript := []byte(`{"type":"human","message":"hello"}` + "\n" + `{"type":"assistant","message":"hi"}`) + + err := store.writeCommittedFullTranscript(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-full-001", + Strategy: "manual-commit", + Transcript: transcript, + Agent: agent.AgentTypeClaudeCode, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }, 0) + require.NoError(t, err) + + tree := v2FullTree(t, repo) + cpPath := cpID.Path() + + // Transcript should exist at session subdirectory 0/ + content := v2ReadFile(t, tree, cpPath+"/0/"+paths.TranscriptFileName) + assert.Contains(t, content, `"type":"human"`) + assert.Contains(t, content, `"type":"assistant"`) +} + +func TestV2GitStore_WriteCommittedFull_ExcludesMetadata(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("a2b3c4d5e6f1") + err := store.writeCommittedFullTranscript(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-full-002", + Strategy: "manual-commit", + Transcript: []byte(`{"line":"one"}`), + Prompts: []string{"hello"}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }, 0) + require.NoError(t, err) + + tree := v2FullTree(t, repo) + cpPath := cpID.Path() + + cpTree, err := tree.Tree(cpPath) + require.NoError(t, err) + + sessionTree, err := cpTree.Tree("0") + require.NoError(t, err) + + for _, entry := range sessionTree.Entries { + assert.NotEqual(t, paths.MetadataFileName, entry.Name, + "metadata.json must not be on /full/current ref") + assert.NotEqual(t, paths.PromptFileName, entry.Name, + "prompt.txt must not be on /full/current ref") + assert.NotEqual(t, paths.ContentHashFileName, entry.Name, + "content_hash.txt must not be on /full/current ref") + } +} + +func TestV2GitStore_WriteCommittedFull_NoTranscript_Noop(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("b3c4d5e6f1a2") + err := store.writeCommittedFullTranscript(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-full-003", + Strategy: "manual-commit", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }, 0) + require.NoError(t, err) + + // /full/current ref should either not exist or have an empty tree + ref, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) + if err == nil { + commit, cErr := repo.CommitObject(ref.Hash()) + require.NoError(t, cErr) + tree, tErr := commit.Tree() + require.NoError(t, tErr) + assert.Empty(t, tree.Entries, "empty transcript should produce no entries") + } + // If ref doesn't exist at all, that's also acceptable for a no-op +} + +func TestV2GitStore_WriteCommittedFullTranscript_ReplacesOnNewCheckpoint(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpA := id.MustCheckpointID("c4d5e6f1a2b3") + cpB := id.MustCheckpointID("d5e6f1a2b3c4") + + // Write checkpoint A + err := store.writeCommittedFullTranscript(ctx, WriteCommittedOptions{ + CheckpointID: cpA, + SessionID: "session-A", + Strategy: "manual-commit", + Transcript: []byte(`{"from":"A"}`), + AuthorName: "Test", + AuthorEmail: "test@test.com", + }, 0) + require.NoError(t, err) + + // Write checkpoint B — should replace A entirely + err = store.writeCommittedFullTranscript(ctx, WriteCommittedOptions{ + CheckpointID: cpB, + SessionID: "session-B", + Strategy: "manual-commit", + Transcript: []byte(`{"from":"B"}`), + AuthorName: "Test", + AuthorEmail: "test@test.com", + }, 0) + require.NoError(t, err) + + tree := v2FullTree(t, repo) + + // Checkpoint B should be present + contentB := v2ReadFile(t, tree, cpB.Path()+"/0/"+paths.TranscriptFileName) + assert.Contains(t, contentB, `"from":"B"`) + + // Checkpoint A should NOT be present — replaced by B + _, err = tree.Tree(cpA.Path()) + assert.Error(t, err, "checkpoint A should not exist after checkpoint B replaced it") +} From 0c3244ca122e41e1d06fb5f42cc4d8dae527287f Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 14:09:27 -0700 Subject: [PATCH 05/18] feat: add V2GitStore.WriteCommitted composing /main and /full/current writes --- cmd/entire/cli/checkpoint/v2_committed.go | 55 ++++++--- cmd/entire/cli/checkpoint/v2_store_test.go | 124 ++++++++++++++++++++- 2 files changed, 160 insertions(+), 19 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index 7be22d27f..5c79ddf3e 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -22,22 +22,43 @@ import ( "github.com/go-git/go-git/v6/plumbing/object" ) +// WriteCommitted writes a committed checkpoint to both v2 refs: +// - /main: metadata, prompts, content hash (no raw transcript) +// - /full/current: raw transcript only (replaces previous content) +// +// This is the public entry point for v2 dual-writes. The session index is +// determined from the /main ref and passed to the /full/current write to +// keep both refs consistent. +func (s *V2GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOptions) error { + sessionIndex, err := s.writeCommittedMain(ctx, opts) + if err != nil { + return fmt.Errorf("v2 /main write failed: %w", err) + } + + if err := s.writeCommittedFullTranscript(ctx, opts, sessionIndex); err != nil { + return fmt.Errorf("v2 /full/current write failed: %w", err) + } + + return nil +} + // writeCommittedMain writes metadata entries to the /main ref. // This includes session metadata, prompts, and content hash — but NOT the // raw transcript (full.jsonl), which goes to /full/current. -func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommittedOptions) error { +// Returns the session index used, so the caller can pass it to writeCommittedFullTranscript. +func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommittedOptions) (int, error) { if err := validateWriteOpts(opts); err != nil { - return err + return 0, err } refName := plumbing.ReferenceName(paths.V2MainRefName) if err := s.ensureRef(refName); err != nil { - return fmt.Errorf("failed to ensure /main ref: %w", err) + return 0, fmt.Errorf("failed to ensure /main ref: %w", err) } parentHash, rootTreeHash, err := s.getRefState(refName) if err != nil { - return err + return 0, err } basePath := opts.CheckpointID.Path() + "/" @@ -46,27 +67,32 @@ func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommitted // Read existing entries at this checkpoint's shard path entries, err := s.gs.flattenCheckpointEntries(rootTreeHash, checkpointPath) if err != nil { - return err + return 0, err } // Build main session entries (metadata, prompts, content hash — no transcript) - if err := s.writeMainCheckpointEntries(ctx, opts, basePath, entries); err != nil { - return err + sessionIndex, err := s.writeMainCheckpointEntries(ctx, opts, basePath, entries) + if err != nil { + return 0, err } // Splice entries into root tree newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { - return err + return 0, err } commitMsg := fmt.Sprintf("Checkpoint: %s\n", opts.CheckpointID) - return s.updateRef(refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail) + if err := s.updateRef(refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail); err != nil { + return 0, err + } + return sessionIndex, nil } // writeMainCheckpointEntries orchestrates writing session data to the /main ref. // It mirrors GitStore.writeStandardCheckpointEntries but excludes raw transcript blobs. -func (s *V2GitStore) writeMainCheckpointEntries(ctx context.Context, opts WriteCommittedOptions, basePath string, entries map[string]object.TreeEntry) error { +// Returns the session index used, for coordination with writeCommittedFullTranscript. +func (s *V2GitStore) writeMainCheckpointEntries(ctx context.Context, opts WriteCommittedOptions, basePath string, entries map[string]object.TreeEntry) (int, error) { // Read existing summary to get current session count var existingSummary *CheckpointSummary metadataPath := basePath + paths.MetadataFileName @@ -84,7 +110,7 @@ func (s *V2GitStore) writeMainCheckpointEntries(ctx context.Context, opts WriteC sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) sessionFilePaths, err := s.writeMainSessionToSubdirectory(opts, sessionPath, entries) if err != nil { - return err + return 0, err } // Build the sessions array @@ -98,7 +124,10 @@ func (s *V2GitStore) writeMainCheckpointEntries(ctx context.Context, opts WriteC sessions[sessionIndex] = sessionFilePaths // Write root CheckpointSummary - return s.gs.writeCheckpointSummary(opts, basePath, entries, sessions) + if err := s.gs.writeCheckpointSummary(opts, basePath, entries, sessions); err != nil { + return 0, err + } + return sessionIndex, nil } // writeMainSessionToSubdirectory writes a single session's metadata, prompts, and @@ -214,7 +243,7 @@ func (s *V2GitStore) writeContentHash(opts WriteCommittedOptions, sessionPath st // sessionIndex is the session slot (0-based), determined by the caller to stay // consistent with the /main ref's session numbering. // This is a no-op if opts.Transcript is empty (and opts.TranscriptPath is unset). -func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts WriteCommittedOptions, sessionIndex int) error { //nolint:unparam // sessionIndex will vary once WriteCommitted orchestrates both refs +func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts WriteCommittedOptions, sessionIndex int) error { transcript := opts.Transcript if len(transcript) == 0 && opts.TranscriptPath != "" { var readErr error diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index 3129aec0a..c5bd54bb3 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -203,7 +203,7 @@ func TestV2GitStore_WriteCommittedMain_WritesMetadata(t *testing.T) { ctx := context.Background() cpID := id.MustCheckpointID("a1b2c3d4e5f6") - err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + _, err := store.writeCommittedMain(ctx, WriteCommittedOptions{ CheckpointID: cpID, SessionID: "test-session-001", Strategy: "manual-commit", @@ -241,7 +241,7 @@ func TestV2GitStore_WriteCommittedMain_WritesPromptsAndContentHash(t *testing.T) ctx := context.Background() cpID := id.MustCheckpointID("b2c3d4e5f6a1") - err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + _, err := store.writeCommittedMain(ctx, WriteCommittedOptions{ CheckpointID: cpID, SessionID: "test-session-002", Strategy: "manual-commit", @@ -272,7 +272,7 @@ func TestV2GitStore_WriteCommittedMain_ExcludesTranscript(t *testing.T) { ctx := context.Background() cpID := id.MustCheckpointID("c3d4e5f6a1b2") - err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + _, err := store.writeCommittedMain(ctx, WriteCommittedOptions{ CheckpointID: cpID, SessionID: "test-session-003", Strategy: "manual-commit", @@ -308,7 +308,7 @@ func TestV2GitStore_WriteCommittedMain_NoTranscript_SkipsContentHash(t *testing. ctx := context.Background() cpID := id.MustCheckpointID("d4e5f6a1b2c3") - err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + _, err := store.writeCommittedMain(ctx, WriteCommittedOptions{ CheckpointID: cpID, SessionID: "test-session-004", Strategy: "manual-commit", @@ -339,7 +339,7 @@ func TestV2GitStore_WriteCommittedMain_MultiSession(t *testing.T) { cpID := id.MustCheckpointID("e5f6a1b2c3d4") // First session - err := store.writeCommittedMain(ctx, WriteCommittedOptions{ + _, err := store.writeCommittedMain(ctx, WriteCommittedOptions{ CheckpointID: cpID, SessionID: "session-A", Strategy: "manual-commit", @@ -351,7 +351,7 @@ func TestV2GitStore_WriteCommittedMain_MultiSession(t *testing.T) { require.NoError(t, err) // Second session (different session ID, same checkpoint) - err = store.writeCommittedMain(ctx, WriteCommittedOptions{ + _, err = store.writeCommittedMain(ctx, WriteCommittedOptions{ CheckpointID: cpID, SessionID: "session-B", Strategy: "manual-commit", @@ -524,3 +524,115 @@ func TestV2GitStore_WriteCommittedFullTranscript_ReplacesOnNewCheckpoint(t *test _, err = tree.Tree(cpA.Path()) assert.Error(t, err, "checkpoint A should not exist after checkpoint B replaced it") } + +func TestV2GitStore_WriteCommitted_WritesBothRefs(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("aa11bb22cc33") + err := store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-both", + Strategy: "manual-commit", + Agent: agent.AgentTypeClaudeCode, + Transcript: []byte(`{"type":"assistant","message":"hello"}`), + Prompts: []string{"hi there"}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + cpPath := cpID.Path() + + // /main ref should have metadata, prompt, content hash — no transcript + mainTree := v2MainTree(t, repo) + _ = v2ReadFile(t, mainTree, cpPath+"/"+paths.MetadataFileName) + _ = v2ReadFile(t, mainTree, cpPath+"/0/"+paths.MetadataFileName) + _ = v2ReadFile(t, mainTree, cpPath+"/0/"+paths.PromptFileName) + _ = v2ReadFile(t, mainTree, cpPath+"/0/"+paths.ContentHashFileName) + + mainSessionTree, err := mainTree.Tree(cpPath + "/0") + require.NoError(t, err) + for _, entry := range mainSessionTree.Entries { + assert.NotEqual(t, paths.TranscriptFileName, entry.Name) + } + + // /full/current ref should have transcript only + fullTree := v2FullTree(t, repo) + content := v2ReadFile(t, fullTree, cpPath+"/0/"+paths.TranscriptFileName) + assert.Contains(t, content, `"type":"assistant"`) +} + +func TestV2GitStore_WriteCommitted_NoTranscript_OnlyWritesMain(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("bb22cc33dd44") + err := store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-notx", + Strategy: "manual-commit", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + // /main should have metadata + mainTree := v2MainTree(t, repo) + _ = v2ReadFile(t, mainTree, cpID.Path()+"/0/"+paths.MetadataFileName) + + // /full/current ref should not exist (no transcript = no-op for full) + _, err = repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) + assert.Error(t, err, "/full/current should not exist when no transcript is written") +} + +func TestV2GitStore_WriteCommitted_MultiSession_ConsistentIndex(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("cc33dd44ee55") + + // First session + err := store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-X", + Strategy: "manual-commit", + Transcript: []byte(`{"from":"X"}`), + CheckpointsCount: 2, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + // Second session — same checkpoint, different session ID + err = store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "session-Y", + Strategy: "manual-commit", + Transcript: []byte(`{"from":"Y"}`), + CheckpointsCount: 3, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + cpPath := cpID.Path() + + // /main should have both sessions + mainTree := v2MainTree(t, repo) + summaryContent := v2ReadFile(t, mainTree, cpPath+"/"+paths.MetadataFileName) + var summary CheckpointSummary + require.NoError(t, json.Unmarshal([]byte(summaryContent), &summary)) + assert.Len(t, summary.Sessions, 2) + + // /full/current should have session Y (latest write replaces) + fullTree := v2FullTree(t, repo) + contentY := v2ReadFile(t, fullTree, cpPath+"/1/"+paths.TranscriptFileName) + assert.Contains(t, contentY, `"from":"Y"`) +} From 4c754d46ae086e93c5d2c21652090a2ab48e2716 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 14:34:30 -0700 Subject: [PATCH 06/18] Write content hash to ephemeral /full/current ref ... instead of adding it to the permanent `/main` ref. --- cmd/entire/cli/checkpoint/v2_committed.go | 46 +++++++------------ cmd/entire/cli/checkpoint/v2_store_test.go | 53 ++++++---------------- 2 files changed, 31 insertions(+), 68 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index 5c79ddf3e..8616a780f 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -144,11 +144,6 @@ func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions, } } - // Write content hash from transcript (but not the transcript itself) - if err := s.writeContentHash(opts, sessionPath, entries, &filePaths); err != nil { - return filePaths, err - } - // Write prompts if len(opts.Prompts) > 0 { promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n")) @@ -206,21 +201,9 @@ func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions, return filePaths, nil } -// writeContentHash computes and writes the content hash for the transcript -// without writing the transcript blobs themselves. -func (s *V2GitStore) writeContentHash(opts WriteCommittedOptions, sessionPath string, entries map[string]object.TreeEntry, filePaths *SessionFilePaths) error { - transcript := opts.Transcript - if len(transcript) == 0 { - return nil - } - - // Redact before hashing so the hash matches what /full/current stores - redacted, err := redact.JSONLBytes(transcript) - if err != nil { - return fmt.Errorf("failed to redact transcript for content hash: %w", err) - } - - contentHash := fmt.Sprintf("sha256:%x", sha256.Sum256(redacted)) +// writeContentHash computes and writes the content hash for already-redacted transcript bytes. +func (s *V2GitStore) writeContentHash(redactedTranscript []byte, sessionPath string, entries map[string]object.TreeEntry) error { + contentHash := fmt.Sprintf("sha256:%x", sha256.Sum256(redactedTranscript)) hashBlob, err := CreateBlobFromContent(s.repo, []byte(contentHash)) if err != nil { return err @@ -230,8 +213,6 @@ func (s *V2GitStore) writeContentHash(opts WriteCommittedOptions, sessionPath st Mode: filemode.Regular, Hash: hashBlob, } - filePaths.ContentHash = "/" + sessionPath + paths.ContentHashFileName - return nil } @@ -275,7 +256,13 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) entries := make(map[string]object.TreeEntry) - if err := s.writeTranscriptBlobs(ctx, transcript, opts.Agent, sessionPath, entries); err != nil { + redactedTranscript, err := s.writeTranscriptBlobs(ctx, transcript, opts.Agent, sessionPath, entries) + if err != nil { + return err + } + + // Write content hash alongside the transcript it references + if err := s.writeContentHash(redactedTranscript, sessionPath, entries); err != nil { return err } @@ -289,25 +276,24 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ } // writeTranscriptBlobs writes redacted, chunked transcript blobs to entries. -// Unlike GitStore.writeTranscript, this does NOT write content_hash.txt — that -// belongs on the /main ref. -func (s *V2GitStore) writeTranscriptBlobs(ctx context.Context, transcript []byte, agentType types.AgentType, sessionPath string, entries map[string]object.TreeEntry) error { +// Returns the redacted transcript bytes so the caller can compute the content hash. +func (s *V2GitStore) writeTranscriptBlobs(ctx context.Context, transcript []byte, agentType types.AgentType, sessionPath string, entries map[string]object.TreeEntry) ([]byte, error) { // Redact secrets before chunking redacted, err := redact.JSONLBytes(transcript) if err != nil { - return fmt.Errorf("failed to redact transcript: %w", err) + return nil, fmt.Errorf("failed to redact transcript: %w", err) } chunks, err := agent.ChunkTranscript(ctx, redacted, agentType) if err != nil { - return fmt.Errorf("failed to chunk transcript: %w", err) + return nil, fmt.Errorf("failed to chunk transcript: %w", err) } for i, chunk := range chunks { chunkPath := sessionPath + agent.ChunkFileName(paths.TranscriptFileName, i) blobHash, err := CreateBlobFromContent(s.repo, chunk) if err != nil { - return err + return nil, err } entries[chunkPath] = object.TreeEntry{ Name: chunkPath, @@ -316,7 +302,7 @@ func (s *V2GitStore) writeTranscriptBlobs(ctx context.Context, transcript []byte } } - return nil + return redacted, nil } // validateWriteOpts validates identifiers in WriteCommittedOptions. diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index c5bd54bb3..c5b4a2a26 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -234,7 +234,7 @@ func TestV2GitStore_WriteCommittedMain_WritesMetadata(t *testing.T) { assert.Equal(t, agent.AgentTypeClaudeCode, meta.Agent) } -func TestV2GitStore_WriteCommittedMain_WritesPromptsAndContentHash(t *testing.T) { +func TestV2GitStore_WriteCommittedMain_WritesPrompts(t *testing.T) { t.Parallel() repo := initTestRepo(t) store := NewV2GitStore(repo) @@ -260,9 +260,11 @@ func TestV2GitStore_WriteCommittedMain_WritesPromptsAndContentHash(t *testing.T) assert.Contains(t, promptContent, "do the thing") assert.Contains(t, promptContent, "also this") - // content_hash.txt should be a sha256 hash of the (redacted) transcript - hashContent := v2ReadFile(t, tree, cpPath+"/0/"+paths.ContentHashFileName) - assert.True(t, strings.HasPrefix(hashContent, "sha256:"), "content hash should be sha256 prefixed") + // content_hash.txt should NOT be on /main — it lives on /full/current + mainSessionTree, err := tree.Tree(cpPath + "/0") + require.NoError(t, err) + _, err = mainSessionTree.File(paths.ContentHashFileName) + assert.Error(t, err, "content_hash.txt should not be on /main ref") } func TestV2GitStore_WriteCommittedMain_ExcludesTranscript(t *testing.T) { @@ -301,35 +303,6 @@ func TestV2GitStore_WriteCommittedMain_ExcludesTranscript(t *testing.T) { } } -func TestV2GitStore_WriteCommittedMain_NoTranscript_SkipsContentHash(t *testing.T) { - t.Parallel() - repo := initTestRepo(t) - store := NewV2GitStore(repo) - ctx := context.Background() - - cpID := id.MustCheckpointID("d4e5f6a1b2c3") - _, err := store.writeCommittedMain(ctx, WriteCommittedOptions{ - CheckpointID: cpID, - SessionID: "test-session-004", - Strategy: "manual-commit", - AuthorName: "Test", - AuthorEmail: "test@test.com", - }) - require.NoError(t, err) - - tree := v2MainTree(t, repo) - cpPath := cpID.Path() - - // content_hash.txt should NOT exist when there's no transcript - cpTree, err := tree.Tree(cpPath) - require.NoError(t, err) - sessionTree, err := cpTree.Tree("0") - require.NoError(t, err) - - _, err = sessionTree.File(paths.ContentHashFileName) - assert.Error(t, err, "content_hash.txt should not exist without transcript") -} - func TestV2GitStore_WriteCommittedMain_MultiSession(t *testing.T) { t.Parallel() repo := initTestRepo(t) @@ -450,9 +423,11 @@ func TestV2GitStore_WriteCommittedFull_ExcludesMetadata(t *testing.T) { "metadata.json must not be on /full/current ref") assert.NotEqual(t, paths.PromptFileName, entry.Name, "prompt.txt must not be on /full/current ref") - assert.NotEqual(t, paths.ContentHashFileName, entry.Name, - "content_hash.txt must not be on /full/current ref") } + + // content_hash.txt SHOULD be on /full/current (co-located with the transcript it hashes) + hashContent := v2ReadFile(t, tree, cpPath+"/0/"+paths.ContentHashFileName) + assert.True(t, strings.HasPrefix(hashContent, "sha256:"), "content hash should be sha256 prefixed") } func TestV2GitStore_WriteCommittedFull_NoTranscript_Noop(t *testing.T) { @@ -546,23 +521,25 @@ func TestV2GitStore_WriteCommitted_WritesBothRefs(t *testing.T) { cpPath := cpID.Path() - // /main ref should have metadata, prompt, content hash — no transcript + // /main ref should have metadata and prompt — no transcript or content hash mainTree := v2MainTree(t, repo) _ = v2ReadFile(t, mainTree, cpPath+"/"+paths.MetadataFileName) _ = v2ReadFile(t, mainTree, cpPath+"/0/"+paths.MetadataFileName) _ = v2ReadFile(t, mainTree, cpPath+"/0/"+paths.PromptFileName) - _ = v2ReadFile(t, mainTree, cpPath+"/0/"+paths.ContentHashFileName) mainSessionTree, err := mainTree.Tree(cpPath + "/0") require.NoError(t, err) for _, entry := range mainSessionTree.Entries { assert.NotEqual(t, paths.TranscriptFileName, entry.Name) + assert.NotEqual(t, paths.ContentHashFileName, entry.Name) } - // /full/current ref should have transcript only + // /full/current ref should have transcript + content hash fullTree := v2FullTree(t, repo) content := v2ReadFile(t, fullTree, cpPath+"/0/"+paths.TranscriptFileName) assert.Contains(t, content, `"type":"assistant"`) + hashContent := v2ReadFile(t, fullTree, cpPath+"/0/"+paths.ContentHashFileName) + assert.True(t, strings.HasPrefix(hashContent, "sha256:")) } func TestV2GitStore_WriteCommitted_NoTranscript_OnlyWritesMain(t *testing.T) { From dc3beb0bb345e928c2e0607ddf82d480907570ef Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 15:00:48 -0700 Subject: [PATCH 07/18] feat: wire v2 dual-write into CondenseSession, gated by checkpoints_v2 setting --- .../strategy/manual_commit_condensation.go | 29 ++- cmd/entire/cli/strategy/manual_commit_test.go | 176 ++++++++++++++++++ 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 608482151..275edf0e4 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -243,8 +243,8 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re } } - // Write checkpoint metadata using the checkpoint store - if err := store.WriteCommitted(ctx, cpkg.WriteCommittedOptions{ + // Build write options (shared by v1 and v2) + writeOpts := cpkg.WriteCommittedOptions{ CheckpointID: checkpointID, SessionID: state.SessionID, Strategy: StrategyNameManualCommit, @@ -265,10 +265,16 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re SessionMetrics: buildSessionMetrics(state), InitialAttribution: attribution, Summary: summary, - }); err != nil { + } + + // Write checkpoint metadata to v1 branch + if err := store.WriteCommitted(ctx, writeOpts); err != nil { return nil, fmt.Errorf("failed to write checkpoint metadata: %w", err) } + // Dual-write to v2 refs when enabled + writeCommittedV2IfEnabled(ctx, repo, writeOpts) + return &CondenseResult{ CheckpointID: checkpointID, SessionID: state.SessionID, @@ -907,3 +913,20 @@ func (s *ManualCommitStrategy) cleanupShadowBranchIfUnused(ctx context.Context, } return nil } + +// 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 writeCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts cpkg.WriteCommittedOptions) { + if !settings.IsCheckpointsV2Enabled(ctx) { + return + } + + v2Store := cpkg.NewV2GitStore(repo) + if err := v2Store.WriteCommitted(ctx, 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_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 4b4e9294e..5f4102b16 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -3875,3 +3875,179 @@ func TestResolveFilesTouched_PrefersStateFallsBackToTranscript(t *testing.T) { } }) } + +// TestCondenseSession_V2DualWrite verifies that when checkpoints_v2 is enabled, +// CondenseSession writes to both v1 (entire/checkpoints/v1) and v2 refs +// (refs/entire/checkpoints/v2/main and refs/entire/checkpoints/v2/full/current). +func TestCondenseSession_V2DualWrite(t *testing.T) { + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + require.NoError(t, err) + + worktree, err := repo.Worktree() + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644)) + _, err = worktree.Add("main.go") + require.NoError(t, err) + commitHash, err := worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + t.Chdir(dir) + + // Enable checkpoints_v2 via settings + entireDir := filepath.Join(dir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + settingsJSON := `{"enabled": true, "strategy": "manual-commit", "strategy_options": {"checkpoints_v2": true}}` + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(settingsJSON), 0o644)) + + s := &ManualCommitStrategy{} + sessionID := "2025-01-15-test-v2-dual-write" + + // Create metadata directory with transcript + metadataDir := ".entire/metadata/" + sessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) + + transcript := `{"type":"human","message":{"content":"hello"}} +{"type":"assistant","message":{"content":"hi there"}} +` + require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644)) + + // SaveStep to create shadow branch + err = s.SaveStep(context.Background(), StepContext{ + SessionID: sessionID, + ModifiedFiles: []string{"main.go"}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "Checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + state, err := s.loadSessionState(context.Background(), sessionID) + require.NoError(t, err) + state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName) + state.BaseCommit = commitHash.String()[:7] + + checkpointID := id.MustCheckpointID("dd11ee22ff33") + result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil) + require.NoError(t, err) + require.NotNil(t, result) + + // v1 branch should exist (as before) + v1Ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err, "v1 metadata branch should exist") + require.NotEqual(t, plumbing.ZeroHash, v1Ref.Hash()) + + // v2 /main ref should exist + v2MainRef, err := repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true) + require.NoError(t, err, "v2 /main ref should exist") + require.NotEqual(t, plumbing.ZeroHash, v2MainRef.Hash()) + + // v2 /full/current ref should exist (transcript was non-empty) + v2FullRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) + require.NoError(t, err, "v2 /full/current ref should exist") + require.NotEqual(t, plumbing.ZeroHash, v2FullRef.Hash()) + + // Verify /main has metadata but no transcript + v2MainCommit, err := repo.CommitObject(v2MainRef.Hash()) + require.NoError(t, err) + v2MainTree, err := v2MainCommit.Tree() + require.NoError(t, err) + + cpPath := checkpointID.Path() + mainCpTree, err := v2MainTree.Tree(cpPath) + require.NoError(t, err) + + // Root metadata.json should exist + _, err = mainCpTree.File(paths.MetadataFileName) + require.NoError(t, err, "root metadata.json should exist on /main") + + // Verify /full/current has transcript + v2FullCommit, err := repo.CommitObject(v2FullRef.Hash()) + require.NoError(t, err) + v2FullTree, err := v2FullCommit.Tree() + require.NoError(t, err) + + fullCpTree, err := v2FullTree.Tree(cpPath) + require.NoError(t, err) + fullSessionTree, err := fullCpTree.Tree("0") + require.NoError(t, err) + _, err = fullSessionTree.File(paths.TranscriptFileName) + require.NoError(t, err, "full.jsonl should exist on /full/current") +} + +// TestCondenseSession_V2Disabled_NoV2Refs verifies that when checkpoints_v2 is +// not enabled, CondenseSession only writes to v1 and does not create v2 refs. +func TestCondenseSession_V2Disabled_NoV2Refs(t *testing.T) { + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + require.NoError(t, err) + + worktree, err := repo.Worktree() + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644)) + _, err = worktree.Add("main.go") + require.NoError(t, err) + commitHash, err := worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + t.Chdir(dir) + + // No checkpoints_v2 setting — default is disabled + entireDir := filepath.Join(dir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + settingsJSON := `{"enabled": true, "strategy": "manual-commit"}` + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(settingsJSON), 0o644)) + + s := &ManualCommitStrategy{} + sessionID := "2025-01-15-test-v2-disabled" + + metadataDir := ".entire/metadata/" + sessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) + + transcript := `{"type":"human","message":{"content":"hello"}} +{"type":"assistant","message":{"content":"hi"}} +` + require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644)) + + err = s.SaveStep(context.Background(), StepContext{ + SessionID: sessionID, + ModifiedFiles: []string{"main.go"}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "Checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + state, err := s.loadSessionState(context.Background(), sessionID) + require.NoError(t, err) + state.TranscriptPath = filepath.Join(metadataDirAbs, paths.TranscriptFileName) + state.BaseCommit = commitHash.String()[:7] + + checkpointID := id.MustCheckpointID("ee22ff33aa44") + result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil) + require.NoError(t, err) + require.NotNil(t, result) + + // v1 should exist + _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err, "v1 metadata branch should exist") + + // v2 refs should NOT exist + _, err = repo.Reference(plumbing.ReferenceName(paths.V2MainRefName), true) + require.Error(t, err, "v2 /main ref should not exist when v2 is disabled") + + _, err = repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) + require.Error(t, err, "v2 /full/current ref should not exist when v2 is disabled") +} From 62170216281a538e9d274e1f8de8b474b1497b1a Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 15:36:47 -0700 Subject: [PATCH 08/18] feat: add V2GitStore.UpdateCommitted and wire into stop-time finalization --- cmd/entire/cli/checkpoint/v2_committed.go | 131 ++++++++++++++++++ cmd/entire/cli/checkpoint/v2_store_test.go | 103 ++++++++++++++ .../strategy/manual_commit_condensation.go | 17 +++ .../cli/strategy/manual_commit_hooks.go | 9 +- 4 files changed, 258 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index 8616a780f..570a827b8 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -42,6 +42,137 @@ func (s *V2GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOpti return nil } +// UpdateCommitted replaces the prompts and/or transcript for an existing v2 checkpoint. +// Called at stop time to finalize checkpoints with the complete session transcript. +// +// On /main: replaces prompts (transcript is not stored there). +// On /full/current: replaces the raw transcript (if provided). +// +// Returns ErrCheckpointNotFound if the checkpoint doesn't exist on /main. +func (s *V2GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error { + if opts.CheckpointID.IsEmpty() { + return errors.New("invalid update options: checkpoint ID is required") + } + + sessionIndex, err := s.updateCommittedMain(ctx, opts) + if err != nil { + return fmt.Errorf("v2 /main update failed: %w", err) + } + + if len(opts.Transcript) > 0 { + if err := s.updateCommittedFullTranscript(ctx, opts, sessionIndex); err != nil { + return fmt.Errorf("v2 /full/current update failed: %w", err) + } + } + + return nil +} + +// updateCommittedMain updates prompts on the /main ref for an existing checkpoint. +// Returns the session index for coordination with /full/current. +func (s *V2GitStore) updateCommittedMain(ctx context.Context, opts UpdateCommittedOptions) (int, error) { + refName := plumbing.ReferenceName(paths.V2MainRefName) + parentHash, rootTreeHash, err := s.getRefState(refName) + if err != nil { + return 0, ErrCheckpointNotFound + } + + basePath := opts.CheckpointID.Path() + "/" + checkpointPath := opts.CheckpointID.Path() + + entries, err := s.gs.flattenCheckpointEntries(rootTreeHash, checkpointPath) + if err != nil { + return 0, err + } + + // Read root summary to find session index + rootMetadataPath := basePath + paths.MetadataFileName + entry, exists := entries[rootMetadataPath] + if !exists { + return 0, ErrCheckpointNotFound + } + + summary, err := readJSONFromBlob[CheckpointSummary](s.repo, entry.Hash) + if err != nil { + return 0, fmt.Errorf("failed to read checkpoint summary: %w", err) + } + if len(summary.Sessions) == 0 { + return 0, ErrCheckpointNotFound + } + + // Find session index by ID, fall back to latest + sessionIndex := s.gs.findSessionIndex(ctx, basePath, summary, entries, opts.SessionID) + if sessionIndex >= len(summary.Sessions) { + // findSessionIndex returns next-available when not found; fall back to latest + sessionIndex = len(summary.Sessions) - 1 + } + + sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) + + // Replace prompts + if len(opts.Prompts) > 0 { + promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n")) + blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent)) + if err != nil { + return 0, fmt.Errorf("failed to create prompt blob: %w", err) + } + entries[sessionPath+paths.PromptFileName] = object.TreeEntry{ + Name: sessionPath + paths.PromptFileName, + Mode: filemode.Regular, + Hash: blobHash, + } + } + + newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) + if err != nil { + return 0, err + } + + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) + commitMsg := fmt.Sprintf("Finalize checkpoint: %s\n", opts.CheckpointID) + if err := s.updateRef(refName, newTreeHash, parentHash, commitMsg, authorName, authorEmail); err != nil { + return 0, err + } + + return sessionIndex, nil +} + +// updateCommittedFullTranscript replaces the transcript on /full/current for a finalized checkpoint. +func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts UpdateCommittedOptions, sessionIndex int) error { + refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) + if err := s.ensureRef(refName); err != nil { + return fmt.Errorf("failed to ensure /full/current ref: %w", err) + } + + parentHash, _, err := s.getRefState(refName) + if err != nil { + return err + } + + // Build fresh tree with finalized transcript (replaces previous content) + basePath := opts.CheckpointID.Path() + "/" + sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) + + entries := make(map[string]object.TreeEntry) + redactedTranscript, err := s.writeTranscriptBlobs(ctx, opts.Transcript, opts.Agent, sessionPath, entries) + if err != nil { + return err + } + + if err := s.writeContentHash(redactedTranscript, sessionPath, entries); err != nil { + return err + } + + newTreeHash, err := BuildTreeFromEntries(s.repo, entries) + if err != nil { + return fmt.Errorf("failed to build /full/current tree: %w", err) + } + + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) + commitMsg := fmt.Sprintf("Finalize checkpoint: %s\n", opts.CheckpointID) + return s.updateRef(refName, newTreeHash, parentHash, commitMsg, authorName, authorEmail) +} + // writeCommittedMain writes metadata entries to the /main ref. // This includes session metadata, prompts, and content hash — but NOT the // raw transcript (full.jsonl), which goes to /full/current. diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index c5b4a2a26..86274319c 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -613,3 +613,106 @@ func TestV2GitStore_WriteCommitted_MultiSession_ConsistentIndex(t *testing.T) { contentY := v2ReadFile(t, fullTree, cpPath+"/1/"+paths.TranscriptFileName) assert.Contains(t, contentY, `"from":"Y"`) } + +func TestV2GitStore_UpdateCommitted_UpdatesBothRefs(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("ff11aa22bb33") + + // Initial write + err := store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-update", + Strategy: "manual-commit", + Agent: agent.AgentTypeClaudeCode, + Transcript: []byte(`{"type":"assistant","message":"initial"}`), + Prompts: []string{"first prompt"}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + // Update with finalized transcript and prompts + err = store.UpdateCommitted(ctx, UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-update", + Transcript: []byte(`{"type":"assistant","message":"finalized"}`), + Prompts: []string{"first prompt", "second prompt"}, + Agent: agent.AgentTypeClaudeCode, + }) + require.NoError(t, err) + + cpPath := cpID.Path() + + // /main should have updated prompts + mainTree := v2MainTree(t, repo) + promptContent := v2ReadFile(t, mainTree, cpPath+"/0/"+paths.PromptFileName) + assert.Contains(t, promptContent, "second prompt") + + // /full/current should have finalized transcript + fullTree := v2FullTree(t, repo) + content := v2ReadFile(t, fullTree, cpPath+"/0/"+paths.TranscriptFileName) + assert.Contains(t, content, "finalized") + assert.NotContains(t, content, "initial") +} + +func TestV2GitStore_UpdateCommitted_NoTranscript_OnlyUpdatesMain(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("aa33bb44cc55") + + // Initial write with transcript + err := store.WriteCommitted(ctx, WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-noupdate", + Strategy: "manual-commit", + Transcript: []byte(`{"type":"assistant","message":"original"}`), + Prompts: []string{"old prompt"}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err) + + // Update with only prompts (no transcript) + err = store.UpdateCommitted(ctx, UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "test-session-noupdate", + Prompts: []string{"old prompt", "new prompt"}, + Agent: agent.AgentTypeClaudeCode, + }) + require.NoError(t, err) + + // /main should have updated prompts + mainTree := v2MainTree(t, repo) + promptContent := v2ReadFile(t, mainTree, cpID.Path()+"/0/"+paths.PromptFileName) + assert.Contains(t, promptContent, "new prompt") + + // /full/current should still have original transcript (not replaced) + fullTree := v2FullTree(t, repo) + content := v2ReadFile(t, fullTree, cpID.Path()+"/0/"+paths.TranscriptFileName) + assert.Contains(t, content, "original") +} + +func TestV2GitStore_UpdateCommitted_CheckpointNotFound(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewV2GitStore(repo) + ctx := context.Background() + + cpID := id.MustCheckpointID("bb44cc55dd66") + + // Update without prior write should return error + err := store.UpdateCommitted(ctx, UpdateCommittedOptions{ + CheckpointID: cpID, + SessionID: "nonexistent", + Transcript: []byte(`{"type":"assistant","message":"hello"}`), + Agent: agent.AgentTypeClaudeCode, + }) + require.Error(t, err) +} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 275edf0e4..0e33e8d08 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -930,3 +930,20 @@ func writeCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts c ) } } + +// updateCommittedV2IfEnabled updates existing checkpoint data on v2 refs when +// checkpoints_v2 is enabled. Used at stop time to finalize transcripts. +// Failures are logged as warnings — must not block the v1 path. +func updateCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts cpkg.UpdateCommittedOptions) { + if !settings.IsCheckpointsV2Enabled(ctx) { + return + } + + v2Store := cpkg.NewV2GitStore(repo) + if err := v2Store.UpdateCommitted(ctx, opts); err != nil { + logging.Warn(ctx, "v2 dual-write update 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 c90c3bd6e..84ebcd843 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2276,13 +2276,15 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s continue } - updateErr := store.UpdateCommitted(ctx, checkpoint.UpdateCommittedOptions{ + updateOpts := checkpoint.UpdateCommittedOptions{ CheckpointID: cpID, SessionID: state.SessionID, Transcript: fullTranscript, Prompts: prompts, Agent: state.AgentType, - }) + } + + updateErr := store.UpdateCommitted(ctx, updateOpts) if updateErr != nil { logging.Warn(logCtx, "finalize: failed to update checkpoint", slog.String("checkpoint_id", cpIDStr), @@ -2292,6 +2294,9 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s continue } + // Dual-write: update v2 refs when enabled + updateCommittedV2IfEnabled(ctx, repo, updateOpts) + logging.Info(logCtx, "finalize: checkpoint updated with full transcript", slog.String("checkpoint_id", cpIDStr), slog.String("session_id", state.SessionID), From 2da036a3883fff3cc1377eba9140a62e216f85a2 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 16:04:57 -0700 Subject: [PATCH 09/18] test: add integration tests for v2 dual-write workflow --- cmd/entire/cli/integration_test/testenv.go | 53 ++++ .../integration_test/v2_dual_write_test.go | 230 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 cmd/entire/cli/integration_test/v2_dual_write_test.go diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index c2fee1e62..4cb94636a 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -854,6 +854,59 @@ func (env *TestEnv) ReadFileFromBranch(branchName, filePath string) (string, boo return content, true } +// ReadFileFromRef reads a file's content from a specific ref's tree. +// Unlike ReadFileFromBranch, this takes a full ref name (e.g., "refs/entire/checkpoints/v2/main") +// and does not prepend "refs/heads/". +// Returns the content and true if found, empty string and false if not found. +func (env *TestEnv) ReadFileFromRef(refName, filePath string) (string, bool) { + env.T.Helper() + + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + ref, err := repo.Reference(plumbing.ReferenceName(refName), true) + if err != nil { + return "", false + } + + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return "", false + } + + tree, err := commit.Tree() + if err != nil { + return "", false + } + + file, err := tree.File(filePath) + if err != nil { + return "", false + } + + content, err := file.Contents() + if err != nil { + return "", false + } + + return content, true +} + +// RefExists checks if a ref exists in the repository. +func (env *TestEnv) RefExists(refName string) bool { + env.T.Helper() + + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + _, err = repo.Reference(plumbing.ReferenceName(refName), true) + return err == nil +} + // GetLatestCommitMessageOnBranch returns the commit message of the latest commit on the given branch. func (env *TestEnv) GetLatestCommitMessageOnBranch(branchName string) string { env.T.Helper() diff --git a/cmd/entire/cli/integration_test/v2_dual_write_test.go b/cmd/entire/cli/integration_test/v2_dual_write_test.go new file mode 100644 index 000000000..714ba6c98 --- /dev/null +++ b/cmd/entire/cli/integration_test/v2_dual_write_test.go @@ -0,0 +1,230 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "strings" + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestV2DualWrite_FullWorkflow verifies that when checkpoints_v2 is enabled, +// a full session workflow (prompt → stop → commit) writes checkpoint data +// to both v1 and v2 refs. +func TestV2DualWrite_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-test") + + // Initialize with checkpoints_v2 enabled + env.InitEntireWithOptions(map[string]any{ + "checkpoints_v2": true, + }) + + // Start session + session := env.NewSession() + err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add greeting function") + require.NoError(t, err) + + // Create a file and transcript + 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) + + // User commits (triggers prepare-commit-msg + post-commit → condensation) + env.GitCommitWithShadowHooks("Add greeting function", "greet.go") + + // Get checkpoint ID from commit trailer + 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() + + // ======================================== + // Verify v1 branch (existing behavior) + // ======================================== + assert.True(t, env.BranchExists(paths.MetadataBranchName), + "v1 metadata branch should exist") + + v1Summary, found := env.ReadFileFromBranch(paths.MetadataBranchName, cpPath+"/"+paths.MetadataFileName) + require.True(t, found, "v1 root metadata.json should exist") + assert.Contains(t, v1Summary, cpIDStr) + + // ======================================== + // Verify v2 /main ref + // ======================================== + assert.True(t, env.RefExists(paths.V2MainRefName), + "v2 /main ref should exist") + + // Root CheckpointSummary + mainSummary, found := env.ReadFileFromRef(paths.V2MainRefName, cpPath+"/"+paths.MetadataFileName) + require.True(t, found, "v2 /main root metadata.json should exist") + + var summary checkpoint.CheckpointSummary + require.NoError(t, json.Unmarshal([]byte(mainSummary), &summary)) + assert.Equal(t, cpID, summary.CheckpointID) + assert.Len(t, summary.Sessions, 1) + + // Session metadata + mainSessionMeta, found := env.ReadFileFromRef(paths.V2MainRefName, cpPath+"/0/"+paths.MetadataFileName) + require.True(t, found, "v2 /main session metadata.json should exist") + assert.Contains(t, mainSessionMeta, session.ID) + + // Prompts + mainPrompts, found := env.ReadFileFromRef(paths.V2MainRefName, cpPath+"/0/"+paths.PromptFileName) + require.True(t, found, "v2 /main prompt.txt should exist") + assert.Contains(t, mainPrompts, "Add greeting function") + + // Transcript should NOT be on /main + _, found = env.ReadFileFromRef(paths.V2MainRefName, cpPath+"/0/"+paths.TranscriptFileName) + assert.False(t, found, "full.jsonl should NOT be on v2 /main") + + // ======================================== + // Verify v2 /full/current ref + // ======================================== + assert.True(t, env.RefExists(paths.V2FullCurrentRefName), + "v2 /full/current ref should exist") + + // Transcript should be on /full/current + fullTranscript, found := env.ReadFileFromRef(paths.V2FullCurrentRefName, cpPath+"/0/"+paths.TranscriptFileName) + require.True(t, found, "full.jsonl should exist on v2 /full/current") + assert.Contains(t, fullTranscript, "Greet") + + // Content hash should be co-located with transcript + fullHash, found := env.ReadFileFromRef(paths.V2FullCurrentRefName, cpPath+"/0/"+paths.ContentHashFileName) + require.True(t, found, "content_hash.txt should exist on v2 /full/current") + assert.True(t, strings.HasPrefix(fullHash, "sha256:")) + + // Metadata should NOT be on /full/current + _, found = env.ReadFileFromRef(paths.V2FullCurrentRefName, cpPath+"/0/"+paths.MetadataFileName) + assert.False(t, found, "metadata.json should NOT be on v2 /full/current") +} + +// TestV2DualWrite_Disabled verifies that when checkpoints_v2 is NOT enabled, +// no v2 refs are created. +func TestV2DualWrite_Disabled(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-disabled") + + // Initialize WITHOUT checkpoints_v2 + env.InitEntire() + + session := env.NewSession() + err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add helper") + require.NoError(t, err) + + env.WriteFile("helper.go", "package main\n\nfunc Helper() {}") + session.CreateTranscript( + "Add helper", + []FileChange{{Path: "helper.go", Content: "package main\n\nfunc Helper() {}"}}, + ) + err = env.SimulateStop(session.ID, session.TranscriptPath) + require.NoError(t, err) + + env.GitCommitWithShadowHooks("Add helper", "helper.go") + + // v1 should exist + assert.True(t, env.BranchExists(paths.MetadataBranchName), + "v1 metadata branch should exist") + + // v2 refs should NOT exist + assert.False(t, env.RefExists(paths.V2MainRefName), + "v2 /main ref should NOT exist when v2 is disabled") + assert.False(t, env.RefExists(paths.V2FullCurrentRefName), + "v2 /full/current ref should NOT exist when v2 is disabled") +} + +// TestV2DualWrite_StopTimeFinalization verifies that stop-time transcript +// finalization also updates v2 refs when checkpoints_v2 is enabled. +func TestV2DualWrite_StopTimeFinalization(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-finalize") + + env.InitEntireWithOptions(map[string]any{ + "checkpoints_v2": true, + }) + + // Start session and create first checkpoint + session := env.NewSession() + err := env.SimulateUserPromptSubmitWithPrompt(session.ID, "Create main file") + require.NoError(t, err) + + env.WriteFile("main.go", "package main\n\nfunc main() {}") + session.CreateTranscript( + "Create main file", + []FileChange{{Path: "main.go", Content: "package main\n\nfunc main() {}"}}, + ) + err = env.SimulateStop(session.ID, session.TranscriptPath) + require.NoError(t, err) + + // Mid-session commit (checkpoint condensed, but transcript is provisional) + env.GitCommitWithShadowHooks("Add main.go", "main.go") + + cpIDStr := env.GetLatestCheckpointIDFromHistory() + require.NotEmpty(t, cpIDStr) + + cpID, err := id.NewCheckpointID(cpIDStr) + require.NoError(t, err) + cpPath := cpID.Path() + + // Continue session with more work + err = env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add tests") + require.NoError(t, err) + + env.WriteFile("main_test.go", "package main\n\nimport \"testing\"\n\nfunc TestMain(t *testing.T) {}") + // Rebuild transcript with both turns (CreateTranscript replaces) + session.CreateTranscript( + "Add tests", + []FileChange{ + {Path: "main.go", Content: "package main\n\nfunc main() {}"}, + {Path: "main_test.go", Content: "package main\n\nimport \"testing\"\n\nfunc TestMain(t *testing.T) {}"}, + }, + ) + + // Stop finalizes the transcript for all turn checkpoints + err = env.SimulateStop(session.ID, session.TranscriptPath) + require.NoError(t, err) + + // After stop-time finalization, /full/current should have the finalized transcript + fullTranscript, found := env.ReadFileFromRef(paths.V2FullCurrentRefName, cpPath+"/0/"+paths.TranscriptFileName) + require.True(t, found, "full.jsonl should exist on /full/current after finalization") + assert.Contains(t, fullTranscript, "main") +} From 20a37d50ff4f04a01ba52907c1eaf6a63f0785c7 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 16:53:44 -0700 Subject: [PATCH 10/18] fix: add omitempty to SessionFilePaths to avoid empty strings in v2 metadata --- cmd/entire/cli/checkpoint/checkpoint.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 40133fbcf..e009d6beb 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -418,10 +418,10 @@ func (m CommittedMetadata) GetTranscriptStart() int { // Paths include the full checkpoint path prefix (e.g., "/a1/b2c3d4e5f6/1/metadata.json"). // Used in CheckpointSummary.Sessions to map session IDs to their file locations. type SessionFilePaths struct { - Metadata string `json:"metadata"` - Transcript string `json:"transcript"` - ContentHash string `json:"content_hash"` - Prompt string `json:"prompt"` + Metadata string `json:"metadata,omitempty"` + Transcript string `json:"transcript,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + Prompt string `json:"prompt,omitempty"` } // CheckpointSummary is the root-level metadata.json for a checkpoint. From 66841b294274eb69cf4e6fe63f7c95cc76c35226 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 16:54:07 -0700 Subject: [PATCH 11/18] fix: Remove redundant validation code --- cmd/entire/cli/checkpoint/committed.go | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 47a75beb8..cc6a7e6f3 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -22,7 +22,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/trailers" - "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/entireio/cli/redact" @@ -44,18 +43,8 @@ var errStopIteration = errors.New("stop iteration") // - For incremental checkpoints: checkpoints/NNN-.json // - For final checkpoints: checkpoint.json and agent-.jsonl func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOptions) error { - // Validate identifiers to prevent path traversal and malformed data - if opts.CheckpointID.IsEmpty() { - return errors.New("invalid checkpoint options: checkpoint ID is required") - } - if err := validation.ValidateSessionID(opts.SessionID); err != nil { - return fmt.Errorf("invalid checkpoint options: %w", err) - } - if err := validation.ValidateToolUseID(opts.ToolUseID); err != nil { - return fmt.Errorf("invalid checkpoint options: %w", err) - } - if err := validation.ValidateAgentID(opts.AgentID); err != nil { - return fmt.Errorf("invalid checkpoint options: %w", err) + if err := validateWriteOpts(opts); err != nil { + return err } // Ensure sessions branch exists From 860efa8c519fa416d2a66db9294b4ed34db159b7 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 16:59:02 -0700 Subject: [PATCH 12/18] Only mark optional attributes as `omitempty` --- cmd/entire/cli/checkpoint/checkpoint.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index e009d6beb..f3300a366 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -418,10 +418,10 @@ func (m CommittedMetadata) GetTranscriptStart() int { // Paths include the full checkpoint path prefix (e.g., "/a1/b2c3d4e5f6/1/metadata.json"). // Used in CheckpointSummary.Sessions to map session IDs to their file locations. type SessionFilePaths struct { - Metadata string `json:"metadata,omitempty"` + Metadata string `json:"metadata"` Transcript string `json:"transcript,omitempty"` ContentHash string `json:"content_hash,omitempty"` - Prompt string `json:"prompt,omitempty"` + Prompt string `json:"prompt"` } // CheckpointSummary is the root-level metadata.json for a checkpoint. From 1a5840d6a3dd0be3153f075a01ea70437ec0a181 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 17:02:24 -0700 Subject: [PATCH 13/18] Revert changes to unrelated file --- cmd/entire/cli/checkpoint/committed.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index cc6a7e6f3..47a75beb8 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -22,6 +22,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/trailers" + "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/entireio/cli/redact" @@ -43,8 +44,18 @@ var errStopIteration = errors.New("stop iteration") // - For incremental checkpoints: checkpoints/NNN-.json // - For final checkpoints: checkpoint.json and agent-.jsonl func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOptions) error { - if err := validateWriteOpts(opts); err != nil { - return err + // Validate identifiers to prevent path traversal and malformed data + if opts.CheckpointID.IsEmpty() { + return errors.New("invalid checkpoint options: checkpoint ID is required") + } + if err := validation.ValidateSessionID(opts.SessionID); err != nil { + return fmt.Errorf("invalid checkpoint options: %w", err) + } + if err := validation.ValidateToolUseID(opts.ToolUseID); err != nil { + return fmt.Errorf("invalid checkpoint options: %w", err) + } + if err := validation.ValidateAgentID(opts.AgentID); err != nil { + return fmt.Errorf("invalid checkpoint options: %w", err) } // Ensure sessions branch exists From 25b2d9ab863e438df234f2c44a9f150094867b39 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 17:18:32 -0700 Subject: [PATCH 14/18] fix: address PR review feedback on comments, logging, and context scoping --- cmd/entire/cli/checkpoint/v2_committed.go | 19 ++++++++++++++----- .../cli/strategy/manual_commit_hooks.go | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index 570a827b8..a805d2a1c 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "errors" "fmt" + "log/slog" "os" "strings" "time" @@ -12,6 +13,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/versioninfo" @@ -23,8 +25,8 @@ import ( ) // WriteCommitted writes a committed checkpoint to both v2 refs: -// - /main: metadata, prompts, content hash (no raw transcript) -// - /full/current: raw transcript only (replaces previous content) +// - /main: metadata and prompts (no raw transcript or content hash) +// - /full/current: raw transcript + content hash (replaces previous content) // // This is the public entry point for v2 dual-writes. The session index is // determined from the /main ref and passed to the /full/current write to @@ -105,6 +107,11 @@ func (s *V2GitStore) updateCommittedMain(ctx context.Context, opts UpdateCommitt if sessionIndex >= len(summary.Sessions) { // findSessionIndex returns next-available when not found; fall back to latest sessionIndex = len(summary.Sessions) - 1 + logging.Debug(ctx, "v2 UpdateCommitted: session ID not found, falling back to latest", + slog.String("session_id", opts.SessionID), + slog.String("checkpoint_id", string(opts.CheckpointID)), + slog.Int("fallback_index", sessionIndex), + ) } sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) @@ -174,8 +181,8 @@ func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts Upd } // writeCommittedMain writes metadata entries to the /main ref. -// This includes session metadata, prompts, and content hash — but NOT the -// raw transcript (full.jsonl), which goes to /full/current. +// This includes session metadata and prompts — but NOT the raw transcript +// (full.jsonl) or content hash (content_hash.txt), which go to /full/current. // Returns the session index used, so the caller can pass it to writeCommittedFullTranscript. func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommittedOptions) (int, error) { if err := validateWriteOpts(opts); err != nil { @@ -201,7 +208,7 @@ func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommitted return 0, err } - // Build main session entries (metadata, prompts, content hash — no transcript) + // Build main session entries (metadata, prompts — no transcript or content hash) sessionIndex, err := s.writeMainCheckpointEntries(ctx, opts, basePath, entries) if err != nil { return 0, err @@ -437,6 +444,8 @@ func (s *V2GitStore) writeTranscriptBlobs(ctx context.Context, transcript []byte } // validateWriteOpts validates identifiers in WriteCommittedOptions. +// Used by both GitStore (v1) and V2GitStore — lives here alongside the v2 code +// but is a package-level function accessible to all stores in this package. func validateWriteOpts(opts WriteCommittedOptions) error { if opts.CheckpointID.IsEmpty() { return errors.New("invalid checkpoint options: checkpoint ID is required") diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 84ebcd843..781823c27 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2295,7 +2295,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s } // Dual-write: update v2 refs when enabled - updateCommittedV2IfEnabled(ctx, repo, updateOpts) + updateCommittedV2IfEnabled(logCtx, repo, updateOpts) logging.Info(logCtx, "finalize: checkpoint updated with full transcript", slog.String("checkpoint_id", cpIDStr), From 10ab903030a6dc8e90200b72b942606383335976 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Mon, 23 Mar 2026 17:35:06 -0700 Subject: [PATCH 15/18] Remove outdated comment lines --- cmd/entire/cli/checkpoint/v2_committed.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index a805d2a1c..abf5917fa 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -444,8 +444,6 @@ func (s *V2GitStore) writeTranscriptBlobs(ctx context.Context, transcript []byte } // validateWriteOpts validates identifiers in WriteCommittedOptions. -// Used by both GitStore (v1) and V2GitStore — lives here alongside the v2 code -// but is a package-level function accessible to all stores in this package. func validateWriteOpts(opts WriteCommittedOptions) error { if opts.CheckpointID.IsEmpty() { return errors.New("invalid checkpoint options: checkpoint ID is required") From 65d73a010eb81757950ad92e693229db6d3448cf Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 24 Mar 2026 09:19:15 -0700 Subject: [PATCH 16/18] Evaluate v2 strategy settings only once --- .../cli/strategy/manual_commit_condensation.go | 17 ----------------- cmd/entire/cli/strategy/manual_commit_hooks.go | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 0e33e8d08..275edf0e4 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -930,20 +930,3 @@ func writeCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts c ) } } - -// updateCommittedV2IfEnabled updates existing checkpoint data on v2 refs when -// checkpoints_v2 is enabled. Used at stop time to finalize transcripts. -// Failures are logged as warnings — must not block the v1 path. -func updateCommittedV2IfEnabled(ctx context.Context, repo *git.Repository, opts cpkg.UpdateCommittedOptions) { - if !settings.IsCheckpointsV2Enabled(ctx) { - return - } - - v2Store := cpkg.NewV2GitStore(repo) - if err := v2Store.UpdateCommitted(ctx, opts); err != nil { - logging.Warn(ctx, "v2 dual-write update 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 781823c27..792a52d41 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2264,6 +2264,12 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s store := checkpoint.NewGitStore(repo) + // Evaluate v2 flag once before the loop to avoid re-reading settings per checkpoint + var v2Store *checkpoint.V2GitStore + if settings.IsCheckpointsV2Enabled(logCtx) { + v2Store = checkpoint.NewV2GitStore(repo) + } + // Update each checkpoint with the full transcript for _, cpIDStr := range state.TurnCheckpointIDs { cpID, parseErr := id.NewCheckpointID(cpIDStr) @@ -2295,7 +2301,14 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s } // Dual-write: update v2 refs when enabled - updateCommittedV2IfEnabled(logCtx, repo, updateOpts) + if v2Store != nil { + if v2Err := v2Store.UpdateCommitted(logCtx, updateOpts); v2Err != nil { + logging.Warn(logCtx, "v2 dual-write update failed", + slog.String("checkpoint_id", cpIDStr), + slog.String("error", v2Err.Error()), + ) + } + } logging.Info(logCtx, "finalize: checkpoint updated with full transcript", slog.String("checkpoint_id", cpIDStr), From 438e1564172ff74bbbe9b06a92c54c118ee78b46 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 24 Mar 2026 10:58:44 -0700 Subject: [PATCH 17/18] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/entire/cli/checkpoint/v2_committed.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index abf5917fa..523c7c631 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -244,7 +244,7 @@ func (s *V2GitStore) writeMainCheckpointEntries(ctx context.Context, opts WriteC // Determine session index sessionIndex := s.gs.findSessionIndex(ctx, basePath, existingSummary, entries, opts.SessionID) - // Write session files (metadata, prompts, content hash — no transcript) + // Write session files (metadata and prompts — no transcript or content hash) sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) sessionFilePaths, err := s.writeMainSessionToSubdirectory(opts, sessionPath, entries) if err != nil { From 9b1fc9992645fe5e3e1e51f93dbdccc546d1816c Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Tue, 24 Mar 2026 14:18:07 -0700 Subject: [PATCH 18/18] fix: accumulate transcripts on /full/current ref instead of replacing --- cmd/entire/cli/checkpoint/v2_committed.go | 49 +++++++++++++++------- cmd/entire/cli/checkpoint/v2_store_test.go | 13 +++--- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index abf5917fa..f3a2b332f 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -144,23 +144,36 @@ func (s *V2GitStore) updateCommittedMain(ctx context.Context, opts UpdateCommitt return sessionIndex, nil } -// updateCommittedFullTranscript replaces the transcript on /full/current for a finalized checkpoint. +// updateCommittedFullTranscript replaces the transcript for a specific checkpoint +// on /full/current while preserving other checkpoints' transcripts in the tree. func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts UpdateCommittedOptions, sessionIndex int) error { refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) if err := s.ensureRef(refName); err != nil { return fmt.Errorf("failed to ensure /full/current ref: %w", err) } - parentHash, _, err := s.getRefState(refName) + parentHash, rootTreeHash, err := s.getRefState(refName) if err != nil { return err } - // Build fresh tree with finalized transcript (replaces previous content) basePath := opts.CheckpointID.Path() + "/" + checkpointPath := opts.CheckpointID.Path() sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) - entries := make(map[string]object.TreeEntry) + // Read existing entries and replace transcript for this checkpoint only + entries, err := s.gs.flattenCheckpointEntries(rootTreeHash, checkpointPath) + if err != nil { + return err + } + + // Clear existing transcript entries at this session path before writing new ones + for key := range entries { + if strings.HasPrefix(key, sessionPath) { + delete(entries, key) + } + } + redactedTranscript, err := s.writeTranscriptBlobs(ctx, opts.Transcript, opts.Agent, sessionPath, entries) if err != nil { return err @@ -170,9 +183,10 @@ func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts Upd return err } - newTreeHash, err := BuildTreeFromEntries(s.repo, entries) + // Splice into existing root tree (preserves other checkpoints' transcripts) + newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { - return fmt.Errorf("failed to build /full/current tree: %w", err) + return err } authorName, authorEmail := GetGitAuthorFromRepo(s.repo) @@ -355,9 +369,9 @@ func (s *V2GitStore) writeContentHash(redactedTranscript []byte, sessionPath str } // writeCommittedFullTranscript writes the raw transcript to the /full/current ref. -// Each write replaces the entire tree — /full/current only ever contains the -// transcript for the most recently written checkpoint. Older transcripts are -// discarded; generation rotation (future work) will archive them before replacement. +// Transcripts accumulate across checkpoints — each write splices into the existing +// tree. When the ref reaches capacity, generation rotation (future work) archives +// the current ref and starts a fresh one. // // sessionIndex is the session slot (0-based), determined by the caller to stay // consistent with the /main ref's session numbering. @@ -384,29 +398,34 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ return fmt.Errorf("failed to ensure /full/current ref: %w", err) } - parentHash, _, err := s.getRefState(refName) + parentHash, rootTreeHash, err := s.getRefState(refName) if err != nil { return err } - // Build a fresh tree with only this checkpoint's transcript (no accumulation). basePath := opts.CheckpointID.Path() + "/" + checkpointPath := opts.CheckpointID.Path() sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex) - entries := make(map[string]object.TreeEntry) + // Read existing entries at this checkpoint's shard path (accumulate, don't replace) + entries, err := s.gs.flattenCheckpointEntries(rootTreeHash, checkpointPath) + if err != nil { + return err + } + redactedTranscript, err := s.writeTranscriptBlobs(ctx, transcript, opts.Agent, sessionPath, entries) if err != nil { return err } - // Write content hash alongside the transcript it references if err := s.writeContentHash(redactedTranscript, sessionPath, entries); err != nil { return err } - newTreeHash, err := BuildTreeFromEntries(s.repo, entries) + // Splice into existing root tree (preserves other checkpoints' transcripts) + newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { - return fmt.Errorf("failed to build /full/current tree: %w", err) + return err } commitMsg := fmt.Sprintf("Checkpoint: %s\n", opts.CheckpointID) diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index 86274319c..cd3b97609 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -458,7 +458,7 @@ func TestV2GitStore_WriteCommittedFull_NoTranscript_Noop(t *testing.T) { // If ref doesn't exist at all, that's also acceptable for a no-op } -func TestV2GitStore_WriteCommittedFullTranscript_ReplacesOnNewCheckpoint(t *testing.T) { +func TestV2GitStore_WriteCommittedFullTranscript_AccumulatesCheckpoints(t *testing.T) { t.Parallel() repo := initTestRepo(t) store := NewV2GitStore(repo) @@ -478,7 +478,7 @@ func TestV2GitStore_WriteCommittedFullTranscript_ReplacesOnNewCheckpoint(t *test }, 0) require.NoError(t, err) - // Write checkpoint B — should replace A entirely + // Write checkpoint B — should accumulate alongside A err = store.writeCommittedFullTranscript(ctx, WriteCommittedOptions{ CheckpointID: cpB, SessionID: "session-B", @@ -491,13 +491,12 @@ func TestV2GitStore_WriteCommittedFullTranscript_ReplacesOnNewCheckpoint(t *test tree := v2FullTree(t, repo) - // Checkpoint B should be present + // Both checkpoints should be present + contentA := v2ReadFile(t, tree, cpA.Path()+"/0/"+paths.TranscriptFileName) + assert.Contains(t, contentA, `"from":"A"`) + contentB := v2ReadFile(t, tree, cpB.Path()+"/0/"+paths.TranscriptFileName) assert.Contains(t, contentB, `"from":"B"`) - - // Checkpoint A should NOT be present — replaced by B - _, err = tree.Tree(cpA.Path()) - assert.Error(t, err, "checkpoint A should not exist after checkpoint B replaced it") } func TestV2GitStore_WriteCommitted_WritesBothRefs(t *testing.T) {