Skip to content
2 changes: 1 addition & 1 deletion cmd/entire/cli/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (opts attachOptions) committedRefs(ctx context.Context) cpkg.CommittedRefs

// openAttachStore opens the committed store for the resolved topology. refs is
// passed explicitly so attach preserves PrimaryAsRead() pinning.
func openAttachStore(ctx context.Context, repo *git.Repository, refs cpkg.CommittedRefs) (*cpkg.GitStore, error) {
func openAttachStore(ctx context.Context, repo *git.Repository, refs cpkg.CommittedRefs) (cpkg.CommittedStore, error) { //nolint:ireturn // committed store capability preserves attach's read-ref override
stores, err := cpkg.Open(ctx, repo, cpkg.OpenOptions{Refs: &refs})
if err != nil {
return nil, fmt.Errorf("open checkpoint store: %w", err)
Expand Down
23 changes: 21 additions & 2 deletions cmd/entire/cli/attribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,15 @@ type attributionSummary struct {
MixedPercentage int `json:"mixed_percentage"`
}

type attributionCheckpointReader interface {
ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*checkpoint.CheckpointSummary, error)
ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*checkpoint.SessionContent, error)
}

type attributionResolver struct {
ctx context.Context
repo *git.Repository
store *checkpoint.GitStore
store attributionCheckpointReader
fetchOnMiss bool

commitCache map[string]*object.Commit
Expand Down Expand Up @@ -406,7 +411,7 @@ func (r *attributionResolver) checkpointContext(cpID id.CheckpointID, file strin

func (r *attributionResolver) readCheckpointContext(cpID id.CheckpointID, file string) attributionCheckpointContext {
ctx := attributionCheckpointContext{CheckpointID: cpID.String()}
summary, err := checkpoint.ReadCommittedCheckpoint(r.ctx, r.store, cpID)
summary, err := readAttributionCheckpointSummary(r.ctx, r.store, cpID)
if err != nil && r.fetchOnMiss {
if fetched, fetchErr := r.fetchCheckpointContext(cpID, file); fetchErr == nil {
return fetched
Expand Down Expand Up @@ -484,6 +489,20 @@ func (r *attributionResolver) readCheckpointContext(cpID id.CheckpointID, file s
return ctx
}

func readAttributionCheckpointSummary(ctx context.Context, reader attributionCheckpointReader, cpID id.CheckpointID) (*checkpoint.CheckpointSummary, error) {
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}
summary, err := reader.ReadCommitted(ctx, cpID)
if err != nil {
return nil, fmt.Errorf("read committed checkpoint: %w", err)
}
if summary == nil {
return nil, checkpoint.ErrCheckpointNotFound
}
return summary, nil
}

func enrichAttributionLineWithFetch(ctx context.Context, file string, line *attributionLine, checkpoints map[string]attributionCheckpointContext) error {
if line == nil || len(line.Candidates) == 0 {
return nil
Expand Down
45 changes: 45 additions & 0 deletions cmd/entire/cli/attribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,51 @@ func TestAttributionBlameMixedUsesFileMatchingCheckpoint(t *testing.T) {
require.Equal(t, 1, payload.Summary.AILines)
}

func TestAttributionResolverUsesCheckpointReader(t *testing.T) {
t.Parallel()

cpID := checkpointid.MustCheckpointID("d9b2c3d4e5f6")
reader := &attributionCheckpointReaderStub{
summary: &checkpoint.CheckpointSummary{
FilesTouched: []string{"auth.py"},
Sessions: []checkpoint.SessionFilePaths{{Metadata: "metadata.json"}},
},
content: &checkpoint.SessionContent{
Metadata: checkpoint.CommittedMetadata{
SessionID: "session-ai",
FilesTouched: []string{"auth.py"},
Agent: agent.AgentTypeClaudeCode,
Model: "claude-test",
},
Prompts: "Explain the authentication change.",
},
}
resolver := &attributionResolver{
ctx: context.Background(),
store: reader,
checkpointCache: make(map[string]attributionCheckpointContext),
}

ctx := resolver.readCheckpointContext(cpID, "auth.py")
require.Equal(t, "session-ai", ctx.SessionID)
require.Equal(t, "Claude Code", ctx.Agent)
require.Equal(t, "claude-test", ctx.Model)
require.Equal(t, "Explain the authentication change.", ctx.Prompt)
}

type attributionCheckpointReaderStub struct {
summary *checkpoint.CheckpointSummary
content *checkpoint.SessionContent
}

func (s *attributionCheckpointReaderStub) ReadCommitted(context.Context, checkpointid.CheckpointID) (*checkpoint.CheckpointSummary, error) {
return s.summary, nil
}

func (s *attributionCheckpointReaderStub) ReadSessionMetadataAndPrompts(context.Context, checkpointid.CheckpointID, int) (*checkpoint.SessionContent, error) {
return s.content, nil
}

func TestAttributionBlameScopesMixedToSessionNotCheckpoint(t *testing.T) {
repoRoot := newAttributionRepo(t)
writeAttributionCheckpoint(t, repoRoot, "a9b2c3d4e5f6", checkpoint.WriteCommittedOptions{
Expand Down
56 changes: 8 additions & 48 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,56 +60,16 @@ const (
Committed
)

// Store provides low-level primitives for reading and writing checkpoints.
// This is used by strategies to implement their storage approach.
//
// The interface matches the GitStore implementation signatures directly:
// - WriteTemporary takes WriteTemporaryOptions and returns a result with commit hash and skip status
// - ReadTemporary takes baseCommit (not sessionID) since shadow branches are keyed by commit
// - List methods return implementation-specific info types for richer data
type Store interface {
// WriteTemporary writes a temporary checkpoint (full state) to a shadow branch.
// Shadow branches are named entire/<base-commit-short-hash>.
// Returns a result containing the commit hash and whether the checkpoint was skipped.
// Checkpoints are skipped (deduplicated) when the tree hash matches the previous checkpoint.
// TemporaryStore provides the production shadow-branch checkpoint surface.
type TemporaryStore interface {
WriteTemporary(ctx context.Context, opts WriteTemporaryOptions) (WriteTemporaryResult, error)

// ReadTemporary reads the latest checkpoint from a shadow branch.
// baseCommit is the commit hash the session is based on.
// worktreeID is the internal git worktree identifier (empty for main worktree).
// Returns nil, nil if the shadow branch doesn't exist.
ReadTemporary(ctx context.Context, baseCommit, worktreeID string) (*ReadTemporaryResult, error)

// ListTemporary lists all shadow branches with their checkpoint info.
WriteTemporaryTask(ctx context.Context, opts WriteTemporaryTaskOptions) (plumbing.Hash, error)
ListTemporary(ctx context.Context) ([]TemporaryInfo, error)

// WriteCommitted writes a committed checkpoint to the entire/checkpoints/v1 branch.
// Checkpoints are stored at sharded paths: <id[:2]>/<id[2:]>/
WriteCommitted(ctx context.Context, opts WriteCommittedOptions) error

// ReadCommitted reads a committed checkpoint's summary by ID.
// Returns only the CheckpointSummary (paths + aggregated stats), not actual content.
// Use ReadSessionContent to read actual transcript/prompts.
// Returns nil, nil if the checkpoint does not exist.
ReadCommitted(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error)

// ReadSessionContent reads the actual content for a specific session within a checkpoint.
// sessionIndex is 0-based (0 for first session, 1 for second, etc.).
// Returns the session's metadata, transcript, and prompts.
ReadSessionContent(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error)

// ReadSessionContentByID reads a session's content by its session ID.
// Useful when you have the session ID but don't know its index within the checkpoint.
ReadSessionContentByID(ctx context.Context, checkpointID id.CheckpointID, sessionID string) (*SessionContent, error)

// ListCommitted lists all committed checkpoints.
ListCommitted(ctx context.Context) ([]CommittedInfo, error)

// UpdateCommitted replaces the transcript and prompts for an existing
// committed checkpoint. Used at stop time to finalize checkpoints with the full
// session transcript (prompt to stop event).
// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error
ListTemporaryCheckpoints(ctx context.Context, baseCommit, worktreeID, sessionID string, limit int) ([]TemporaryCheckpointInfo, error)
ListCheckpointsForBranch(ctx context.Context, branchName, sessionID string, limit int) ([]TemporaryCheckpointInfo, error)
ListAllTemporaryCheckpoints(ctx context.Context, sessionID string, limit int) ([]TemporaryCheckpointInfo, error)
GetTranscriptFromCommit(ctx context.Context, commitHash plumbing.Hash, metadataDir string, agentType types.AgentType) ([]byte, error)
ShadowBranchExists(baseCommit, worktreeID string) bool
}

// WriteTemporaryResult contains the result of writing a temporary checkpoint.
Expand Down
4 changes: 2 additions & 2 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -1327,7 +1327,7 @@ func (s *GitStore) GetSessionLog(ctx context.Context, cpID id.CheckpointID) ([]b

// LookupSessionLog is a convenience function that opens the repository and retrieves
// a session log by checkpoint ID. This is the primary entry point for callers that
// don't already have a GitStore instance.
// do not already have a committed store instance.
// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
// Returns ErrNoTranscript if the checkpoint exists but has no transcript.
func LookupSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string, error) {
Expand All @@ -1340,7 +1340,7 @@ func LookupSessionLog(ctx context.Context, cpID id.CheckpointID) ([]byte, string
if err != nil {
return nil, "", fmt.Errorf("open checkpoint store: %w", err)
}
return stores.Primary.GetSessionLog(ctx, cpID)
return ReadRawSessionLogForCheckpoint(ctx, stores.Primary, cpID)
Comment thread
pfleidi marked this conversation as resolved.
Outdated
}

// UpdateSummary updates the summary field in the latest session's metadata.
Expand Down
25 changes: 24 additions & 1 deletion cmd/entire/cli/checkpoint/committed_reader_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ type CommittedListReader interface {
ReadSessionPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (string, error)
}

// CommittedWriter provides write access to committed checkpoint data.
type CommittedWriter interface {
WriteCommitted(ctx context.Context, opts WriteCommittedOptions) error
UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error
UpdateSummary(ctx context.Context, checkpointID id.CheckpointID, summary *Summary) error
UpdateCheckpointSummary(ctx context.Context, checkpointID id.CheckpointID, combinedAttribution *InitialAttribution) error
}

// CommittedStore provides the production committed checkpoint storage surface.
type CommittedStore interface {
CommittedListReader
ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error)
CommittedWriter
}

// AuthorReader provides optional checkpoint author lookup.
type AuthorReader interface {
GetCheckpointAuthor(ctx context.Context, checkpointID id.CheckpointID) (Author, error)
}

// ReadCommittedCheckpoint reads a committed checkpoint summary and normalizes
// a nil store response into ErrCheckpointNotFound.
func ReadCommittedCheckpoint(ctx context.Context, reader CommittedReader, checkpointID id.CheckpointID) (*CheckpointSummary, error) {
Expand All @@ -41,9 +61,12 @@ func ReadCommittedCheckpoint(ctx context.Context, reader CommittedReader, checkp
// ReadLatestSessionContent reads the latest session from an already-resolved
// committed reader and summary.
func ReadLatestSessionContent(ctx context.Context, reader CommittedReader, checkpointID id.CheckpointID, summary *CheckpointSummary) (*SessionContent, error) {
if summary == nil || len(summary.Sessions) == 0 {
if summary == nil {
return nil, ErrCheckpointNotFound
}
if len(summary.Sessions) == 0 {
return nil, fmt.Errorf("checkpoint has no sessions: %s", checkpointID)
Comment thread
pfleidi marked this conversation as resolved.
Outdated
}
latestIndex := len(summary.Sessions) - 1
content, err := reader.ReadSessionContent(ctx, checkpointID, latestIndex)
if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions cmd/entire/cli/checkpoint/committed_reader_resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ func TestReadCommittedCheckpointWrapsReaderError(t *testing.T) {
require.ErrorContains(t, err, "read committed checkpoint")
}

func TestReadLatestSessionContentEmptySummaryReturnsDistinctError(t *testing.T) {
t.Parallel()

cpID := id.MustCheckpointID("111111111111")
summary := &CheckpointSummary{}
reader := &committedReaderStub{summary: summary}

content, err := ReadLatestSessionContent(context.Background(), reader, cpID, summary)
require.Nil(t, content)
require.ErrorContains(t, err, "checkpoint has no sessions")
require.NotErrorIs(t, err, ErrCheckpointNotFound)
}

func TestReadRawSessionLogForCheckpointReadsLatestV1Session(t *testing.T) {
t.Parallel()

Expand Down
19 changes: 9 additions & 10 deletions cmd/entire/cli/checkpoint/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ type OpenOptions struct {
// Stores is the facade returned by Open: the committed store plus the git-only
// temporary capability and resolved committed-ref topology.
type Stores struct {
// Primary is the committed store — the source of truth that serves all
// committed reads and writes.
Primary *GitStore
// Primary is the committed store that serves committed reads and writes.
Primary CommittedStore

refs CommittedRefs
temporary TemporaryStore
refs CommittedRefs
}

// Open resolves the checkpoint storage topology and constructs the backing
Expand All @@ -41,8 +41,9 @@ func Open(ctx context.Context, repo *git.Repository, opts OpenOptions) (*Stores,
store.SetBlobFetcher(opts.BlobFetcher)
}
return &Stores{
Primary: store,
refs: refs,
Primary: store,
temporary: store,
refs: refs,
}, nil
}

Expand All @@ -53,10 +54,8 @@ func resolveOpenRefs(ctx context.Context, opts OpenOptions) CommittedRefs {
return ResolveCommittedRefs(ctx)
}

// Temporary returns the git-backed temporary (shadow-branch) store. It is the
// same backing store as Primary; the name marks shadow-branch intent at the
// call site.
func (s *Stores) Temporary() *GitStore { return s.Primary }
// Temporary returns the git-backed temporary shadow-branch store.
func (s *Stores) Temporary() TemporaryStore { return s.temporary } //nolint:ireturn // temporary store capability is the abstraction boundary

// Refs returns the resolved committed-ref topology.
func (s *Stores) Refs() CommittedRefs { return s.refs }
7 changes: 5 additions & 2 deletions cmd/entire/cli/checkpoint/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import (
"github.com/go-git/go-git/v6/plumbing"
)

// Compile-time check that GitStore implements the Store interface.
var _ Store = (*GitStore)(nil)
var (
_ CommittedStore = (*GitStore)(nil)
_ TemporaryStore = (*GitStore)(nil)
_ AuthorReader = (*GitStore)(nil)
)

// GitStore provides operations for both temporary and committed checkpoint
// storage. Writes target refs.Primary; committed reads resolve against
Expand Down
10 changes: 5 additions & 5 deletions cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ var errCannotGenerateTemporaryCheckpoint = errors.New("cannot generate summary f

type explainCheckpointLookup struct {
repo *git.Repository
store *checkpoint.GitStore
store checkpoint.CommittedStore
committed []checkpoint.CommittedInfo
}

Expand Down Expand Up @@ -729,8 +729,8 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec
Name: associatedCommits[0].Author,
Email: associatedCommits[0].Email,
}
} else {
author, _ = lookup.store.GetCheckpointAuthor(ctx, fullCheckpointID) //nolint:errcheck // Author is optional
} else if authorReader, ok := lookup.store.(checkpoint.AuthorReader); ok {
author, _ = authorReader.GetCheckpointAuthor(ctx, fullCheckpointID) //nolint:errcheck // Author is optional
}

// Format and output. Stop spinner BEFORE any write to w to keep stderr
Expand Down Expand Up @@ -1204,7 +1204,7 @@ func formatSummaryTimeout(d time.Duration) string {
// Searches ALL shadow branches, not just the one for current HEAD, to find checkpoints
// created from different base commits (e.g., if HEAD advanced since session start).
// The writer w is used for raw transcript output to bypass the pager.
func explainTemporaryCheckpoint(ctx context.Context, w, errW io.Writer, repo *git.Repository, store *checkpoint.GitStore, shaPrefix string, verbose, full, rawTranscript bool) (string, bool, error) {
func explainTemporaryCheckpoint(ctx context.Context, w, errW io.Writer, repo *git.Repository, store checkpoint.TemporaryStore, shaPrefix string, verbose, full, rawTranscript bool) (string, bool, error) {
// List temporary checkpoints from ALL shadow branches
// This ensures we find checkpoints even if HEAD has advanced since the session started
tempCheckpoints, err := store.ListAllTemporaryCheckpoints(ctx, "", branchCheckpointsLimit)
Expand Down Expand Up @@ -2183,7 +2183,7 @@ func readLatestCommittedSessionPrompt(ctx context.Context, store checkpoint.Comm
// whose base commit is reachable from the given HEAD hash and that belong to this worktree.
// For default branches, all shadow branches for this worktree are included.
// For feature branches, only shadow branches whose base commit is in HEAD's history are included.
func getReachableTemporaryCheckpoints(ctx context.Context, repo *git.Repository, store *checkpoint.GitStore, headHash plumbing.Hash, isOnDefault bool, limit int) []strategy.RewindPoint {
func getReachableTemporaryCheckpoints(ctx context.Context, repo *git.Repository, store checkpoint.TemporaryStore, headHash plumbing.Hash, isOnDefault bool, limit int) []strategy.RewindPoint {
var points []strategy.RewindPoint

// Compute current worktree's hash for filtering shadow branches
Expand Down
9 changes: 7 additions & 2 deletions cmd/entire/cli/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ func resumeFromCurrentBranch(ctx context.Context, w, errW io.Writer, branchName

// resolveLatestCheckpoint reads metadata for each checkpoint ID and returns
// the checkpoint with the latest CreatedAt.
func resolveLatestCheckpoint(ctx context.Context, store *checkpoint.GitStore, checkpointIDs []id.CheckpointID) (*strategy.CheckpointInfo, error) {
func resolveLatestCheckpoint(ctx context.Context, store checkpointInfoReader, checkpointIDs []id.CheckpointID) (*strategy.CheckpointInfo, error) {
infoMap := make(map[id.CheckpointID]strategy.CheckpointInfo, len(checkpointIDs))
for _, cpID := range checkpointIDs {
metadata, readErr := readCheckpointInfoFromStore(ctx, store, cpID)
Expand All @@ -349,7 +349,12 @@ func resolveLatestCheckpoint(ctx context.Context, store *checkpoint.GitStore, ch
return &latest, nil
}

func readCheckpointInfoFromStore(ctx context.Context, store checkpoint.CommittedListReader, checkpointID id.CheckpointID) (*strategy.CheckpointInfo, error) {
type checkpointInfoReader interface {
checkpoint.CommittedReader
ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*checkpoint.CommittedMetadata, error)
}

func readCheckpointInfoFromStore(ctx context.Context, store checkpointInfoReader, checkpointID id.CheckpointID) (*strategy.CheckpointInfo, error) {
summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, checkpointID)
if err != nil {
return nil, fmt.Errorf("read checkpoint: %w", err)
Expand Down
Loading
Loading