From fdc537fdbde8f6c6c646d622d339100d411b43b3 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Wed, 13 May 2026 21:49:09 -0400 Subject: [PATCH 1/3] feat(search): add --local fallback that greps the local checkpoint branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can hit empty results from the remote search service even when checkpoints are fully captured locally (visible via `entire activity` and `entire checkpoint explain`) — for example when the index is lagging or when checkpoints push to a dedicated mirror repo whose name differs from the working copy's origin. `entire search --local` (also `entire checkpoint search --local`) walks entire/checkpoints/v1 and does case-insensitive substring matching over prompts, transcripts, file paths, and the checkpoint branch name. The flag bypasses auth and the remote service entirely, so it works offline and against any repo the user owns checkpoints in. Output reuses the existing JSON/static-table shapes so scripted consumers don't have to branch on the source. Refs #1195, #1171 --- cmd/entire/cli/search_cmd.go | 13 +- cmd/entire/cli/search_local.go | 212 +++++++++++++++++++++++++++ cmd/entire/cli/search_local_test.go | 214 ++++++++++++++++++++++++++++ 3 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 cmd/entire/cli/search_local.go create mode 100644 cmd/entire/cli/search_local_test.go diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index 299f46f186..7732993663 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) @@ -191,6 +201,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..710bbf8c8b --- /dev/null +++ b/cmd/entire/cli/search_local.go @@ -0,0 +1,212 @@ +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 +} + +// 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..3447fe5564 --- /dev/null +++ b/cmd/entire/cli/search_local_test.go @@ -0,0 +1,214 @@ +package cli + +import ( + "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/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 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) + } +} From f9a9337c0e08936570616a6088958e084568c155 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Wed, 13 May 2026 21:49:22 -0400 Subject: [PATCH 2/3] test(search): add local search fixture seeder A small Go script that initializes a temp repo and writes two committed checkpoints via the public store API. Useful for manual end-to-end verification of `entire checkpoint search --local` without having to run an agent session. Usage: go run ./scripts/seed-local-search-fixture cd entire checkpoint search --local "" --json --- scripts/seed-local-search-fixture/main.go | 106 ++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 scripts/seed-local-search-fixture/main.go 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 +} From 7d6324ec73126b520878762fb90e02e809d205e5 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Wed, 13 May 2026 22:02:45 -0400 Subject: [PATCH 3/3] feat(search): suggest --local when remote search returns 0 results When `entire search` / `entire checkpoint search` returns 0 results and the local entire/checkpoints/v1 branch has content, print a one-line stderr hint pointing the user at `--local`. Closes the discoverability gap reported in #1171 and #1195: the reporters knew their checkpoints were captured (visible in activity / dispatch / explain) but had no signal that --local existed or would work for them. The hint fires only on JSON/static paths so the TUI isn't polluted, and is silent when results came back, no local checkpoints exist, or the response is nil. Refs #1171, #1195 --- cmd/entire/cli/search_cmd.go | 2 + cmd/entire/cli/search_local.go | 47 ++++++++++++++++++++ cmd/entire/cli/search_local_test.go | 68 +++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index 7732993663..a91c4f0463 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -169,6 +169,7 @@ prompts, transcripts, file paths, and the checkpoint branch name.`, // JSON output: explicit flag or piped/redirected stdout if jsonOutput || !isTerminal { + maybePrintLocalFallbackHint(ctx, cmd.ErrOrStderr(), resp, query) return writeSearchJSON(w, resp, requestedLimit, requestedPage) } @@ -178,6 +179,7 @@ prompts, transcripts, file paths, and the checkpoint branch name.`, 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) diff --git a/cmd/entire/cli/search_local.go b/cmd/entire/cli/search_local.go index 710bbf8c8b..e4f1b717e4 100644 --- a/cmd/entire/cli/search_local.go +++ b/cmd/entire/cli/search_local.go @@ -47,6 +47,53 @@ func runSearchLocal(ctx context.Context, w io.Writer, query, branch string, json 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 diff --git a/cmd/entire/cli/search_local_test.go b/cmd/entire/cli/search_local_test.go index 3447fe5564..11e87f45b8 100644 --- a/cmd/entire/cli/search_local_test.go +++ b/cmd/entire/cli/search_local_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "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" @@ -200,6 +202,72 @@ func TestLocalSearch_NoMatches(t *testing.T) { } } +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()