diff --git a/cmd/entire/cli/checkpoint/attempt.go b/cmd/entire/cli/checkpoint/attempt.go new file mode 100644 index 0000000000..7db8965d56 --- /dev/null +++ b/cmd/entire/cli/checkpoint/attempt.go @@ -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) + } +} diff --git a/cmd/entire/cli/checkpoint/v2_resolve.go b/cmd/entire/cli/checkpoint/v2_resolve.go index 87d8d85fb8..ebb2501732 100644 --- a/cmd/entire/cli/checkpoint/v2_resolve.go +++ b/cmd/entire/cli/checkpoint/v2_resolve.go @@ -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 } } diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 2b775da955..4a8db1ba19 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -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 @@ -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) } @@ -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). @@ -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 @@ -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 { diff --git a/cmd/entire/cli/explain_export.go b/cmd/entire/cli/explain_export.go index 54b9d6b38c..c38d340383 100644 --- a/cmd/entire/cli/explain_export.go +++ b/cmd/entire/cli/explain_export.go @@ -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 } diff --git a/cmd/entire/cli/explain_progress.go b/cmd/entire/cli/explain_progress.go new file mode 100644 index 0000000000..24c39fd6b1 --- /dev/null +++ b/cmd/entire/cli/explain_progress.go @@ -0,0 +1,151 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/interactive" +) + +// explainProgressWriter renders phase events for the explain pipeline: +// status flips per attempt (→ label / ✓ label / ✗ label) and an +// in-place sublabel ticker for the data-loading sub-steps. Shares visual +// conventions with the streaming summary progress UX (statusStyles glyphs, +// TTY/non-TTY/ACCESSIBLE fallbacks). +type explainProgressWriter struct { + w io.Writer + inplace bool + arrow string + check string + cross string + lastLine string +} + +func newExplainProgressWriter(w io.Writer) *explainProgressWriter { + styles := newStatusStyles(w) + accessible := IsAccessibleMode() + inplace := interactive.IsTerminalWriter(w) && !accessible + arrow, check, cross := "→", "✓", "✗" + if accessible { + arrow, check, cross = "->", "[ok]", "[fail]" + } + return &explainProgressWriter{ + w: w, + inplace: inplace, + arrow: styles.render(styles.cyan, arrow), + check: styles.render(styles.green, check), + cross: styles.render(styles.red, cross), + } +} + +// StartPhase shows "→ label..." for an attempt that's in progress. +// On TTY it can be overwritten in place; on non-TTY it appears as its own line. +func (p *explainProgressWriter) StartPhase(label string) { + p.updateLine(fmt.Sprintf("%s %s...", p.arrow, label)) +} + +// FinishPhase replaces the most recent in-flight line with the phase outcome. +// detail is appended after a separator ("✓ label (1.6s)" on ok, +// "✗ label: not configured" on fail). detail may be empty. +func (p *explainProgressWriter) FinishPhase(label string, ok bool, detail string) { + glyph := p.check + line := glyph + " " + label + if !ok { + glyph = p.cross + line = glyph + " " + label + if detail != "" { + line += ": " + detail + } + } else if detail != "" { + line += " (" + detail + ")" + } + p.printLine(line) +} + +// UpdateSublabel ticks a single in-place line for sub-step transitions +// (e.g. metadata → content → commits during the data-loading pipeline). +// On non-TTY it emits one line per sub-step. +func (p *explainProgressWriter) UpdateSublabel(prefix, sub string) { + p.updateLine(fmt.Sprintf("%s %s — %s...", p.arrow, prefix, sub)) +} + +// updateLine writes line in-place on TTY; on non-TTY appends a new line. +// A subsequent updateLine or printLine call clears the line. +func (p *explainProgressWriter) updateLine(line string) { + if p.lastLine == line { + return + } + if p.inplace { + fmt.Fprintf(p.w, "\r\033[2K%s", line) + } else { + fmt.Fprintln(p.w, line) + } + p.lastLine = line +} + +// printLine emits line as a finalized line (with trailing newline), +// clearing any in-flight in-place line first. +func (p *explainProgressWriter) printLine(line string) { + if p.inplace && p.lastLine != "" { + fmt.Fprint(p.w, "\r\033[2K") + } + fmt.Fprintln(p.w, line) + p.lastLine = "" +} + +// formatPhaseDuration renders a duration for inline phase details +// ("1.6s", "120ms"). Mirrors the style used by the streaming summary UX. +func formatPhaseDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +} + +// newPhaseProgressHooks adapts an explainProgressWriter to +// checkpoint.AttemptHooks so phase events fire as each fetch/read attempt +// runs. Successful attempts render as "✓ label (duration)"; failed attempts +// render as "✗ label: " so the user sees the immediate +// reason rather than just "failed". +func newPhaseProgressHooks(pw *explainProgressWriter) checkpoint.AttemptHooks { + if pw == nil { + return checkpoint.AttemptHooks{} + } + return checkpoint.AttemptHooks{ + OnStart: func(label string) { + pw.StartPhase(label) + }, + OnFinish: func(label string, duration time.Duration, err error) { + if err == nil { + pw.FinishPhase(label, true, formatPhaseDuration(duration)) + return + } + pw.FinishPhase(label, false, firstStderrLine(err)) + }, + } +} + +// firstStderrLine returns a short single-line representation of err +// suitable for an inline "✗