Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cmd/entire/cli/checkpoint/attempt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package checkpoint

import "time"

// AttemptHooks observes per-attempt progress when a function internally
// tries multiple fetch/read strategies. OnStart fires before each attempt;
// OnFinish fires after with the elapsed time and the attempt's error
// (nil = success, non-nil = the chain will move on to the next strategy).
// Either field may be nil to opt out. The zero value is a no-op.
type AttemptHooks struct {
OnStart func(label string)
OnFinish func(label string, duration time.Duration, err error)
}

// WithLabel wraps a single attempt: emits OnStart, runs fn, emits OnFinish
// with fn's error. Callers that need the attempt's error capture it via
// outer-var assignment inside fn; WithLabel itself returns nothing so
// `hooks.WithLabel(...)` doesn't require a discard expression.
// Safe to call on the zero value (nil hooks become no-ops).
func (h AttemptHooks) WithLabel(label string, fn func() error) {
if h.OnStart != nil {
h.OnStart(label)
}
started := time.Now()
err := fn()
if h.OnFinish != nil {
h.OnFinish(label, time.Since(started), err)
}
}
68 changes: 46 additions & 22 deletions cmd/entire/cli/checkpoint/v2_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,45 +17,69 @@ import (
type FetchRefFunc func(ctx context.Context) error

// GetV2MetadataTree resolves the v2 /main ref tree with fetch fallback.
// Follows the same pattern as getMetadataTree() in resume.go:
// See GetV2MetadataTreeWithHooks for the full strategy chain.
func GetV2MetadataTree(ctx context.Context, treelessFetchFn, fullFetchFn FetchRefFunc, openRepoFn func(context.Context) (*git.Repository, error)) (*object.Tree, *git.Repository, error) {
return GetV2MetadataTreeWithHooks(ctx, treelessFetchFn, fullFetchFn, openRepoFn, AttemptHooks{})
}

// GetV2MetadataTreeWithHooks resolves the v2 /main ref tree with fetch fallback,
// emitting AttemptHooks events around each strategy:
// 1. Treeless fetch → open fresh repo → read /main ref tree
// 2. Local ref lookup
// 3. Full fetch → read tree
//
// Takes fetch functions as dependencies to avoid importing the cli package.
// openRepoFn opens a fresh repository (needed after fetch to see new packfiles).
func GetV2MetadataTree(ctx context.Context, treelessFetchFn, fullFetchFn FetchRefFunc, openRepoFn func(context.Context) (*git.Repository, error)) (*object.Tree, *git.Repository, error) {
// hooks may be the zero value to opt out of progress notifications.
func GetV2MetadataTreeWithHooks(ctx context.Context, treelessFetchFn, fullFetchFn FetchRefFunc, openRepoFn func(context.Context) (*git.Repository, error), hooks AttemptHooks) (*object.Tree, *git.Repository, error) {
refName := plumbing.ReferenceName(paths.V2MainRefName)

if treelessFetchFn != nil {
if fetchErr := treelessFetchFn(ctx); fetchErr == nil {
freshRepo, repoErr := openRepoFn(ctx)
if repoErr == nil {
tree, treeErr := getV2RefTree(freshRepo, refName)
if treeErr == nil {
return tree, freshRepo, nil
// Helper: run one attempt (fetch + open + read), return (tree, repo, ok).
// Emits OnStart/OnFinish around the combined work; the finish error is
// whatever step failed first (or nil on success).
attempt := func(label string, fetchFn FetchRefFunc) (*object.Tree, *git.Repository, bool) {
var (
tree *object.Tree
repo *git.Repository
runErr error
)
fn := func() error {
if fetchFn != nil {
if err := fetchFn(ctx); err != nil {
runErr = err
return err
}
}
r, err := openRepoFn(ctx)
if err != nil {
runErr = err
return err
}
t, err := getV2RefTree(r, refName)
if err != nil {
runErr = err
return err
}
tree, repo = t, r
return nil
}
hooks.WithLabel(label, fn)
return tree, repo, runErr == nil
}

localRepo, repoErr := openRepoFn(ctx)
if repoErr == nil {
tree, err := getV2RefTree(localRepo, refName)
if err == nil {
return tree, localRepo, nil
if treelessFetchFn != nil {
if tree, repo, ok := attempt("Treeless fetch of v2 /main from origin", treelessFetchFn); ok {
return tree, repo, nil
}
}

if tree, repo, ok := attempt("Reading v2 /main from local", nil); ok {
return tree, repo, nil
}

if fullFetchFn != nil {
if fetchErr := fullFetchFn(ctx); fetchErr == nil {
freshRepo, repoErr := openRepoFn(ctx)
if repoErr == nil {
tree, treeErr := getV2RefTree(freshRepo, refName)
if treeErr == nil {
return tree, freshRepo, nil
}
}
if tree, repo, ok := attempt("Full fetch of v2 /main from origin", fullFetchFn); ok {
return tree, repo, nil
}
}

Expand Down
79 changes: 57 additions & 22 deletions cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,9 +469,16 @@ func runExplain(ctx context.Context, w, errW io.Writer, sessionID, commitRef, ch
// an ambiguity pre-check to avoid writing a summary to the wrong
// checkpoint on short-prefix collisions.
func runExplainAuto(ctx context.Context, w, errW io.Writer, target string, noPager, verbose, full, rawTranscript, generate, force, searchAll bool, summaryTimeoutSeconds int) error {
stop := startSpinner(errW, "Loading checkpoints")
lookup, lookupErr := newExplainCheckpointLookup(ctx)
stop(false)
pw := newExplainProgressWriter(errW)
hooks := newPhaseProgressHooks(pw)
var (
lookup *explainCheckpointLookup
lookupErr error
)
hooks.WithLabel("Loading local checkpoints", func() error {
lookup, lookupErr = newExplainCheckpointLookup(ctx)
return lookupErr
})
if generate {
if err := runExplainAutoAmbiguityGuard(ctx, target, lookup, lookupErr); err != nil {
return err
Expand Down Expand Up @@ -637,40 +644,56 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec
return NewSilentError(fmt.Errorf("%w: %s matches %d checkpoints", errAmbiguousCommitPrefix, checkpointIDPrefix, len(matches)))
}

// One spinner covers the entire data-loading pipeline: prefetch's
// missing-blob analysis (which spawns one cat-file -e per blob and
// can take seconds on a deep checkpoint subtree), the prefetch fetch
// itself, ResolveCommittedReader's metadata read, session content
// reads, and getAssociatedCommits' git log walk. Stop strictly before
// any write to w (stdout) so stderr spinner frames and stdout output
// never interleave.
stopLoad := startSpinner(errW, fmt.Sprintf("Loading checkpoint %s", fullCheckpointID))
// One phase covers the entire data-loading pipeline: prefetch's
// missing-blob analysis (one cat-file -e per blob, seconds on deep
// subtrees), the prefetch fetch itself, ResolveCommittedReader's
// metadata read, session content reads, and getAssociatedCommits'
// git log walk. The sublabel ticks through each so the user sees
// which sub-step is running instead of a single opaque spinner.
loadPW := newExplainProgressWriter(errW)
loadPrefix := fmt.Sprintf("Loading checkpoint %s", fullCheckpointID)
loadStart := time.Now()
loadFinished := false
finishLoad := func(ok bool, detail string) {
if loadFinished {
return
}
loadPW.FinishPhase(loadPrefix, ok, detail)
loadFinished = true
}
updateSub := func(label string) {
loadPW.UpdateSublabel(loadPrefix, label)
}

resolvedReader, summary, content, err := loadCheckpointForExplain(ctx, errW, lookup, fullCheckpointID, full, generate, rawTranscript)
resolvedReader, summary, content, err := loadCheckpointForExplain(ctx, errW, lookup, fullCheckpointID, full, generate, rawTranscript, updateSub)
if err != nil {
stopLoad(false)
finishLoad(false, firstStderrLine(err))
return err
}
// Handle summary generation — uses raw transcript.
if generate {
stopLoad(false) // generation prints its own progress to w/errW
finishLoad(true, formatPhaseDuration(time.Since(loadStart)))
if err := generateCheckpointSummary(ctx, w, errW, lookup.store, fullCheckpointID, summary, content, force, summaryTimeoutSeconds); err != nil {
return err
}
// Reload to get the updated summary. After generation, display can
// prefer v2 /main but must still fall back for v1-only checkpoints in
// dual-read mode.
stopLoad = startSpinner(errW, fmt.Sprintf("Reloading checkpoint %s", fullCheckpointID))
reloadPrefix := fmt.Sprintf("Reloading checkpoint %s", fullCheckpointID)
reloadStart := time.Now()
loadPW.UpdateSublabel(reloadPrefix, "content")
content, err = readCheckpointContentForExplain(ctx, resolvedReader, fullCheckpointID, summary, true)
if err != nil {
stopLoad(false)
loadPW.FinishPhase(reloadPrefix, false, firstStderrLine(err))
return fmt.Errorf("failed to reload checkpoint: %w", err)
}
loadPW.FinishPhase(reloadPrefix, true, formatPhaseDuration(time.Since(reloadStart)))
}

// Handle raw transcript output
// Handle raw transcript output. finishLoad is a no-op if generate already
// finished loadPrefix above.
if rawTranscript {
stopLoad(false)
finishLoad(true, formatPhaseDuration(time.Since(loadStart)))
if len(content.Transcript) == 0 {
return fmt.Errorf("checkpoint %s has no transcript", fullCheckpointID)
}
Expand All @@ -682,6 +705,9 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec
}

// Find associated commits (git commits with matching Entire-Checkpoint trailer)
if !loadFinished {
updateSub("associated commits")
}
associatedCommits, _ := getAssociatedCommits(ctx, lookup.repo, fullCheckpointID, searchAll) //nolint:errcheck // Best-effort

// Derive author from the first associated commit (the user who made the commit).
Expand All @@ -697,9 +723,9 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec
author, _ = lookup.store.GetCheckpointAuthor(ctx, fullCheckpointID) //nolint:errcheck // Author is optional
}

// Format and output. Stop spinner BEFORE any write to w to keep stderr
// frames and stdout content from interleaving.
stopLoad(false)
// Format and output. Finish the phase BEFORE any write to w so stderr
// progress and stdout content don't interleave.
finishLoad(true, formatPhaseDuration(time.Since(loadStart)))
output := formatCheckpointOutput(summary, content, fullCheckpointID, associatedCommits, author, verbose, full, w)
outputExplainContent(w, output, noPager)
return nil
Expand All @@ -710,15 +736,24 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec
// data-load pipeline out of runExplainCheckpointWithLookup so that
// function stays under maintidx limits. Caller is responsible for the
// surrounding spinner.
func loadCheckpointForExplain(ctx context.Context, errW io.Writer, lookup *explainCheckpointLookup, cpID id.CheckpointID, full, generate, rawTranscript bool) (checkpoint.CommittedReader, *checkpoint.CheckpointSummary, *checkpoint.SessionContent, error) {
func loadCheckpointForExplain(ctx context.Context, errW io.Writer, lookup *explainCheckpointLookup, cpID id.CheckpointID, full, generate, rawTranscript bool, onSubStep func(string)) (checkpoint.CommittedReader, *checkpoint.CheckpointSummary, *checkpoint.SessionContent, error) {
if onSubStep != nil {
onSubStep("prefetching blobs")
}
prefetchCheckpointBlobs(ctx, errW, lookup.repo, cpID)

if onSubStep != nil {
onSubStep("metadata")
}
store := lookup.store
summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, cpID)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to read checkpoint: %w", err)
}

if onSubStep != nil {
onSubStep("content")
}
needsRawTranscript := full || generate || rawTranscript
content, contentErr := readCheckpointContentForExplain(ctx, store, cpID, summary, !needsRawTranscript)
if contentErr != nil {
Expand Down
16 changes: 10 additions & 6 deletions cmd/entire/cli/explain_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,23 +186,27 @@ func lookupHasCheckpoint(lookup *explainCheckpointLookup, cpID id.CheckpointID)

// matchCheckpointPrefixWithRemoteFallback returns all committed checkpoints
// whose ID starts with prefix. On a local miss, fetches metadata from the
// remote (treeless origin → full origin chain) and retries once with a fresh
// lookup. The returned lookup may differ from the input on retry.
// remote (checkpoint_remote → treeless origin → local → full origin →
// remote-tracking, plus the v2 variant) and retries once with a fresh
// lookup. Each fetch/read attempt renders as a phase line via the explain
// progress writer so the user can see which strategy is running and which
// errored. The returned lookup may differ from the input on retry.
func matchCheckpointPrefixWithRemoteFallback(ctx context.Context, errW io.Writer, lookup *explainCheckpointLookup, prefix string) ([]id.CheckpointID, *explainCheckpointLookup) {
matches := matchCheckpointPrefix(lookup, prefix)
if len(matches) > 0 {
return matches, lookup
}

stop := startSpinner(errW, "Fetching checkpoint metadata from remote")
_, _, v1Err := getMetadataTree(ctx)
pw := newExplainProgressWriter(errW)
hooks := newPhaseProgressHooks(pw)

_, _, v1Err := getMetadataTreeWithHooks(ctx, hooks)
v2OK := false
if shouldFetchV2Metadata(ctx, lookup) {
if _, _, v2Err := getV2MetadataTree(ctx); v2Err == nil {
if _, _, v2Err := getV2MetadataTreeWithHooks(ctx, hooks); v2Err == nil {
v2OK = true
}
}
stop(false)
if v1Err != nil && !v2OK {
return nil, lookup
}
Expand Down
Loading
Loading