diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index 299f46f186..a91c4f0463 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -26,6 +26,7 @@ func newSearchCmd() *cobra.Command { dateFlag string branchFlag string repoFlag string + localFlag bool ) cmd := &cobra.Command{ @@ -40,7 +41,12 @@ Run without arguments to open an interactive search. Results are displayed in an interactive table. Use --json for machine-readable output. CLI queries also support inline filters like author:, date:, -branch:, repo:, and repo:* to search all accessible repos.`, +branch:, repo:, and repo:* to search all accessible repos. + +Pass --local to search the local entire/checkpoints/v1 branch directly, +skipping the remote service. Useful when the remote index is lagging or +unavailable. Local search does case-insensitive substring matching over +prompts, transcripts, file paths, and the checkpoint branch name.`, Args: cobra.ArbitraryArgs, Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -76,6 +82,10 @@ branch:, repo:, and repo:* to search all accessible repos.`, return errors.New("query required when using --json, accessible mode, or piped output. Usage: entire search ") } + if localFlag { + return runSearchLocal(ctx, w, query, branchFlag, jsonOutput, isTerminal, limitFlag, pageFlag) + } + ghToken, err := auth.LookupCurrentToken() if err != nil { return fmt.Errorf("reading credentials: %w", err) @@ -159,6 +169,7 @@ branch:, repo:, and repo:* to search all accessible repos.`, // JSON output: explicit flag or piped/redirected stdout if jsonOutput || !isTerminal { + maybePrintLocalFallbackHint(ctx, cmd.ErrOrStderr(), resp, query) return writeSearchJSON(w, resp, requestedLimit, requestedPage) } @@ -168,6 +179,7 @@ branch:, repo:, and repo:* to search all accessible repos.`, if IsAccessibleMode() { if len(resp.Results) == 0 { fmt.Fprintln(w, "No results found.") + maybePrintLocalFallbackHint(ctx, cmd.ErrOrStderr(), resp, query) return nil } renderSearchStatic(w, resp.Results, query, resp.Total, styles) @@ -191,6 +203,7 @@ branch:, repo:, and repo:* to search all accessible repos.`, cmd.Flags().StringVar(&dateFlag, "date", "", "Filter by time period (week or month)") cmd.Flags().StringVar(&branchFlag, "branch", "", "Filter by branch name") cmd.Flags().StringVar(&repoFlag, "repo", "", "Filter by repository (owner/name or *)") + cmd.Flags().BoolVar(&localFlag, "local", false, "Search local checkpoints only (skip the remote search service)") cmd.RegisterFlagCompletionFunc("date", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { //nolint:errcheck,gosec // only fails if the flag isn't defined; defined directly above return []string{"week", "month"}, cobra.ShellCompDirectiveNoFileComp diff --git a/cmd/entire/cli/search_local.go b/cmd/entire/cli/search_local.go new file mode 100644 index 0000000000..e4f1b717e4 --- /dev/null +++ b/cmd/entire/cli/search_local.go @@ -0,0 +1,259 @@ +package cli + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/search" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// runSearchLocal is the --local entry point invoked from the search command. +// It resolves the local owner/repo best-effort (for result enrichment), +// performs the local search, and writes JSON or a static table. +// +// The local path intentionally skips the TUI: the remote TUI re-issues +// queries against the search service as the user types, which would +// require either special-casing the dispatcher or duplicating the TUI +// model. A static table is good enough as a fallback surface. +func runSearchLocal(ctx context.Context, w io.Writer, query, branch string, jsonOutput, isTerminal bool, limit, page int) error { + owner, repoName := localOriginIdentity(ctx) + resp, err := runLocalSearch(ctx, localSearchInput{ + Query: query, + Branch: branch, + Owner: owner, + Repo: repoName, + }) + if err != nil { + return fmt.Errorf("local search failed: %w", err) + } + + if jsonOutput || !isTerminal { + return writeSearchJSON(w, resp, limit, page) + } + + styles := newStatusStyles(w) + if len(resp.Results) == 0 { + fmt.Fprintln(w, "No local checkpoints matched.") + return nil + } + renderSearchStatic(w, resp.Results, query, resp.Total, styles) + return nil +} + +// maybePrintLocalFallbackHint writes a one-line stderr hint when the remote +// search came back empty but the local entire/checkpoints/v1 branch has +// content. This closes the discoverability gap reported in #1171 / #1195: +// users seeing 0 results from the service have no signal that --local +// exists or that their data is captured locally. +// +// The hint is silent in three cases — when results were returned, when no +// local checkpoints exist, or when the lookup itself errors — so it never +// fires misleadingly and never breaks the search command. +func maybePrintLocalFallbackHint(ctx context.Context, errW io.Writer, resp *search.Response, query string) { + writeLocalFallbackHint(errW, resp, query, countLocalCheckpoints(ctx)) +} + +// writeLocalFallbackHint is the pure rendering half, separated so tests can +// supply a fixed local count instead of opening a real repo. +func writeLocalFallbackHint(errW io.Writer, resp *search.Response, query string, localCount int) { + if resp == nil || resp.Total > 0 || localCount <= 0 { + return + } + suggested := strings.TrimSpace(query) + if suggested == "" { + fmt.Fprintf(errW, + "Hint: 0 results from the search service. %d local checkpoint(s) are present on entire/checkpoints/v1 — try `entire search --local` to grep them directly.\n", + localCount) + return + } + fmt.Fprintf(errW, + "Hint: 0 results from the search service. %d local checkpoint(s) are present on entire/checkpoints/v1 — try `entire search --local %q` to grep them directly.\n", + localCount, suggested) +} + +// countLocalCheckpoints returns the number of committed checkpoints on +// entire/checkpoints/v1, or 0 if anything goes wrong (no repo, no branch, +// store unreadable). Used by maybePrintLocalFallbackHint as a cheap "is +// there anything to fall back to?" probe. +func countLocalCheckpoints(ctx context.Context) int { + repo, err := strategy.OpenRepository(ctx) + if err != nil { + return 0 + } + infos, err := checkpoint.NewGitStore(repo).ListCommitted(ctx) + if err != nil { + return 0 + } + return len(infos) +} + +// localOriginIdentity resolves the GitHub owner/repo from the local +// origin remote on a best-effort basis. Failures are silently swallowed +// because local search must work even when there's no GitHub origin +// (e.g. on a clean repro repo, or one that points at a non-GitHub host). +func localOriginIdentity(ctx context.Context) (string, string) { + repo, err := strategy.OpenRepository(ctx) + if err != nil { + return "", "" + } + remote, err := repo.Remote("origin") + if err != nil { + return "", "" + } + urls := remote.Config().URLs + if len(urls) == 0 { + return "", "" + } + owner, repoName, err := search.ParseGitHubRemote(urls[0]) + if err != nil { + return "", "" + } + return owner, repoName +} + +// localSearchInput holds the inputs for a local checkpoint search. +type localSearchInput struct { + Query string // case-insensitive substring; empty matches all + Branch string // exact-match filter on checkpoint branch + Owner string // origin owner for result enrichment (best-effort) + Repo string // origin repo for result enrichment (best-effort) +} + +// runLocalSearch walks entire/checkpoints/v1 in the current repo and returns +// checkpoints whose text content contains the query (case-insensitive). The +// search covers branch, file paths, prompts, and transcript text. An empty +// query matches every checkpoint that passes the filters. +// +// This is a CLI-only fallback for the remote search service. Use it when +// the remote index is lagging, unavailable, or scoped to a different +// GitHub repo than the local working copy (e.g. when checkpoints are +// pushed to a dedicated `-checkpoints-private` mirror). +func runLocalSearch(ctx context.Context, in localSearchInput) (*search.Response, error) { + repo, err := strategy.OpenRepository(ctx) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + return localSearchWithStore(ctx, checkpoint.NewGitStore(repo), in) +} + +// localSearchWithStore is the pure matching logic, exposed for tests so they +// can build a synthetic store without going through OpenRepository (which +// resolves the worktree via CWD and is incompatible with t.Parallel). +func localSearchWithStore(ctx context.Context, store *checkpoint.GitStore, in localSearchInput) (*search.Response, error) { + infos, err := store.ListCommitted(ctx) + if err != nil { + return nil, fmt.Errorf("list local checkpoints: %w", err) + } + + needle := strings.ToLower(strings.TrimSpace(in.Query)) + branchFilter := strings.TrimSpace(in.Branch) + + matches := make([]search.Result, 0, len(infos)) + for _, info := range infos { + summary, err := store.ReadCommitted(ctx, info.CheckpointID) + if err != nil || summary == nil { + continue + } + if branchFilter != "" && summary.Branch != branchFilter { + continue + } + + prompt, transcript := readLatestSessionText(ctx, store, info.CheckpointID, summary) + + matched := needle == "" + snippet := "" + if !matched { + for _, candidate := range []string{ + prompt, + string(transcript), + strings.Join(summary.FilesTouched, " "), + summary.Branch, + } { + if candidate == "" { + continue + } + lower := strings.ToLower(candidate) + idx := strings.Index(lower, needle) + if idx < 0 { + continue + } + matched = true + snippet = makeLocalSnippet(candidate, idx, len(needle)) + break + } + } + if !matched { + continue + } + + matches = append(matches, search.Result{ + Type: "checkpoint", + Data: search.CheckpointResult{ + ID: info.CheckpointID.String(), + Prompt: strings.TrimSpace(firstLine(strings.TrimSpace(prompt))), + Branch: summary.Branch, + Org: in.Owner, + Repo: in.Repo, + CreatedAt: info.CreatedAt.UTC().Format(time.RFC3339), + FilesTouched: summary.FilesTouched, + }, + Meta: search.Meta{ + MatchType: "local", + Score: 1.0, + Snippet: snippet, + }, + }) + } + + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Data.CreatedAt > matches[j].Data.CreatedAt + }) + + return &search.Response{Results: matches, Total: len(matches), Page: 1}, nil +} + +// readLatestSessionText reads the latest session's prompts and transcript. +// Returns empty strings on any error so callers treat missing content as +// non-matching rather than failing the whole search — checkpoints written +// by other agents or older CLI versions are still listable, just not +// fully grep-able. +func readLatestSessionText(ctx context.Context, store *checkpoint.GitStore, cpID id.CheckpointID, summary *checkpoint.CheckpointSummary) (string, []byte) { + if summary == nil || len(summary.Sessions) == 0 { + return "", nil + } + latest := len(summary.Sessions) - 1 + content, err := store.ReadSessionContent(ctx, cpID, latest) + if err != nil || content == nil { + return "", nil + } + return content.Prompts, content.Transcript +} + +// makeLocalSnippet returns a short windowed slice around idx for display. +// Newlines are folded to spaces so the snippet renders on a single line in +// the table view. +func makeLocalSnippet(text string, idx, qlen int) string { + const window = 40 + if idx < 0 || idx >= len(text) { + return "" + } + start := idx - window + if start < 0 { + start = 0 + } + end := idx + qlen + window + if end > len(text) { + end = len(text) + } + s := text[start:end] + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + return strings.TrimSpace(s) +} diff --git a/cmd/entire/cli/search_local_test.go b/cmd/entire/cli/search_local_test.go new file mode 100644 index 0000000000..11e87f45b8 --- /dev/null +++ b/cmd/entire/cli/search_local_test.go @@ -0,0 +1,282 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "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/search" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/redact" + + "github.com/go-git/go-git/v6" +) + +// localFixture describes a single-session checkpoint written into a test +// repo. It exists so individual cases can spell out only the fields they +// care about while the helper fills in sensible defaults for the rest. +type localFixture struct { + id string + branch string + filesTouched []string + prompt string + transcript string +} + +// makeLocalSearchRepo initializes a git repo with one user commit and the +// given checkpoints written to entire/checkpoints/v1. Tests exercise the +// real on-disk store rather than a mock, so regressions in either the +// matcher or the underlying ReadCommitted/ReadSessionContent plumbing are +// caught here. +func makeLocalSearchRepo(t *testing.T, fixtures []localFixture) *checkpoint.GitStore { + t.Helper() + + repoDir := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + testutil.InitRepo(t, repoDir) + testutil.WriteFile(t, repoDir, "README.md", "init") + testutil.GitAdd(t, repoDir, "README.md") + testutil.GitCommit(t, repoDir, "init") + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("open repo: %v", err) + } + store := checkpoint.NewGitStore(repo) + + for _, f := range fixtures { + err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ + CheckpointID: id.MustCheckpointID(f.id), + SessionID: "session-" + f.id, + Strategy: "manual-commit", + Branch: f.branch, + FilesTouched: f.filesTouched, + Prompts: []string{f.prompt}, + Transcript: redact.AlreadyRedacted([]byte(f.transcript)), + AuthorName: "Test", + AuthorEmail: "test@example.com", + }) + if err != nil { + t.Fatalf("WriteCommitted %s: %v", f.id, err) + } + } + + return store +} + +func TestLocalSearch_FindsByTranscriptToken(t *testing.T) { + t.Parallel() + + store := makeLocalSearchRepo(t, []localFixture{ + { + id: "a1b2c3d4e5f6", + branch: "main", + filesTouched: []string{"src/auth.go"}, + prompt: "Fix the login flow", + transcript: "assistant: I rewrote the Lefthook config to call entire instead.\n", + }, + { + id: "b2c3d4e5f6a7", + branch: "main", + filesTouched: []string{"docs/intro.md"}, + prompt: "Add intro docs", + transcript: "assistant: drafted an intro section about onboarding.\n", + }, + }) + + resp, err := localSearchWithStore(context.Background(), store, localSearchInput{Query: "lefthook"}) + if err != nil { + t.Fatalf("localSearchWithStore error: %v", err) + } + if resp.Total != 1 { + t.Fatalf("expected 1 match, got %d (results=%v)", resp.Total, resp.Results) + } + got := resp.Results[0].Data.ID + if got != "a1b2c3d4e5f6" { + t.Errorf("matched checkpoint = %q, want %q", got, "a1b2c3d4e5f6") + } + if resp.Results[0].Meta.MatchType != "local" { + t.Errorf("meta.matchType = %q, want %q", resp.Results[0].Meta.MatchType, "local") + } + if resp.Results[0].Meta.Snippet == "" { + t.Error("expected a non-empty snippet for matched result") + } +} + +func TestLocalSearch_FindsByFilesTouched(t *testing.T) { + t.Parallel() + + store := makeLocalSearchRepo(t, []localFixture{ + { + id: "a1b2c3d4e5f6", + branch: "feature", + filesTouched: []string{"app/login_handler.go"}, + prompt: "Refactor", + transcript: "assistant: refactored handler.\n", + }, + }) + + resp, err := localSearchWithStore(context.Background(), store, localSearchInput{Query: "login_handler"}) + if err != nil { + t.Fatalf("localSearchWithStore error: %v", err) + } + if resp.Total != 1 { + t.Fatalf("expected 1 match for files_touched hit, got %d", resp.Total) + } +} + +func TestLocalSearch_BranchFilter(t *testing.T) { + t.Parallel() + + store := makeLocalSearchRepo(t, []localFixture{ + { + id: "a1b2c3d4e5f6", + branch: "main", + prompt: "Touched main", + transcript: "assistant: touched something on main.\n", + }, + { + id: "b2c3d4e5f6a7", + branch: "feature", + prompt: "Touched feature", + transcript: "assistant: touched something on feature.\n", + }, + }) + + resp, err := localSearchWithStore(context.Background(), store, localSearchInput{ + Branch: "feature", + }) + if err != nil { + t.Fatalf("localSearchWithStore error: %v", err) + } + if resp.Total != 1 { + t.Fatalf("expected 1 match on feature branch, got %d", resp.Total) + } + if resp.Results[0].Data.Branch != "feature" { + t.Errorf("result branch = %q, want %q", resp.Results[0].Data.Branch, "feature") + } +} + +func TestLocalSearch_EmptyQueryMatchesAll(t *testing.T) { + t.Parallel() + + store := makeLocalSearchRepo(t, []localFixture{ + {id: "a1b2c3d4e5f6", branch: "main", prompt: "one", transcript: "assistant: one.\n"}, + {id: "b2c3d4e5f6a7", branch: "main", prompt: "two", transcript: "assistant: two.\n"}, + }) + + resp, err := localSearchWithStore(context.Background(), store, localSearchInput{}) + if err != nil { + t.Fatalf("localSearchWithStore error: %v", err) + } + if resp.Total != 2 { + t.Fatalf("empty query: expected 2 matches, got %d", resp.Total) + } +} + +func TestLocalSearch_NoMatches(t *testing.T) { + t.Parallel() + + store := makeLocalSearchRepo(t, []localFixture{ + { + id: "a1b2c3d4e5f6", + branch: "main", + prompt: "hello", + transcript: "assistant: hello world\n", + }, + }) + + resp, err := localSearchWithStore(context.Background(), store, localSearchInput{Query: "kubernetes"}) + if err != nil { + t.Fatalf("localSearchWithStore error: %v", err) + } + if resp.Total != 0 { + t.Errorf("expected 0 matches, got %d", resp.Total) + } +} + +func TestWriteLocalFallbackHint_PrintsWhenRemoteEmptyAndLocalHasCheckpoints(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeLocalFallbackHint(&buf, &search.Response{Total: 0}, "lefthook", 3) + + out := buf.String() + if !strings.Contains(out, "0 results from the search service") { + t.Fatalf("hint missing service-empty phrase: %q", out) + } + if !strings.Contains(out, "3 local checkpoint") { + t.Fatalf("hint missing local count: %q", out) + } + if !strings.Contains(out, "--local \"lefthook\"") { + t.Fatalf("hint missing suggested --local invocation: %q", out) + } +} + +func TestWriteLocalFallbackHint_OmitsQuotesForEmptyQuery(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeLocalFallbackHint(&buf, &search.Response{Total: 0}, " ", 1) + + out := buf.String() + if !strings.Contains(out, "--local`") { + t.Fatalf("hint should suggest bare --local for empty query: %q", out) + } + if strings.Contains(out, "\"\"") { + t.Fatalf("hint should not include empty quoted arg: %q", out) + } +} + +func TestWriteLocalFallbackHint_SilentWhenResultsExist(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeLocalFallbackHint(&buf, &search.Response{Total: 5}, "anything", 10) + + if buf.Len() != 0 { + t.Fatalf("expected no output when remote returned results, got %q", buf.String()) + } +} + +func TestWriteLocalFallbackHint_SilentWhenNoLocalCheckpoints(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeLocalFallbackHint(&buf, &search.Response{Total: 0}, "anything", 0) + + if buf.Len() != 0 { + t.Fatalf("expected no output when local store is empty, got %q", buf.String()) + } +} + +func TestWriteLocalFallbackHint_SilentOnNilResponse(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeLocalFallbackHint(&buf, nil, "anything", 5) + + if buf.Len() != 0 { + t.Fatalf("expected no output on nil response, got %q", buf.String()) + } +} + +func TestMakeLocalSnippet_WindowsAroundMatch(t *testing.T) { + t.Parallel() + + text := "prefix prefix prefix needle suffix suffix suffix" + snippet := makeLocalSnippet(text, len("prefix prefix prefix "), len("needle")) + if snippet == "" { + t.Fatal("expected non-empty snippet") + } + if !strings.Contains(snippet, "needle") { + t.Errorf("snippet missing match token: %q", snippet) + } +} diff --git a/scripts/seed-local-search-fixture/main.go b/scripts/seed-local-search-fixture/main.go new file mode 100644 index 0000000000..c7777d0d25 --- /dev/null +++ b/scripts/seed-local-search-fixture/main.go @@ -0,0 +1,106 @@ +// Build a tiny git repo with two committed checkpoints so the entire +// binary can be exercised against real data without spinning up an agent. +// Used by manual end-to-end verification of `entire checkpoint search --local`. +// Run: go run ./scripts/seed-local-search-fixture +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/redact" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: seed-local-search-fixture ") + os.Exit(2) + } + dir := os.Args[1] + if err := run(dir); err != nil { + fmt.Fprintf(os.Stderr, "seed failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("seeded fixture repo at %s\n", dir) +} + +func run(dir string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + repo, err := git.PlainInit(dir, false) + if err != nil { + return fmt.Errorf("git init: %w", err) + } + // Disable gpg signing for the initial commit, otherwise PlainInit-created + // repos pick up the user's global config and the commit will fail in CI. + cmd := exec.Command("git", "-C", dir, "config", "commit.gpgsign", "false") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("disable gpgsign: %w: %s", err, out) + } + + readme := filepath.Join(dir, "README.md") + if err := os.WriteFile(readme, []byte("# fixture\n"), 0o644); err != nil { + return fmt.Errorf("write README: %w", err) + } + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("worktree: %w", err) + } + if _, err := wt.Add("README.md"); err != nil { + return fmt.Errorf("git add: %w", err) + } + if _, err := wt.Commit("init", &git.CommitOptions{ + Author: &object.Signature{Name: "Fixture", Email: "fixture@example.com"}, + }); err != nil { + return fmt.Errorf("git commit: %w", err) + } + + store := checkpoint.NewGitStore(repo) + fixtures := []struct { + id string + branch string + filesTouched []string + prompt string + transcript string + }{ + { + id: "a1b2c3d4e5f6", + branch: "main", + filesTouched: []string{"src/auth.go"}, + prompt: "Fix the login flow", + transcript: "assistant: I rewrote the Lefthook config to call entire instead.\n", + }, + { + id: "b2c3d4e5f6a7", + branch: "feature", + filesTouched: []string{"docs/intro.md"}, + prompt: "Add intro docs", + transcript: "assistant: drafted an intro section about onboarding.\n", + }, + } + for _, f := range fixtures { + if err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ + CheckpointID: id.MustCheckpointID(f.id), + SessionID: "session-" + f.id, + Strategy: "manual-commit", + Branch: f.branch, + FilesTouched: f.filesTouched, + Prompts: []string{f.prompt}, + Transcript: redact.AlreadyRedacted([]byte(f.transcript)), + AuthorName: "Fixture", + AuthorEmail: "fixture@example.com", + }); err != nil { + return fmt.Errorf("write checkpoint %s: %w", f.id, err) + } + } + return nil +}