Merge branch 'main' into soph/promote-control-plane-commands-out-of-labs · Entire
Log in
Merge branch 'main' into soph/promote-control-plane-commands-out-of-labs
362a628→main·
gtrrz-victor·1w ago·10 files·+2,346 added/-110 removed
Changes
10
.github/workflows
Mpublish-git-remote-entire.yml+1/-1
cmd/entire/cli
codesearch
Acodesearch.go+119
Acodesearch_test.go+153
Mrecap.go+16
Mrepo_mirror.go+175/-14
Mrepo_mirror_test.go+314/-7
Msearch_cmd.go+549/-5
Msearch_cmd_test.go+620
Msearch_tui.go+369/-52
Msearch_tui_test.go+30/-31
37 unmodified lines
38
39
40
41
41
42
43
44
37 unmodified lines
run: git tag --force v0.0.0
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
with:
role-to-assume: arn:aws:iam::128096325110:role/github-actions-public-release-upload
aws-region: us-east-2
M.github/workflows/publish-git-remote-entire.yml+1/-1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
package codesearch
import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"github.com/entireio/cli/cmd/entire/cli/api"
)
const maxResponseBytes = 8 << 20 // 8 MiB — code search results with context lines can be large
// SearchRequest holds the parameters for a code search call to peregrine
// via the cell's entire-api gateway at GET /api/v1/search/api/search.
type SearchRequest struct {
Query string
Repos []string
MaxResults int
CaseSensitive bool
}
// Stats holds aggregate search statistics.
type Stats struct {
TotalMatches int `json:"total_matches"`
TotalFiles int `json:"total_files"`
DurationMs float64 `json:"duration_ms"`
ReposSearched int `json:"repos_searched"`
}
// RepoStats holds per-repo match statistics.
type RepoStats struct {
Repo string `json:"repo"`
MatchCount int `json:"match_count"`
FileCount int `json:"file_count"`
}
// Result is a single code search match from peregrine.
type Result struct {
Repo string `json:"repo"`
Path string `json:"path"`
Line int `json:"line"`
Column int `json:"column"`
ContextBefore []string `json:"context_before"`
ContextLine string `json:"context_line"`
ContextAfter []string `json:"context_after"`
Score float64 `json:"score"`
}
// SearchResponse is peregrine's code search response.
type SearchResponse struct {
Query string `json:"query"`
Stats Stats `json:"stats"`
RepoStats []RepoStats `json:"repo_stats"`
Results []Result `json:"results"`
// FailedJurisdictions is set by the CLI's merge layer (not by peregrine)
// when one or more cells failed during multi-region fan-out.
FailedJurisdictions []string `json:"failed_jurisdictions,omitempty"`
}
// Search calls peregrine's code search endpoint through the cell's entire-api
// gateway: GET /api/v1/search/api/search?q=...&max_results=...&repo=...
// The client must already be authenticated against the cell.
func Search(ctx context.Context, client *api.Client, req SearchRequest) (*SearchResponse, error) {
params := url.Values{}
params.Set("q", req.Query)
if req.MaxResults > 0 {
params.Set("max_results", strconv.Itoa(req.MaxResults))
}
if req.CaseSensitive {
// ponytail: peregrine's proto does not yet define case_sensitive;
// the param is sent optimistically so it takes effect once
// peregrine adds support without a CLI release.
params.Set("case_sensitive", "true")
}
for _, r := range req.Repos {
params.Add("repo", r)
}
searchPath := "/api/v1/search/api/search?" + params.Encode()
resp, err := client.Get(ctx, searchPath)
if err != nil {
return nil, fmt.Errorf("code search request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, fmt.Errorf("reading code search response: %w", err)
}
if int64(len(body)) > maxResponseBytes {
return nil, fmt.Errorf("code search response exceeds %d bytes", maxResponseBytes)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
apiErr := &api.HTTPError{StatusCode: resp.StatusCode}
var parsed api.ErrorResponse
if json.Unmarshal(body, &parsed) == nil {
if msg := parsed.Message(); msg != "" {
apiErr.Message = msg
}
}
if apiErr.Message == "" && len(body) > 0 {
apiErr.Message = strings.TrimSpace(string(body))
}
return nil, fmt.Errorf("code search: %w", apiErr)
}
var result SearchResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("decoding code search response: %w", err)
}
return &result, nil
}
Acmd/entire/cli/codesearch/codesearch.go+119
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
package codesearch
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/entireio/cli/cmd/entire/cli/api"
)
func TestSearch_Success(t *testing.T) {
t.Parallel()
want := SearchResponse{
Query: "handleRequest",
Stats: Stats{
TotalMatches: 3,
TotalFiles: 2,
DurationMs: 42.5,
ReposSearched: 1,
},
RepoStats: []RepoStats{
{Repo: "entireio/cli", MatchCount: 3, FileCount: 2},
},
Results: []Result{
{
Repo: "entireio/cli",
Path: "cmd/server/main.go",
Line: 15,
Column: 6,
ContextBefore: []string{"", "// handleRequest processes incoming requests."},
ContextLine: "func handleRequest(w http.ResponseWriter, r *http.Request) {",
ContextAfter: []string{"\tctx := r.Context()"},
Score: 0.95,
},
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search/api/search" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.Error(w, "not found", http.StatusNotFound)
return
}
if r.Method != http.MethodGet {
t.Errorf("unexpected method: %s", r.Method)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if q := r.URL.Query().Get("q"); q != "handleRequest" {
t.Errorf("unexpected query param q: %s", q)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(want) //nolint:errcheck // test handler, error irrelevant
}))
defer srv.Close()
client := api.NewClientWithBaseURL("test-token", srv.URL)
got, err := Search(context.Background(), client, SearchRequest{
Query: "handleRequest",
MaxResults: 10,
})
if err != nil {
t.Fatalf("Search() error: %v", err)
}
if got.Stats.TotalMatches != want.Stats.TotalMatches {
t.Errorf("TotalMatches = %d, want %d", got.Stats.TotalMatches, want.Stats.TotalMatches)
}
if len(got.Results) != len(want.Results) {
t.Fatalf("len(Results) = %d, want %d", len(got.Results), len(want.Results))
}
if got.Results[0].Path != want.Results[0].Path {
t.Errorf("Results[0].Path = %q, want %q", got.Results[0].Path, want.Results[0].Path)
}
}
func TestSearch_APIError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{"error": "insufficient permissions"}) //nolint:errcheck // test handler
}))
defer srv.Close()
client := api.NewClientWithBaseURL("test-token", srv.URL)
_, err := Search(context.Background(), client, SearchRequest{Query: "test"})
if err == nil {
t.Fatal("Search() expected error, got nil")
}
if !strings.Contains(err.Error(), "insufficient permissions") {
t.Errorf("error = %q, want containing 'insufficient permissions'", err.Error())
}
var httpErr *api.HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusForbidden {
t.Errorf("expected HTTPError with status 403, got %v", err)
}
}
func TestSearch_NonJSONError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(" Bad Gateway\n")) //nolint:errcheck // test handler — trailing whitespace exercises TrimSpace
}))
defer srv.Close()
client := api.NewClientWithBaseURL("test-token", srv.URL)
_, err := Search(context.Background(), client, SearchRequest{Query: "test"})
if err == nil {
t.Fatal("Search() expected error, got nil")
}
// Body text should surface (trimmed) in the error message.
if !strings.Contains(err.Error(), "Bad Gateway") {
t.Errorf("error = %q, want containing 'Bad Gateway'", err.Error())
}
// Should wrap *api.HTTPError with the correct status code.
var httpErr *api.HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadGateway {
t.Errorf("expected HTTPError with status 502, got %v", err)
}
}
func TestSearch_ResponseTooLarge(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Write more than maxResponseBytes (8 MiB).
buf := make([]byte, maxResponseBytes+1)
for i := range buf {
buf[i] = 'x'
}
w.Write(buf) //nolint:errcheck // test handler
}))
defer srv.Close()
client := api.NewClientWithBaseURL("test-token", srv.URL)
_, err := Search(context.Background(), client, SearchRequest{Query: "test"})
if err == nil {
t.Fatal("Search() expected error for oversized response, got nil")
}
if want := "exceeds"; !strings.Contains(err.Error(), want) {
t.Errorf("error = %q, want containing %q", err.Error(), want)
}
}
Acmd/entire/cli/codesearch/codesearch_test.go+153
271 unmodified lines
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
271 unmodified lines
}
return owner + "/" + repoName
}
// currentRepoSlugWithForge is like currentRepoSlug but includes the forge
// prefix (e.g. "gh/owner/repo", "et/proj/repo") when the remote maps to a
// known forge. Code search needs this because the repo index FullName may
// include the forge prefix (especially for Entire forge repos stored as
// "et/proj/repo").
func currentRepoSlugWithForge(ctx context.Context) string {
forge, owner, repoName, err := gitremote.ResolveRemoteRepo(ctx, "origin")
if err != nil || owner == "" || repoName == "" {
return ""
}
if forge != "" {
return forge + "/" + owner + "/" + repoName
}
return owner + "/" + repoName
}
Mcmd/entire/cli/recap.go+16
1
2
3
4
5
6
7
1 unmodified line
9
10
11
12
13
14
15
2 unmodified lines
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
20
21
22
23
24
25
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
30
31
32
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
34
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
3 unmodified lines
176
177
178
44
179
180
181
182
298 unmodified lines
481
482
483
349
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
12 unmodified lines
517
518
519
373
520
521
522
523
524
525
526
527
10 unmodified lines
538
539
540
390
541
542
543
544
13 unmodified lines
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
package cli
import (
"cmp"
"context"
"errors"
"fmt"
1 unmodified line
"net"
"net/url"
"regexp"
"slices"
"strings"
"time"
2 unmodified lines
"github.com/entireio/cli/internal/coreapi"
)
// Column header names, the single source of truth for both the table headers
// (mirrorColumns/availableMirrorColumns) and the --sort key switches. parseSort-
// Column returns the canonical header it matched, so the sort switches compare
// against these constants directly.
const (
colRepo = "REPO"
colCloneURL = "CLONE URL"
colPrivate = "PRIVATE"
colAccess = "ACCESS"
colStatus = "STATUS"
)
// mirrorColumns is the human table/field view of a mirror: the scannable
// repo name, the clone URL you'd copy, and whether the upstream is
// private. The cluster is omitted — it's already embedded in the clone
// URL — and the wire model's internal ids are dropped entirely. The clone
// URL is synthesised from the mirror's coords (the form `git clone`
// accepts), since the list API doesn't return it.
var mirrorColumns = []string{"REPO", "CLONE URL", "PRIVATE"}
// owner/repo name, the clone URL you'd copy, and whether the upstream is
// private. Owner, provider, and cluster aren't columns of their own — they're
// inferable from the owner/repo pair and the clone URL
// (entire://<cluster>/gh/<owner>/<repo>). `--repo` filters on the repo name
// only; owner/provider/cluster stay server-side filters, and the wire model's
// internal ids are dropped. The clone URL is synthesised from the mirror's
// coords (the form `git clone` accepts), since the list API doesn't return it.
var mirrorColumns = []string{colRepo, colCloneURL, colPrivate}
// mirrorPrivate renders the PRIVATE column ("yes"/"no"), shared by the table
// row and the --sort private key so both agree on the cell value.
func mirrorPrivate(m coreapi.Mirror) string {
if m.IsPrivate.Or(false) {
return "yes"
}
return "no"
}
func mirrorRow(m coreapi.Mirror) []string {
repo := m.Owner + "/" + m.Repo
cloneURL := mirrorCloneURL(m.ClusterHost, m.Owner, m.Repo)
private := "no"
if m.IsPrivate.Or(false) {
private = "yes"
return []string{repo, cloneURL, mirrorPrivate(m)}
}
// parseSortColumn resolves a --sort spec to the canonical column header it
// names (one of the columns entries) and a direction. It trims first, then
// reads the '-' prefix, so leading/trailing whitespace is handled identically
// on every path (the direction and the column name never disagree). An empty
// spec selects the first column. An unknown name errors naming the valid
// columns. Returning the matched header lets callers switch on the col*
// constants directly.
func parseSortColumn(spec string, columns []string) (col string, desc bool, err error) {
spec = strings.TrimSpace(spec)
desc = strings.HasPrefix(spec, "-")
name := strings.TrimSpace(strings.TrimPrefix(spec, "-"))
if name == "" {
return columns[0], desc, nil
}
return []string{repo, cloneURL, private}
for _, h := range columns {
if strings.EqualFold(h, name) {
return h, desc, nil
}
}
return "", false, fmt.Errorf("unknown sort column %q; valid columns: %s", name, strings.ToLower(strings.Join(columns, ", ")))
}
// sortMirrors orders mirrors in place by the --sort spec: by the named column's
// value ascending (case-insensitive), always breaking ties by owner/repo then
// cluster host so a repo mirrored across clusters (or rows equal on any other
// column) has a stable, deterministic order rather than arbitrary server order.
// A '-' prefix reverses the whole ordering. `repo`/default sorts by the
// tiebreak alone.
func sortMirrors(mirrors []coreapi.Mirror, spec string) error {
col, desc, err := parseSortColumn(spec, mirrorColumns)
if err != nil {
return err
}
key := func(m coreapi.Mirror) string {
switch col {
case colCloneURL:
return strings.ToLower(mirrorCloneURL(m.ClusterHost, m.Owner, m.Repo))
case colPrivate:
return mirrorPrivate(m)
default: // repo -> tiebreak alone
return ""
}
}
slices.SortStableFunc(mirrors, func(a, b coreapi.Mirror) int {
c := cmp.Compare(key(a), key(b))
if c == 0 {
c = cmp.Compare(strings.ToLower(a.Owner+"/"+a.Repo), strings.ToLower(b.Owner+"/"+b.Repo))
}
if c == 0 {
c = cmp.Compare(strings.ToLower(a.ClusterHost), strings.ToLower(b.ClusterHost))
}
if desc {
return -c
}
return c
})
return nil
}
// sortAvailable orders available mirrors in place by the --sort spec, matching
// sortMirrors: by the named column ascending (case-insensitive) with an
// owner/repo tiebreak for a deterministic order on equal keys. AvailableMirror
// has no cluster host (the onboardable set is cluster-agnostic), so owner/repo
// is the only secondary key. A '-' prefix reverses the whole ordering.
func sortAvailable(avail []coreapi.AvailableMirror, spec string) error {
col, desc, err := parseSortColumn(spec, availableMirrorColumns)
if err != nil {
return err
}
key := func(m coreapi.AvailableMirror) string {
switch col {
case colAccess:
return strings.ToLower(string(m.Access))
case colStatus:
return strings.ToLower(string(m.Status))
default: // repo -> tiebreak alone
return ""
}
}
slices.SortStableFunc(avail, func(a, b coreapi.AvailableMirror) int {
c := cmp.Compare(key(a), key(b))
if c == 0 {
c = cmp.Compare(strings.ToLower(a.Owner+"/"+a.Repo), strings.ToLower(b.Owner+"/"+b.Repo))
}
if desc {
return -c
}
return c
})
return nil
}
// filterByRepo keeps items whose repo identifier contains substr (case-
// insensitive). The control plane already filters by owner/provider/cluster
// server-side but not by repo name, so `repo mirror list --repo` narrows that
// last dimension client-side. repoOf returns the item's displayed identifier —
// the callers pass the owner/repo form shown in the REPO column, so a value
// copied from the table (e.g. acme/web) matches the row it came from. An empty
// substr returns items unchanged.
func filterByRepo[T any](items []T, repoOf func(T) string, substr string) []T {
substr = strings.TrimSpace(substr)
if substr == "" {
return items
}
substr = strings.ToLower(substr)
out := make([]T, 0, len(items))
for _, it := range items {
if strings.Contains(strings.ToLower(repoOf(it)), substr) {
out = append(out, it)
}
}
return out
}
// availableMirrorColumns is the view of a repo you *could* mirror: the
3 unmodified lines
// clone URL), or "owner-only" (a personal repo of another user; only its
// owner may mirror it). No clone URL column: an un-onboarded repo doesn't
// have one yet.
var availableMirrorColumns = []string{"REPO", "ACCESS", "STATUS"}
var availableMirrorColumns = []string{colRepo, colAccess, colStatus}
func availableMirrorRow(m coreapi.AvailableMirror) []string {
return []string{m.Owner + "/" + m.Repo, string(m.Access), string(m.Status)}
298 unmodified lines
}
func newRepoMirrorListCmd() *cobra.Command {
var cluster, provider, owner string
var cluster, provider, owner, repo string
var sortSpec string
var showAvailable bool
cmd := &cobra.Command{
Use: "list",
Short: "List mirrors you can see (or, with --show-available, repos you could mirror)",
Args: cobra.NoArgs,
// Validate --sort before RunE so a bad column fails fast, without the
// network round-trip RunE would otherwise do first. The valid column
// set depends on --show-available (different table shape).
PreRunE: func(_ *cobra.Command, _ []string) error {
cols := mirrorColumns
if showAvailable {
cols = availableMirrorColumns
}
_, _, err := parseSortColumn(sortSpec, cols)
return err
},
RunE: func(cmd *cobra.Command, _ []string) error {
if showAvailable {
return runCoreList(cmd, "No repos available to mirror.", availableMirrorColumns, availableMirrorRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.AvailableMirror, error) {
12 unmodified lines
if err != nil {
return nil, err
}
return out.Available, nil
avail := filterByRepo(out.Available, func(m coreapi.AvailableMirror) string { return m.Owner + "/" + m.Repo }, repo)
if err := sortAvailable(avail, sortSpec); err != nil {
return nil, err
}
return avail, nil
})
}
return runCoreList(cmd, "No mirrors found.", mirrorColumns, mirrorRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.Mirror, error) {
10 unmodified lines
if !jsonRequested(cmd) {
fmt.Fprintf(cmd.ErrOrStderr(), "Listing mirrors on %s\n", c.CoreOrigin())
}
return fetchAllPages(ctx, func(ctx context.Context, cursor string) ([]coreapi.Mirror, string, error) {
mirrors, err := fetchAllPages(ctx, func(ctx context.Context, cursor string) ([]coreapi.Mirror, string, error) {
params := coreapi.ListMirrorsParams{}
if cluster != "" {
params.Cluster = coreapi.NewOptString(cluster)
13 unmodified lines
}
return out.Mirrors, out.NextPageToken.Or(""), nil
})
if err != nil {
return nil, err
}
mirrors = filterByRepo(mirrors, func(m coreapi.Mirror) string { return m.Owner + "/" + m.Repo }, repo)
if err := sortMirrors(mirrors, sortSpec); err != nil {
return nil, err
}
return mirrors, nil
})
},
}
cmd.Flags().StringVar(&cluster, "cluster", "", "Filter by cluster public host")
cmd.Flags().StringVar(&provider, "provider", "", "Filter by upstream provider (e.g. github)")
cmd.Flags().StringVar(&owner, "owner", "", "Filter by upstream owner login")
cmd.Flags().StringVar(&repo, "repo", "", "Filter by owner/repo substring, matching the REPO column (case-insensitive)")
cmd.Flags().StringVar(&sortSpec, "sort", "", "Sort by column (header name; prefix '-' for descending). Default: repo name ascending")
cmd.Flags().BoolVar(&showAvailable, "show-available", false, "Instead of existing mirrors, list GitHub repos you could onboard as mirrors (ignores --cluster/--provider)")
return cmd
}
Mcmd/entire/cli/repo_mirror.go+175/-14
377 unmodified lines
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
385
386
387
388
389
390
391
401
402
403
404
405
406
82 unmodified lines
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
432 unmodified lines
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
377 unmodified lines
return recCh
}
// execMirrorList runs `list` under a parent that carries the control-plane
// persistent flags (--json lives there, not on the list command itself), so
// tests can exercise --json and the client-side --repo/--sort together.
func execMirrorList(t *testing.T, args ...string) (stdout, stderr string, err error) {
t.Helper()
parent := &cobra.Command{Use: "mirror"}
addControlPlaneFlags(parent)
parent.AddCommand(newRepoMirrorListCmd())
var out, errOut bytes.Buffer
parent.SetOut(&out)
parent.SetErr(&errOut)
parent.SetArgs(append([]string{"list"}, args...))
err = parent.ExecuteContext(t.Context())
return out.String(), errOut.String(), err
}
// runMirrorList executes `repo mirror list` with args against the fake server,
// returning stdout (the table/JSON) and stderr (the routing banner).
func runMirrorList(t *testing.T, args ...string) (stdout, stderr string) {
t.Helper()
cmd := newRepoMirrorListCmd()
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(args)
require.NoError(t, cmd.ExecuteContext(t.Context()))
return out.String(), errOut.String()
stdout, stderr, err := execMirrorList(t, args...)
require.NoError(t, err)
return stdout, stderr
}
// TestRepoMirrorList_ShowAvailableRouting locks in the flag-driven branch of
82 unmodified lines
})
}
// runMirrorListErr is runMirrorList for the error paths (bad --sort column): it
// returns the command error instead of asserting success.
func runMirrorListErr(t *testing.T, args ...string) error {
t.Helper()
_, _, err := execMirrorList(t, args...)
return err
}
// requireOrder asserts each needle appears in s, in the given order. It guards
// presence first: strings.Index returns -1 for an absent needle, so a bare
// index comparison would pass when the earlier needle is missing entirely
// (-1 < anyPresentIndex). This fails loudly instead.
func requireOrder(t *testing.T, s string, needles ...string) {
t.Helper()
prev := -1
for _, n := range needles {
i := strings.Index(s, n)
require.GreaterOrEqualf(t, i, 0, "expected %q in output", n)
require.Greaterf(t, i, prev, "expected %q to come after the previous item", n)
prev = i
}
}
// TestRepoMirrorList_FilterSort pins the client-side --repo filter and --sort
// applied to `repo mirror list` before rendering (server handles
// owner/provider/cluster), so they shape both the table and --json output and
// work under --show-available.
//
// Not parallel: swaps the package-level activeCoreClient seam.
func TestRepoMirrorList_FilterSort(t *testing.T) {
mirrors := []coreapi.Mirror{
{Owner: "acme", Repo: "web", ClusterHost: "aws-us-east-2.entire.io"},
{Owner: "acme", Repo: "cli", ClusterHost: "aws-us-east-2.entire.io"},
{Owner: "other", Repo: "api", ClusterHost: "eu-west-1.entire.io"},
}
t.Run("--repo narrows the table by repo-name substring", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--repo", "cli")
require.Contains(t, stdout, "acme/cli")
require.NotContains(t, stdout, "acme/web")
require.NotContains(t, stdout, "other/api")
})
t.Run("--repo matches the owner/repo form shown in the REPO column", func(t *testing.T) {
// A value copied straight from the displayed REPO column must match the
// row it came from; filtering on the bare repo name would drop it.
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--repo", "acme/web")
require.Contains(t, stdout, "acme/web")
require.NotContains(t, stdout, "acme/cli")
require.NotContains(t, stdout, "other/api")
})
t.Run("default output is owner/repo sorted", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t)
// acme/cli < acme/web < other/api by owner/repo
requireOrder(t, stdout, "acme/cli", "acme/web", "other/api")
})
t.Run("--sort -repo reverses the order", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--sort", "-repo")
requireOrder(t, stdout, "other/api", "acme/web", "acme/cli")
})
t.Run("--repo applies to --json and keeps [] not null", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--repo", "cli", "--json")
require.Contains(t, stdout, `"repo": "cli"`)
require.NotContains(t, stdout, `"repo": "web"`)
serveMirrorList(t, mirrors, nil)
stdout, _ = runMirrorList(t, "--repo", "zzz", "--json")
require.Contains(t, stdout, "[]")
require.NotContains(t, stdout, "null")
})
t.Run("unknown --sort column errors naming valid columns", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
err := runMirrorListErr(t, "--sort", "nope")
require.Error(t, err)
require.Contains(t, err.Error(), "unknown sort column")
})
t.Run("default order breaks duplicate-repo ties by cluster ascending", func(t *testing.T) {
// Same repo on two clusters, delivered eu-first; the default sort must
// deterministically place aws before eu.
dupes := []coreapi.Mirror{
{Owner: "acme", Repo: "web", ClusterHost: "eu-west-1.entire.io"},
{Owner: "acme", Repo: "web", ClusterHost: "aws-us-east-2.entire.io"},
}
serveMirrorList(t, dupes, nil)
stdout, _ := runMirrorList(t)
requireOrder(t, stdout,
"entire://aws-us-east-2.entire.io/gh/acme/web",
"entire://eu-west-1.entire.io/gh/acme/web",
)
})
t.Run("explicit --sort repo keeps the cluster tiebreak (matches default)", func(t *testing.T) {
// A repo on two clusters plus a lexically-earlier repo. Explicit
// `--sort repo` must order like the default: owner/repo ascending, and
// within the duplicate tie, cluster ascending (aws before eu). Guards
// against `--sort repo` regressing to a plain single-key sort that would
// drop the tiebreak.
dupes := []coreapi.Mirror{
{Owner: "acme", Repo: "web", ClusterHost: "eu-west-1.entire.io"},
{Owner: "acme", Repo: "web", ClusterHost: "aws-us-east-2.entire.io"},
{Owner: "acme", Repo: "api", ClusterHost: "aws-us-east-2.entire.io"},
}
serveMirrorList(t, dupes, nil)
stdout, _ := runMirrorList(t, "--sort", "repo")
// acme/api before acme/web, and within the acme/web tie aws before eu.
requireOrder(t, stdout,
"entire://aws-us-east-2.entire.io/gh/acme/api",
"entire://aws-us-east-2.entire.io/gh/acme/web",
"entire://eu-west-1.entire.io/gh/acme/web",
)
// -repo reverses the whole ordering, tiebreak included.
serveMirrorList(t, dupes, nil)
stdout, _ = runMirrorList(t, "--sort", "-repo")
requireOrder(t, stdout,
"entire://eu-west-1.entire.io/gh/acme/web",
"entire://aws-us-east-2.entire.io/gh/acme/web",
)
})
t.Run("--repo/--sort apply under --show-available", func(t *testing.T) {
// --repo cli keeps two rows (so --sort is observable) and drops the
// third, so the filter and the sort are both exercised: `access` orders
// read before write, i.e. cli-web before cli-api.
serveMirrorList(t, nil, []coreapi.AvailableMirror{
{Owner: "acme", Repo: "cli-api", Access: "write", Status: "available"},
{Owner: "acme", Repo: "cli-web", Access: "read", Status: "available"},
{Owner: "other", Repo: "srv", Access: "read", Status: "available"},
})
stdout, _ := runMirrorList(t, "--show-available", "--repo", "cli", "--sort", "access")
require.NotContains(t, stdout, "other/srv", "--repo cli must drop the non-matching row")
requireOrder(t, stdout, "acme/cli-web", "acme/cli-api")
})
t.Run("--sort private breaks ties deterministically by owner/repo then cluster", func(t *testing.T) {
// A non-repo column sort: all rows share the same private value, so the
// order must fall back to the owner/repo + cluster tiebreak rather than
// the eu-first order the server delivered.
dupes := []coreapi.Mirror{
{Owner: "acme", Repo: "web", ClusterHost: "eu-west-1.entire.io"},
{Owner: "acme", Repo: "web", ClusterHost: "aws-us-east-2.entire.io"},
{Owner: "acme", Repo: "api", ClusterHost: "aws-us-east-2.entire.io"},
}
serveMirrorList(t, dupes, nil)
stdout, _ := runMirrorList(t, "--sort", "private")
// All rows share the private value, so acme/api sorts before acme/web,
// and within the acme/web tie aws before eu.
requireOrder(t, stdout,
"entire://aws-us-east-2.entire.io/gh/acme/api",
"entire://aws-us-east-2.entire.io/gh/acme/web",
"entire://eu-west-1.entire.io/gh/acme/web",
)
})
t.Run("--sort with leading whitespace parses direction like the trimmed spec", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--sort", " -repo")
requireOrder(t, stdout, "other/api", "acme/web", "acme/cli")
})
}
// TestParseGitHubURL is ported from entiredb's cmd/entire-repo/cli
// mirror_test.go, since parseGitHubURL was carried over verbatim.
func TestParseGitHubURL(t *testing.T) {
432 unmodified lines
require.Empty(t, out.String())
})
}
// mirrorRepoHosts renders each mirror as "owner/repo@clusterHost" so a sorted
// slice's order (including the cluster tiebreak) is asserted in one line.
func mirrorRepoHosts(mirrors []coreapi.Mirror) []string {
out := make([]string, len(mirrors))
for i, m := range mirrors {
out[i] = m.Owner + "/" + m.Repo + "@" + m.ClusterHost
}
return out
}
func TestSortMirrors(t *testing.T) {
t.Parallel()
// One repo mirrored on two clusters (delivered eu-first) plus a
// lexically-earlier repo, so both the primary key and the cluster tiebreak
// are observable.
base := func() []coreapi.Mirror {
return []coreapi.Mirror{
{Owner: "acme", Repo: "web", ClusterHost: "eu-west-1.entire.io", IsPrivate: coreapi.NewOptBool(true)},
{Owner: "acme", Repo: "web", ClusterHost: "aws-us-east-2.entire.io", IsPrivate: coreapi.NewOptBool(false)},
{Owner: "acme", Repo: "api", ClusterHost: "aws-us-east-2.entire.io", IsPrivate: coreapi.NewOptBool(false)},
}
}
t.Run("default sorts owner/repo then cluster ascending", func(t *testing.T) {
t.Parallel()
m := base()
require.NoError(t, sortMirrors(m, ""))
require.Equal(t, []string{
"acme/api@aws-us-east-2.entire.io",
"acme/web@aws-us-east-2.entire.io",
"acme/web@eu-west-1.entire.io",
}, mirrorRepoHosts(m))
})
t.Run("-repo reverses the whole ordering, tiebreak included", func(t *testing.T) {
t.Parallel()
m := base()
require.NoError(t, sortMirrors(m, "-repo"))
require.Equal(t, []string{
"acme/web@eu-west-1.entire.io",
"acme/web@aws-us-east-2.entire.io",
"acme/api@aws-us-east-2.entire.io",
}, mirrorRepoHosts(m))
})
t.Run("non-repo column sorts keep the owner/repo+cluster tiebreak", func(t *testing.T) {
t.Parallel()
// All three sort keys collide on "private" once acme/api and the aws web
// mirror are both public; the deterministic order must fall back to
// owner/repo then cluster, not arbitrary input order.
m := base()
require.NoError(t, sortMirrors(m, "private"))
require.Equal(t, []string{
// "no" (public) group first, ordered by owner/repo then cluster.
"acme/api@aws-us-east-2.entire.io",
"acme/web@aws-us-east-2.entire.io",
// "yes" (private) group last.
"acme/web@eu-west-1.entire.io",
}, mirrorRepoHosts(m))
})
t.Run("whitespace spec parses direction from the trimmed spec", func(t *testing.T) {
t.Parallel()
m := base()
require.NoError(t, sortMirrors(m, " -repo"))
require.Equal(t, []string{
"acme/web@eu-west-1.entire.io",
"acme/web@aws-us-east-2.entire.io",
"acme/api@aws-us-east-2.entire.io",
}, mirrorRepoHosts(m))
})
t.Run("unknown column errors naming valid columns", func(t *testing.T) {
t.Parallel()
err := sortMirrors(base(), "nope")
require.Error(t, err)
require.Contains(t, err.Error(), "unknown sort column")
require.Contains(t, err.Error(), "repo")
})
}
func TestSortAvailable(t *testing.T) {
t.Parallel()
base := func() []coreapi.AvailableMirror {
return []coreapi.AvailableMirror{
{Owner: "acme", Repo: "web", Access: "write", Status: "available"},
{Owner: "acme", Repo: "api", Access: "read", Status: "available"},
{Owner: "acme", Repo: "cli", Access: "read", Status: "available"},
}
}
repos := func(avail []coreapi.AvailableMirror) []string {
out := make([]string, len(avail))
for i, m := range avail {
out[i] = m.Owner + "/" + m.Repo
}
return out
}
t.Run("sorts by access with an owner/repo tiebreak on equal keys", func(t *testing.T) {
t.Parallel()
a := base()
require.NoError(t, sortAvailable(a, "access"))
// "read" < "write"; within read, acme/api < acme/cli by owner/repo.
require.Equal(t, []string{"acme/api", "acme/cli", "acme/web"}, repos(a))
})
t.Run("whitespace spec parses direction from the trimmed spec", func(t *testing.T) {
t.Parallel()
a := base()
require.NoError(t, sortAvailable(a, " -repo"))
require.Equal(t, []string{"acme/web", "acme/cli", "acme/api"}, repos(a))
})
t.Run("unknown column errors naming valid columns", func(t *testing.T) {
t.Parallel()
err := sortAvailable(base(), "nope")
require.Error(t, err)
require.Contains(t, err.Error(), "unknown sort column")
require.Contains(t, err.Error(), "access")
})
}
Mcmd/entire/cli/repo_mirror_test.go+314/-7
5 unmodified lines
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
26 unmodified lines
60
61
62
56
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
92 unmodified lines
233
234
235
155
236
237
238
239
240
241
242
32 unmodified lines
275
276
277
196
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
3 unmodified lines
301
302
303
206
304
305
306
307
308
309
310
211
311
312
313
314
50 unmodified lines
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
5 unmodified lines
"fmt"
"io"
"os"
"sort"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/cmd/entire/cli/auth"
"github.com/entireio/cli/cmd/entire/cli/codesearch"
"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/search"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/internal/coreapi"
"github.com/spf13/cobra"
)
func newSearchCmd() *cobra.Command { //nolint:maintidx // command wiring is inherently complex
var (
jsonOutput bool
codeFlag bool
caseSensitive bool
limitFlag int
pageFlag int
authorFlag string
26 unmodified lines
ctx := cmd.Context()
query := strings.Join(args, " ")
// Extract inline filters (author:, date:, branch:, repo:) from query args
if caseSensitive && !codeFlag {
return errors.New("--case-sensitive can only be used with --code")
}
if codeFlag {
// Reject flags that only apply to checkpoint search.
for _, pair := range []struct{ flag, name string }{
{authorFlag, "--author"},
{dateFlag, "--date"},
{branchFlag, "--branch"},
} {
if pair.flag != "" {
return fmt.Errorf("%s cannot be used with --code", pair.name)
}
}
if cmd.Flags().Changed("page") {
return errors.New("--page cannot be used with --code")
}
// For code search, only extract repo: inline filters from
// the query. Other checkpoint filters (author:, date:,
// branch:) are not supported and must be preserved as
// literal search text so "author:foo" searches for that
// string in code rather than being silently consumed.
codeQuery, inlineRepos := extractInlineRepoFilters(query)
var codeRepos []string
if repoFlag != "" {
codeRepos = []string{repoFlag}
}
codeRepos = append(codeRepos, inlineRepos...)
// repo:* or --all-repos means "all repos" — no filter.
// Otherwise, if no explicit filter was given, scope to the
// current repo (matching the checkpoint-search default).
hasAllRepos := allReposFlag
for _, r := range codeRepos {
if r == search.AllReposFilter {
hasAllRepos = true
}
}
if hasAllRepos {
codeRepos = nil
} else {
// Remove any stray "*" entries.
filtered := codeRepos[:0]
for _, r := range codeRepos {
if r != search.AllReposFilter {
filtered = append(filtered, r)
}
}
codeRepos = filtered
// No explicit repo filter → derive from git origin remote.
// Use forge-prefixed slug so et/ forge repos match the index.
if len(codeRepos) == 0 {
slug := currentRepoSlugWithForge(ctx)
if slug == "" {
return errors.New("could not determine current repository for code search (use --repo or --all-repos)")
}
codeRepos = []string{slug}
}
}
return runCodeSearch(ctx, cmd, codeSearchOpts{
query: codeQuery,
repoFilters: codeRepos,
limit: limitFlag,
caseSensitive: caseSensitive,
jsonOutput: jsonOutput,
insecureHTTP: insecureHTTPAuth,
})
}
// Extract inline filters (author:, date:, branch:, repo:) from query args.
// Keep the raw query for code search (which preserves author:/date:/branch:
// as literal text via extractInlineRepoFilters).
rawQuery := query
parsed := search.ParseSearchInput(query)
query = parsed.Query
if authorFlag == "" {
92 unmodified lines
if query == "" && !searchCfg.HasFilters() {
searchCfg.Limit = search.DefaultLimit
styles := newStatusStyles(w)
model := newSearchModel(nil, "", 0, searchCfg, styles)
model := newSearchModel(nil, "", 0, searchCfg, styles, buildCodeSearchOpts(ctx, owner, repoName, nil, false, insecureHTTPAuth))
model.mode = modeSearch
model.input.Focus()
model.codeLoading = false // don't fetch until a query is entered
p := tea.NewProgram(model)
if _, err := p.Run(); err != nil {
return fmt.Errorf("TUI error: %w", err)
32 unmodified lines
}
// Interactive TUI
model := newSearchModel(resp.Results, query, resp.Total, searchCfg, styles)
codeOpts := buildCodeSearchOpts(ctx, owner, repoName, repos, allRepos, insecureHTTPAuth)
if codeOpts != nil {
// Use extractInlineRepoFilters on the raw query so author:/date:/branch:
// tokens are preserved as literal code-search text, matching --code and
// the TUI submit path. Inline repo: filters override the flag-based
// scope, consistent with TUI re-search behavior.
codeQuery, inlineRepos := extractInlineRepoFilters(rawQuery)
codeOpts.query = codeQuery // empty → no initial code search (gated in newSearchModel)
if len(inlineRepos) > 0 {
if hasAllReposFilter(inlineRepos) {
codeOpts.repoFilters = nil
} else {
codeOpts.repoFilters = inlineRepos
}
}
}
model := newSearchModel(resp.Results, query, resp.Total, searchCfg, styles, codeOpts)
p := tea.NewProgram(model)
if _, err := p.Run(); err != nil {
return fmt.Errorf("TUI error: %w", err)
3 unmodified lines
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")
cmd.Flags().IntVar(&limitFlag, "limit", resultsPerPage, "Maximum number of results per page")
cmd.Flags().BoolVar(&codeFlag, "code", false, "Search code content across repositories")
cmd.Flags().BoolVar(&caseSensitive, "case-sensitive", false, "Case-sensitive code search (only with --code)")
cmd.Flags().IntVar(&limitFlag, "limit", resultsPerPage, "Maximum number of results (per page for checkpoint search, total for --code)")
cmd.Flags().IntVar(&pageFlag, "page", 1, "Page number (1-based)")
cmd.Flags().StringVar(&authorFlag, "author", "", "Filter by author name")
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().StringVar(&repoFlag, "repo", "", "Filter by repository (gh/owner/repo, et/proj/repo, owner/repo, ULID, or *)")
cmd.Flags().BoolVar(&allReposFlag, "all-repos", false, "Search all accessible repos instead of just the current one")
addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth)
50 unmodified lines
return suggestions, cobra.ShellCompDirectiveNoFileComp
}
// codeSearchEnabled reports whether the code search feature is gated on.
func codeSearchEnabled() bool {
return os.Getenv("ENTIRE_CODE_SEARCH") == "1"
}
type codeSearchOpts struct {
query string
repoFilters []string
resolvedRepoIDs []string // ULIDs resolved from repoFilters via repo index
limit int
caseSensitive bool
jsonOutput bool
insecureHTTP bool
}
// extractInlineRepoFilters extracts only repo: prefixed filters from a query
// string, returning the remaining query text and the list of repo values.
// Unlike search.ParseSearchInput, this does NOT consume author:, date:, or
// branch: tokens — those are checkpoint-search-only and should be treated as
// literal text in code search queries.
func extractInlineRepoFilters(query string) (remaining string, repos []string) {
var kept []string
for _, part := range strings.Fields(query) {
if strings.HasPrefix(part, "repo:") {
// Split comma-separated values (repo:a,b → [a, b]), matching
// checkpoint search's parseListFilter behavior. Trim quotes so
// repo:"gh/owner/repo" works like the unquoted form.
for _, v := range strings.Split(part[5:], ",") {
v = strings.Trim(v, `"'`)
if v != "" {
repos = append(repos, v)
}
}
} else {
kept = append(kept, part)
}
}
return strings.Join(kept, " "), repos
}
// hasAllReposFilter returns true if repos contains the wildcard "*" filter.
func hasAllReposFilter(repos []string) bool {
for _, r := range repos {
if r == search.AllReposFilter {
return true
}
}
return false
}
// filterRepoWildcards returns repos with AllReposFilter entries removed.
func filterRepoWildcards(repos []string) []string {
var out []string
for _, r := range repos {
if r != search.AllReposFilter {
out = append(out, r)
}
}
return out
}
// buildCodeSearchOpts returns a *codeSearchOpts pre-populated with repo filters
// when ENTIRE_CODE_SEARCH=1 is set, or nil when the feature is off. It honors
// --repo, --all-repos, and inline repo: filters from the command line; when none
// are specified, it falls back to the current git origin slug.
func buildCodeSearchOpts(ctx context.Context, owner, repoName string, repos []string, allRepos, insecureHTTP bool) *codeSearchOpts {
if !codeSearchEnabled() {
return nil
}
var repoFilters []string
switch {
case allRepos:
// nil repoFilters → searchAllCells searches all repos
case len(repos) > 0:
repoFilters = repos
default:
// Use forge-prefixed slug (e.g. "et/proj/repo") so Entire forge
// repos match the index FullName. Falls back to owner/repo for
// GitHub repos (gh/ prefix is stripped by resolveRepoFilters).
if slug := currentRepoSlugWithForge(ctx); slug != "" {
repoFilters = []string{slug}
} else {
repoFilters = []string{owner + "/" + repoName}
}
}
return &codeSearchOpts{
repoFilters: repoFilters,
limit: search.DefaultLimit,
insecureHTTP: insecureHTTP,
}
}
// codeSearchCellTimeout bounds each per-cell search call (token exchange + API).
const codeSearchCellTimeout = 30 * time.Second
// runCodeSearch handles the --code flag path: search code content via peregrine.
//
// When a repo filter is specified, it routes to that repo's owning cell.
// Without a filter, it fans out across all cells that host the user's repos
// (mirroring the BFF's /api/v1/stream endpoint): list repos from the control
// plane, group by cell/jurisdiction, search each cell in parallel, merge.
func runCodeSearch(ctx context.Context, cmd *cobra.Command, opts codeSearchOpts) error {
if !codeSearchEnabled() {
return errors.New("code search is not yet available")
}
if opts.query == "" {
return errors.New("query required for code search. Usage: entire search --code <query>")
}
w := cmd.OutOrStdout()
// Always fan out via searchAllCells — it fetches the repo index,
// resolves slugs to ULIDs, and handles single- vs multi-jurisdiction.
resp, err := searchAllCells(ctx, opts)
if err != nil {
return err
}
isTerminal := interactive.IsTerminalWriter(w)
if opts.jsonOutput || !isTerminal {
return writeCodeSearchJSON(w, resp)
}
writeCodeSearchText(w, resp)
return nil
}
// searchAllCells fans out code search across all cells that host the user's
// repos, using the shared cell-routing foundation (cell_fanout.go):
// 1. List repos from the control plane (entire-core) to discover cells
// 2. Resolve repo slug filters to ULIDs
// 3. Group by cell and resolve baseURLs via the shared helpers
// 4. Fan out via fanOutCells with per-cell codesearch.Search calls
// 5. Merge results (sorted by score, capped to limit)
func searchAllCells(ctx context.Context, opts codeSearchOpts) (*codesearch.SearchResponse, error) {
// Step 1: Get repos index from the control plane.
// coreapi.Client satisfies cellCoreClient (for resolveCellBaseURLs)
// and also provides ListRepos (which cellCoreClient doesn't expose).
coreClient, err := coreapi.New()
if err != nil {
if errors.Is(err, auth.ErrNotLoggedIn) {
return nil, errors.New("not authenticated. Run 'entire login' to authenticate")
}
return nil, fmt.Errorf("resolving control-plane client: %w", err)
}
reposCtx, reposCancel := context.WithTimeout(ctx, 10*time.Second)
defer reposCancel()
repoIndex, err := coreClient.ListRepos(reposCtx)
if err != nil {
return nil, fmt.Errorf("listing repos for cell discovery: %w", err)
}
if repoIndex.Truncated {
logging.Warn(ctx, "repo index truncated; code search results may be incomplete")
}
// Step 2: Resolve repo slug filters to ULIDs and narrow to matching cells.
indexRepos := repoIndex.Repos
if len(opts.repoFilters) > 0 {
resolved, filtered := resolveRepoFilters(opts.repoFilters, repoIndex.Repos)
if len(resolved) == 0 {
hint := ""
if repoIndex.Truncated {
hint = " (repo index was truncated — the repo may exist but was not included)"
}
return nil, fmt.Errorf("no matching repositories found for filter %q%s", opts.repoFilters, hint)
}
opts.resolvedRepoIDs = resolved
indexRepos = filtered
}
// Step 3: Group repos by cell and resolve baseURLs via shared helpers.
cells := groupReposByCell(indexRepos)
if len(cells) == 0 {
return &codesearch.SearchResponse{}, nil
}
resolveCellBaseURLs(ctx, coreClient, cells)
// Step 4: Fan out via the shared fanOutCells helper.
// Each cell gets the full limit for single-cell, or 2x for multi-cell so
// the merge sees enough candidates from every region for proper global
// ranking. mergeSearchResults applies the final cap.
perCellLimit := opts.limit
if len(cells) > 1 && perCellLimit > 0 {
perCellLimit *= 2
}
results, err := fanOutCells(ctx, opts.insecureHTTP, codeSearchCellTimeout, cells, func(ctx context.Context, group cellGroup, client *api.Client) (*codesearch.SearchResponse, error) {
var repoIDs []string
if len(opts.resolvedRepoIDs) > 0 {
repoIDs = group.repoIDs
}
req := codesearch.SearchRequest{
Query: opts.query,
Repos: repoIDs,
CaseSensitive: opts.caseSensitive,
}
if perCellLimit > 0 {
req.MaxResults = perCellLimit
}
return codesearch.Search(ctx, client, req)
})
if err != nil {
if errors.Is(err, auth.ErrNotLoggedIn) {
return nil, errors.New("not authenticated. Run 'entire login' to authenticate")
}
return nil, fmt.Errorf("code search: %w", err)
}
return mergeSearchResults(ctx, opts.limit, results)
}
// resolveRepoFilters matches user-provided filters against the repo index,
// returning the ULID list for peregrine and the subset of index entries whose
// repos matched (for cell grouping).
//
// Matching mirrors the BFF (code-search.ts lines 315-319):
//
// slug = filter starts with "gh/" ? strip prefix : filter unchanged
// match = id === filter || full_name === slug || full_name === filter
//
// Accepted filter formats:
// - ULID — matched directly on repo ID (raw filter)
// - gh/owner/repo — GitHub repo, stripped to owner/repo for FullName match
// - owner/repo — bare slug, matched on FullName directly
func resolveRepoFilters(filters []string, repos []coreapi.RepoIndexEntry) (repoIDs []string, matched []coreapi.RepoIndexEntry) {
byName := make(map[string]coreapi.RepoIndexEntry, len(repos))
byID := make(map[string]coreapi.RepoIndexEntry, len(repos))
for _, r := range repos {
byName[strings.ToLower(r.FullName)] = r
byID[r.ID] = r
}
seen := make(map[string]bool) // dedup by ID
for _, f := range filters {
// BFF only strips gh/ prefix; other prefixes are left as-is.
slug := f
if strings.HasPrefix(f, "gh/") {
slug = f[3:]
}
// Match order mirrors the BFF: id === filter || full_name === slug || full_name === filter
// FullName comparison is case-insensitive so casing differences between
// the git remote (e.g. entireio/CLI) and the repo index (entireio/cli)
// don't cause a "no matching repositories found" failure.
var r coreapi.RepoIndexEntry
var ok bool
if r, ok = byID[f]; !ok {
if r, ok = byName[strings.ToLower(slug)]; !ok {
r, ok = byName[strings.ToLower(f)]
}
}
if ok && !seen[r.ID] {
repoIDs = append(repoIDs, r.ID)
matched = append(matched, r)
seen[r.ID] = true
}
}
return repoIDs, matched
}
// mergeSearchResults merges responses from multiple cells into one, combining
// results, stats, and repo_stats. Results are sorted by Score (descending) for
// global relevance ranking and truncated to limit. Individual cell errors are
// logged and skipped, but if ALL cells fail the error is surfaced.
func mergeSearchResults(ctx context.Context, limit int, results []cellCallResult[*codesearch.SearchResponse]) (*codesearch.SearchResponse, error) {
merged := &codesearch.SearchResponse{}
var lastErr error
successCount := 0
for _, r := range results {
if r.err != nil {
lastErr = r.err
continue
}
if r.value == nil {
continue
}
successCount++
merged.Results = append(merged.Results, r.value.Results...)
merged.RepoStats = append(merged.RepoStats, r.value.RepoStats...)
merged.Stats.TotalMatches += r.value.Stats.TotalMatches
merged.Stats.TotalFiles += r.value.Stats.TotalFiles
merged.Stats.ReposSearched += r.value.Stats.ReposSearched
if r.value.Stats.DurationMs > merged.Stats.DurationMs {
merged.Stats.DurationMs = r.value.Stats.DurationMs // wall-clock = slowest cell
}
if merged.Query == "" {
merged.Query = r.value.Query
}
}
if successCount == 0 && lastErr != nil {
return nil, fmt.Errorf("code search failed: %w", lastErr)
}
// Track partial failures so consumers (especially --json) can see them.
var failedJurisdictions []string
for _, r := range results {
if r.err == nil {
continue
}
failedJurisdictions = append(failedJurisdictions, r.group.label())
}
if len(failedJurisdictions) > 0 {
logging.Warn(ctx, "code search partial failure; results may be incomplete",
"succeeded", successCount,
"total", len(results),
"failed_cells", failedJurisdictions)
}
// Sort by score descending so results are globally ranked by relevance,
// not grouped by whichever cell returned first. Stable sort with a
// tiebreaker keeps --json output deterministic across runs.
sort.SliceStable(merged.Results, func(i, j int) bool {
a, b := merged.Results[i], merged.Results[j]
if a.Score != b.Score {
return a.Score > b.Score
}
if a.Repo != b.Repo {
return a.Repo < b.Repo
}
if a.Path != b.Path {
return a.Path < b.Path
}
return a.Line < b.Line
})
// Deduplicate results that may appear from overlapping cells (e.g. a repo
// with empty jurisdiction searched via both home and explicit cell).
seen := make(map[string]bool, len(merged.Results))
deduped := merged.Results[:0]
for _, r := range merged.Results {
key := r.Repo + "\x00" + r.Path + "\x00" + fmt.Sprintf("%d:%d", r.Line, r.Column)
if seen[key] {
continue
}
seen[key] = true
deduped = append(deduped, r)
}
merged.Results = deduped
// Deduplicate RepoStats by repo name, summing match/file counts.
repoStatsMap := make(map[string]*codesearch.RepoStats, len(merged.RepoStats))
var dedupedStats []codesearch.RepoStats
for _, rs := range merged.RepoStats {
if existing, ok := repoStatsMap[rs.Repo]; ok {
existing.MatchCount += rs.MatchCount
existing.FileCount += rs.FileCount
} else {
entry := rs // copy
repoStatsMap[rs.Repo] = &entry
dedupedStats = append(dedupedStats, entry)
}
}
// Write back merged values.
for i := range dedupedStats {
if m, ok := repoStatsMap[dedupedStats[i].Repo]; ok {
dedupedStats[i] = *m
}
}
merged.RepoStats = dedupedStats
// Stats are preserved as the sum of per-cell peregrine stats — they
// reflect the true totals (including zero-match repos and per-cell
// truncation), not just the deduped result slice.
// Cap to the caller's requested limit.
if limit > 0 && len(merged.Results) > limit {
merged.Results = merged.Results[:limit]
}
// Surface partial failures in the response so JSON consumers can detect them.
merged.FailedJurisdictions = failedJurisdictions
return merged, nil
}
// writeCodeSearchJSON writes code search results as JSON.
func writeCodeSearchJSON(w io.Writer, resp *codesearch.SearchResponse) error {
out := struct {
Query string `json:"query"`
Results []codesearch.Result `json:"results"`
Total int `json:"total"`
Stats codesearch.Stats `json:"stats"`
RepoStats []codesearch.RepoStats `json:"repo_stats,omitempty"`
FailedJurisdictions []string `json:"failed_jurisdictions,omitempty"`
}{
Query: resp.Query,
Results: resp.Results,
Total: len(resp.Results),
Stats: resp.Stats,
RepoStats: resp.RepoStats,
FailedJurisdictions: resp.FailedJurisdictions,
}
if out.Results == nil {
out.Results = []codesearch.Result{}
}
data, err := jsonutil.MarshalIndentWithNewline(out, "", " ")
if err != nil {
return fmt.Errorf("marshaling code search results: %w", err)
}
fmt.Fprint(w, string(data))
return nil
}
// maxContextLineLen is the maximum number of characters to display for a
// context_line in grep-style text output. Lines longer than this are truncated
// with an ellipsis so that JSONL/minified files don't blow up the terminal.
const maxContextLineLen = 200
// writeCodeSearchText renders code search results in grep-style format.
func writeCodeSearchText(w io.Writer, resp *codesearch.SearchResponse) {
if len(resp.Results) == 0 {
if len(resp.FailedJurisdictions) > 0 {
fmt.Fprintf(w, "No code search results found (some regions failed: %s)\n",
strings.Join(resp.FailedJurisdictions, ", "))
} else {
fmt.Fprintln(w, "No code search results found.")
}
return
}
for _, r := range resp.Results {
line := r.ContextLine
runes := []rune(line)
if len(runes) > maxContextLineLen {
line = string(runes[:maxContextLineLen]) + "…"
}
fmt.Fprintf(w, "%s:%s:%d: %s\n", r.Repo, r.Path, r.Line, line)
}
shown := len(resp.Results)
if resp.Stats.TotalMatches > shown {
fmt.Fprintf(w, "\nShowing %d of %d matches across %d files in %d repos (%.0fms)\n",
shown, resp.Stats.TotalMatches, resp.Stats.TotalFiles, resp.Stats.ReposSearched, resp.Stats.DurationMs)
} else {
fmt.Fprintf(w, "\n%d matches across %d files in %d repos (%.0fms)\n",
resp.Stats.TotalMatches, resp.Stats.TotalFiles, resp.Stats.ReposSearched, resp.Stats.DurationMs)
}
if len(resp.FailedJurisdictions) > 0 {
fmt.Fprintf(w, "Warning: results may be incomplete (failed jurisdictions: %s)\n",
strings.Join(resp.FailedJurisdictions, ", "))
}
}
// writeSearchJSON writes client-side paginated search results as JSON.
func writeSearchJSON(w io.Writer, resp *search.Response, limit, page int) error {
if limit <= 0 {
Mcmd/entire/cli/search_cmd.go+549/-5
1 unmodified line
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
64 unmodified lines
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
1 unmodified line
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/entireio/cli/cmd/entire/cli/codesearch"
"github.com/entireio/cli/cmd/entire/cli/search"
"github.com/entireio/cli/internal/coreapi"
)
// test constants used across code-search tests.
const (
testRepoID1 = "01ABC"
testRepoID2 = "02DEF"
testCellEU = "aws-eu-west-1"
)
// TestSearchCmd_AccessibleModeRequiresQuery verifies that accessible mode
64 unmodified lines
t.Fatalf("output missing total_pages:\n%s", output)
}
}
func TestCodeSearchEnabled_EnvGate(t *testing.T) {
// Modifies process-global env, no t.Parallel().
for _, tc := range []struct {
val string
want bool
}{
{"", false},
{"0", false},
{"false", false},
{"true", false},
{"1", true},
} {
t.Setenv("ENTIRE_CODE_SEARCH", tc.val)
if got := codeSearchEnabled(); got != tc.want {
t.Errorf("ENTIRE_CODE_SEARCH=%q: codeSearchEnabled() = %v, want %v", tc.val, got, tc.want)
}
}
}
func TestSearchCmd_CodeFlagGated(t *testing.T) {
// --code without ENTIRE_CODE_SEARCH should fail with gate message.
t.Setenv("ENTIRE_CODE_SEARCH", "")
root := NewRootCmd()
root.SetArgs([]string{"search", "--code", "test query"})
err := root.Execute()
if err == nil {
t.Fatal("expected error when --code used without ENTIRE_CODE_SEARCH")
}
if !strings.Contains(err.Error(), "not yet available") {
t.Errorf("error = %q, want containing 'not yet available'", err.Error())
}
if strings.Contains(err.Error(), "ENTIRE_CODE_SEARCH") {
t.Errorf("gate error should not mention env var, got: %q", err.Error())
}
}
func TestSearchCmd_CodeFlagRequiresQuery(t *testing.T) {
t.Setenv("ENTIRE_CODE_SEARCH", "1")
root := NewRootCmd()
root.SetArgs([]string{"search", "--code"})
err := root.Execute()
if err == nil {
t.Fatal("expected error when --code used without query")
}
if !strings.Contains(err.Error(), "query required for code search") {
t.Errorf("error = %q, want containing 'query required'", err.Error())
}
}
func TestSearchCmd_CaseSensitiveWithoutCode(t *testing.T) {
root := NewRootCmd()
root.SetArgs([]string{"search", "--case-sensitive", "--json", "test"})
err := root.Execute()
if err == nil {
t.Fatal("expected error when --case-sensitive used without --code")
}
if !strings.Contains(err.Error(), "--case-sensitive can only be used with --code") {
t.Errorf("error = %q, want containing '--case-sensitive can only be used with --code'", err.Error())
}
}
func TestWriteCodeSearchText(t *testing.T) {
t.Parallel()
resp := &codesearch.SearchResponse{
Stats: codesearch.Stats{TotalMatches: 2, TotalFiles: 1, ReposSearched: 1, DurationMs: 15},
Results: []codesearch.Result{
{Repo: "entireio/cli", Path: "main.go", Line: 10, ContextLine: "func main() {"},
{Repo: "entireio/cli", Path: "main.go", Line: 42, ContextLine: "\tfmt.Println(\"hello\")"},
},
}
var buf bytes.Buffer
writeCodeSearchText(&buf, resp)
output := buf.String()
if !strings.Contains(output, "entireio/cli:main.go:10: func main() {") {
t.Errorf("output missing first result:\n%s", output)
}
if !strings.Contains(output, "2 matches across 1 files") {
t.Errorf("output missing summary line:\n%s", output)
}
}
func TestWriteCodeSearchJSON(t *testing.T) {
t.Parallel()
resp := &codesearch.SearchResponse{
Query: "handleRequest",
Stats: codesearch.Stats{TotalMatches: 1, TotalFiles: 1, ReposSearched: 1, DurationMs: 5},
RepoStats: []codesearch.RepoStats{{Repo: "r", MatchCount: 1, FileCount: 1}},
Results: []codesearch.Result{{Repo: "r", Path: "f.go", Line: 1, ContextLine: "package main"}},
}
var buf bytes.Buffer
if err := writeCodeSearchJSON(&buf, resp); err != nil {
t.Fatalf("writeCodeSearchJSON error: %v", err)
}
output := buf.String()
if !strings.Contains(output, `"query": "handleRequest"`) {
t.Errorf("output missing query echo:\n%s", output)
}
if !strings.Contains(output, `"total": 1`) {
t.Errorf("output missing total:\n%s", output)
}
if !strings.Contains(output, `"path": "f.go"`) {
t.Errorf("output missing result path:\n%s", output)
}
if !strings.Contains(output, `"repo_stats"`) {
t.Errorf("output missing repo_stats:\n%s", output)
}
}
func TestWriteCodeSearchText_TruncatesLongLines(t *testing.T) {
t.Parallel()
longLine := strings.Repeat("x", 300)
resp := &codesearch.SearchResponse{
Stats: codesearch.Stats{TotalMatches: 1, TotalFiles: 1, ReposSearched: 1, DurationMs: 1},
Results: []codesearch.Result{{Repo: "r", Path: "f.go", Line: 1, ContextLine: longLine}},
}
var buf bytes.Buffer
writeCodeSearchText(&buf, resp)
output := buf.String()
if strings.Contains(output, longLine) {
t.Error("expected long context_line to be truncated")
}
if !strings.Contains(output, "…") {
t.Error("expected truncated line to end with ellipsis")
}
// The prefix + 200 chars + ellipsis should be present.
truncated := strings.Repeat("x", maxContextLineLen)
if !strings.Contains(output, truncated+"…") {
t.Error("expected exactly maxContextLineLen characters before ellipsis")
}
}
func TestWriteCodeSearchText_Empty(t *testing.T) {
t.Parallel()
resp := &codesearch.SearchResponse{
Stats: codesearch.Stats{},
}
var buf bytes.Buffer
writeCodeSearchText(&buf, resp)
if !strings.Contains(buf.String(), "No code search results found") {
t.Errorf("expected empty results message, got:\n%s", buf.String())
}
}
func TestMergeSearchResults(t *testing.T) {
t.Parallel()
results := []cellCallResult[*codesearch.SearchResponse]{
{
group: cellGroup{cell: "aws-us-east-2", jurisdiction: "us"},
value: &codesearch.SearchResponse{
Query: "handleRequest",
Stats: codesearch.Stats{TotalMatches: 3, TotalFiles: 2, ReposSearched: 1, DurationMs: 10},
Results: []codesearch.Result{
{Repo: "acme/web", Path: "main.go", Line: 1, Score: 0.5},
},
RepoStats: []codesearch.RepoStats{{Repo: "acme/web", MatchCount: 3}},
},
},
{
group: cellGroup{cell: testCellEU, jurisdiction: "eu"},
value: &codesearch.SearchResponse{
Query: "handleRequest",
Stats: codesearch.Stats{TotalMatches: 1, TotalFiles: 1, ReposSearched: 1, DurationMs: 20},
Results: []codesearch.Result{
{Repo: "acme/docs", Path: "handler.go", Line: 5, Score: 0.9},
},
RepoStats: []codesearch.RepoStats{{Repo: "acme/docs", MatchCount: 1}},
},
},
}
merged, err := mergeSearchResults(context.Background(), 0, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged.Stats.TotalMatches != 4 {
t.Errorf("TotalMatches = %d, want 4 (summed from cells)", merged.Stats.TotalMatches)
}
if merged.Stats.TotalFiles != 3 {
t.Errorf("TotalFiles = %d, want 3 (summed from cells)", merged.Stats.TotalFiles)
}
if merged.Stats.ReposSearched != 2 {
t.Errorf("ReposSearched = %d, want 2", merged.Stats.ReposSearched)
}
if merged.Stats.DurationMs != 20 {
t.Errorf("DurationMs = %v, want 20 (slowest cell)", merged.Stats.DurationMs)
}
if len(merged.Results) != 2 {
t.Fatalf("len(Results) = %d, want 2", len(merged.Results))
}
if merged.Results[0].Repo != "acme/docs" {
t.Errorf("Results[0].Repo = %q, want acme/docs (higher score)", merged.Results[0].Repo)
}
if len(merged.RepoStats) != 2 {
t.Fatalf("len(RepoStats) = %d, want 2", len(merged.RepoStats))
}
}
func TestMergeSearchResults_Truncation(t *testing.T) {
t.Parallel()
results := []cellCallResult[*codesearch.SearchResponse]{
{
group: cellGroup{cell: "aws-us-east-2", jurisdiction: "us"},
value: &codesearch.SearchResponse{
Results: []codesearch.Result{
{Repo: "a", Path: "1.go", Score: 0.9},
{Repo: "a", Path: "2.go", Score: 0.7},
},
Stats: codesearch.Stats{TotalMatches: 2},
},
},
{
group: cellGroup{cell: testCellEU, jurisdiction: "eu"},
value: &codesearch.SearchResponse{
Results: []codesearch.Result{
{Repo: "b", Path: "3.go", Score: 0.8},
{Repo: "b", Path: "4.go", Score: 0.6},
},
Stats: codesearch.Stats{TotalMatches: 2},
},
},
}
merged, err := mergeSearchResults(context.Background(), 3, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(merged.Results) != 3 {
t.Fatalf("len(Results) = %d, want 3 (truncated to limit)", len(merged.Results))
}
if merged.Results[0].Score != 0.9 || merged.Results[1].Score != 0.8 || merged.Results[2].Score != 0.7 {
t.Errorf("results not sorted by score: %v, %v, %v",
merged.Results[0].Score, merged.Results[1].Score, merged.Results[2].Score)
}
}
func TestMergeSearchResults_PartialCellError(t *testing.T) {
t.Parallel()
results := []cellCallResult[*codesearch.SearchResponse]{
{
group: cellGroup{cell: "aws-us-east-2", jurisdiction: "us"},
value: &codesearch.SearchResponse{
Query: "test",
Stats: codesearch.Stats{TotalMatches: 2, TotalFiles: 1, ReposSearched: 1, DurationMs: 5},
Results: []codesearch.Result{{Repo: "acme/web", Path: "f.go", Line: 1}},
},
},
{
group: cellGroup{cell: testCellEU, jurisdiction: "eu"},
err: errors.New("cell timed out"),
},
}
merged, err := mergeSearchResults(context.Background(), 0, results)
if err != nil {
t.Fatalf("partial failure should not error: %v", err)
}
if merged.Stats.TotalMatches != 2 {
t.Errorf("TotalMatches = %d, want 2 (from successful cell)", merged.Stats.TotalMatches)
}
if len(merged.Results) != 1 {
t.Fatalf("len(Results) = %d, want 1 (failed cell skipped)", len(merged.Results))
}
if len(merged.FailedJurisdictions) != 1 || merged.FailedJurisdictions[0] != testCellEU {
t.Errorf("FailedJurisdictions = %v, want [aws-eu-west-1]", merged.FailedJurisdictions)
}
}
func TestMergeSearchResults_DeduplicatesOverlappingCells(t *testing.T) {
t.Parallel()
dup := codesearch.Result{Repo: "acme/web", Path: "main.go", Line: 10, Column: 5, Score: 0.9}
results := []cellCallResult[*codesearch.SearchResponse]{
{
group: cellGroup{cell: "", jurisdiction: ""},
value: &codesearch.SearchResponse{
Results: []codesearch.Result{dup},
Stats: codesearch.Stats{TotalMatches: 1, TotalFiles: 1, ReposSearched: 1},
},
},
{
group: cellGroup{cell: "aws-us-east-2", jurisdiction: "us"},
value: &codesearch.SearchResponse{
Results: []codesearch.Result{dup},
Stats: codesearch.Stats{TotalMatches: 1, TotalFiles: 1, ReposSearched: 1},
},
},
}
merged, err := mergeSearchResults(context.Background(), 0, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(merged.Results) != 1 {
t.Fatalf("len(Results) = %d, want 1 (duplicate removed)", len(merged.Results))
}
}
func TestResolveRepoFilters_GhPrefix(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{ID: testRepoID1, FullName: "entirehq/entire.io"},
}
ids, matched := resolveRepoFilters([]string{"gh/entirehq/entire.io"}, repos)
if len(ids) != 1 || ids[0] != testRepoID1 {
t.Fatalf("gh/ prefix: ids = %v, want [01ABC]", ids)
}
if len(matched) != 1 {
t.Fatalf("gh/ prefix: matched = %d, want 1", len(matched))
}
}
func TestResolveRepoFilters_EtPrefixNoStrip(t *testing.T) {
t.Parallel()
// BFF only strips gh/, not et/. "et/myproj/backend" is tried as-is
// against full_name. It won't match "myproj/backend" — this aligns
// with the BFF behavior.
repos := []coreapi.RepoIndexEntry{
{ID: testRepoID2, FullName: "myproj/backend"},
}
ids, _ := resolveRepoFilters([]string{"et/myproj/backend"}, repos)
if len(ids) != 0 {
t.Fatalf("et/ prefix should not match stripped FullName: ids = %v, want empty", ids)
}
// But if FullName is stored with the et/ prefix, it matches via the
// unstripped fallback (full_name === filter).
repos2 := []coreapi.RepoIndexEntry{
{ID: testRepoID2, FullName: "et/myproj/backend"},
}
ids2, matched := resolveRepoFilters([]string{"et/myproj/backend"}, repos2)
if len(ids2) != 1 || ids2[0] != testRepoID2 {
t.Fatalf("et/ prefix with matching FullName: ids = %v, want [02DEF]", ids2)
}
if len(matched) != 1 {
t.Fatalf("et/ prefix with matching FullName: matched = %d, want 1", len(matched))
}
}
func TestResolveRepoFilters_ULID(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{ID: "01JXYZ123ABC", FullName: "entirehq/cli"},
}
ids, _ := resolveRepoFilters([]string{"01JXYZ123ABC"}, repos)
if len(ids) != 1 || ids[0] != "01JXYZ123ABC" {
t.Fatalf("ULID: ids = %v, want [01JXYZ123ABC]", ids)
}
}
func TestResolveRepoFilters_BareSlug(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{ID: testRepoID1, FullName: "entirehq/entire.io"},
}
ids, _ := resolveRepoFilters([]string{"entirehq/entire.io"}, repos)
if len(ids) != 1 || ids[0] != testRepoID1 {
t.Fatalf("bare slug: ids = %v, want [01ABC]", ids)
}
}
func TestResolveRepoFilters_UnstrippedFallback(t *testing.T) {
t.Parallel()
// BFF tries full_name === filter (unstripped) as a fallback. This lets
// a filter like "gh/owner/repo" match if FullName happens to be
// "gh/owner/repo" (not just "owner/repo").
repos := []coreapi.RepoIndexEntry{
{ID: testRepoID1, FullName: "gh/entirehq/entire.io"},
}
ids, matched := resolveRepoFilters([]string{"gh/entirehq/entire.io"}, repos)
if len(ids) != 1 || ids[0] != testRepoID1 {
t.Fatalf("unstripped fallback: ids = %v, want [01ABC]", ids)
}
if len(matched) != 1 {
t.Fatalf("unstripped fallback: matched = %d, want 1", len(matched))
}
}
func TestResolveRepoFilters_IDMatchUsesRawFilter(t *testing.T) {
t.Parallel()
// BFF matches id === filter (raw filter, not stripped slug).
repos := []coreapi.RepoIndexEntry{
{ID: "gh/something", FullName: "unrelated/repo"},
}
ids, _ := resolveRepoFilters([]string{"gh/something"}, repos)
if len(ids) != 1 || ids[0] != "gh/something" {
t.Fatalf("ID match on raw filter: ids = %v, want [gh/something]", ids)
}
}
func TestResolveRepoFilters_NoMatch(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{ID: testRepoID1, FullName: "entirehq/entire.io"},
}
ids, matched := resolveRepoFilters([]string{"gh/nonexistent/repo"}, repos)
if len(ids) != 0 {
t.Fatalf("no match: ids = %v, want empty", ids)
}
if len(matched) != 0 {
t.Fatalf("no match: matched = %d, want 0", len(matched))
}
}
func TestResolveRepoFilters_DeduplicatesSameRepo(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{ID: testRepoID1, FullName: "entirehq/entire.io"},
}
// Same repo via three different formats — should produce one result.
ids, _ := resolveRepoFilters([]string{"gh/entirehq/entire.io", "entirehq/entire.io", testRepoID1}, repos)
if len(ids) != 1 {
t.Fatalf("dedup: len(ids) = %d, want 1", len(ids))
}
}
func TestResolveRepoFilters_MultipleReposMixed(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{ID: testRepoID1, FullName: "entirehq/entire.io"},
{ID: testRepoID2, FullName: "myproj/backend"},
}
ids, matched := resolveRepoFilters([]string{"gh/entirehq/entire.io", "myproj/backend"}, repos)
if len(ids) != 2 {
t.Fatalf("multiple: len(ids) = %d, want 2", len(ids))
}
if len(matched) != 2 {
t.Fatalf("multiple: len(matched) = %d, want 2", len(matched))
}
}
func TestSearchCmd_CaseSensitiveWithCodeFlagParsesCorrectly(t *testing.T) {
// --case-sensitive with --code should be accepted (fails later at auth, not at validation).
t.Setenv("ENTIRE_CODE_SEARCH", "1")
root := NewRootCmd()
root.SetArgs([]string{"search", "--code", "--case-sensitive", "HandleRequest"})
err := root.Execute()
// Will fail at auth, but should NOT fail at flag validation.
if err != nil && strings.Contains(err.Error(), "--case-sensitive can only be used with --code") {
t.Errorf("--case-sensitive with --code should be accepted, got: %v", err)
}
}
func TestSearchCmd_LimitFlagAccepted(t *testing.T) {
// --limit with --code should parse correctly.
t.Setenv("ENTIRE_CODE_SEARCH", "1")
root := NewRootCmd()
root.SetArgs([]string{"search", "--code", "--limit", "50", "handleRequest"})
err := root.Execute()
// Will fail at auth, but should NOT fail at flag parsing.
if err != nil && strings.Contains(err.Error(), "invalid") {
t.Errorf("--limit 50 should be accepted, got: %v", err)
}
}
func TestSearchCmd_InlineRepoStarTreatedAsAllRepos(t *testing.T) {
// repo:* inline should be treated as "all repos" (no filter).
t.Setenv("ENTIRE_CODE_SEARCH", "1")
root := NewRootCmd()
root.SetArgs([]string{"search", "--code", "auth repo:*"})
err := root.Execute()
// Will fail at auth, but should NOT fail at query parsing.
if err != nil && strings.Contains(err.Error(), "invalid") {
t.Errorf("repo:* should be accepted, got: %v", err)
}
}
func TestSearchCmd_MultipleInlineRepoFilters(t *testing.T) {
// Multiple inline repo: filters should all be collected.
t.Setenv("ENTIRE_CODE_SEARCH", "1")
root := NewRootCmd()
root.SetArgs([]string{"search", "--code", "auth repo:gh/entirehq/entire.io repo:gh/entirehq/cli"})
err := root.Execute()
// Will fail at auth, but should NOT fail at filter parsing.
if err != nil && strings.Contains(err.Error(), "invalid") {
t.Errorf("multiple repo: filters should be accepted, got: %v", err)
}
}
func TestWriteCodeSearchJSON_RepoFilteredEmpty(t *testing.T) {
t.Parallel()
// When a repo filter matches nothing, we get an empty response.
resp := &codesearch.SearchResponse{
Query: "handleRequest",
Stats: codesearch.Stats{},
Results: nil,
}
var buf bytes.Buffer
if err := writeCodeSearchJSON(&buf, resp); err != nil {
t.Fatalf("writeCodeSearchJSON error: %v", err)
}
output := buf.String()
if !strings.Contains(output, `"results": []`) {
t.Errorf("expected empty results array, got:\n%s", output)
}
if !strings.Contains(output, `"total": 0`) {
t.Errorf("expected total 0, got:\n%s", output)
}
}
func TestExtractInlineRepoFilters(t *testing.T) {
t.Parallel()
tests := []struct {
input string
wantQuery string
wantRepos []string
}{
{"auth", "auth", nil},
{"auth repo:gh/entirehq/cli", "auth", []string{"gh/entirehq/cli"}},
{"repo:gh/a/b repo:et/c/d handleRequest", "handleRequest", []string{"gh/a/b", "et/c/d"}},
{"repo:*", "", []string{"*"}},
// author: and branch: are NOT consumed — they stay in the query.
{"author:foo TODO", "author:foo TODO", nil},
{"branch:main auth repo:gh/a/b", "branch:main auth", []string{"gh/a/b"}},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
t.Parallel()
gotQuery, gotRepos := extractInlineRepoFilters(tc.input)
if gotQuery != tc.wantQuery {
t.Errorf("query = %q, want %q", gotQuery, tc.wantQuery)
}
if len(gotRepos) != len(tc.wantRepos) {
t.Fatalf("repos = %v, want %v", gotRepos, tc.wantRepos)
}
for i := range gotRepos {
if gotRepos[i] != tc.wantRepos[i] {
t.Errorf("repos[%d] = %q, want %q", i, gotRepos[i], tc.wantRepos[i])
}
}
})
}
}
func TestSearchCmd_CodePreservesNonRepoFiltersInQuery(t *testing.T) {
// Ensure author:foo is NOT consumed by code search query parsing.
t.Setenv("ENTIRE_CODE_SEARCH", "1")
root := NewRootCmd()
root.SetArgs([]string{"search", "--code", "author:foo TODO"})
err := root.Execute()
// Will fail at auth/git, but should NOT fail with empty query.
if err != nil && strings.Contains(err.Error(), "query required") {
t.Errorf("author:foo should be preserved in code query, got: %v", err)
}
}
func TestMergeSearchResults_AllCellsFail(t *testing.T) {
t.Parallel()
results := []cellCallResult[*codesearch.SearchResponse]{
{group: cellGroup{cell: "aws-us-east-2", jurisdiction: "us"}, err: errors.New("us cell timed out")},
{group: cellGroup{cell: testCellEU, jurisdiction: "eu"}, err: errors.New("eu cell timed out")},
}
_, err := mergeSearchResults(context.Background(), 0, results)
if err == nil {
t.Fatal("expected error when all cells fail")
}
if !strings.Contains(err.Error(), "code search failed") {
t.Errorf("error = %q, want containing 'code search failed'", err.Error())
}
}
Mcmd/entire/cli/search_cmd_test.go+620
16 unmodified lines
17
18
19
20
21
22
23
23 unmodified lines
47
48
49
50
51
52
53
54
55
56
57
58
59
60 unmodified lines
120
121
122
123
124
125
126
21 unmodified lines
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
20 unmodified lines
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
174
175
176
177
178
208
209
210
211
212
213
214
215
216
217
218
219
220
24 unmodified lines
245
246
247
209
248
249
250
251
252
253
254
216
255
256
257
258
32 unmodified lines
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
261
308
309
263
310
311
312
313
314
315
316
47 unmodified lines
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
19 unmodified lines
409
410
411
412
413
414
415
343
344
345
346
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
350
351
352
353
354
355
356
357
358
359
360
361
485
363
486
487
488
489
25 unmodified lines
515
516
517
518
519
520
521
522
523
524
525
526
527
528
397
529
530
531
532
533
534
535
536
537
13 unmodified lines
551
552
553
417
418
419
554
555
556
557
558
559
560
561
562
421
422
563
564
565
566
567
568
569
570
571
572
573
4 unmodified lines
578
579
580
581
582
434
583
584
585
586
8 unmodified lines
595
596
597
449
598
599
600
601
602
603
604
605
606
607
608
609
610
40 unmodified lines
651
652
653
654
655
656
657
658
659
660
661
662
663
127 unmodified lines
791
792
793
794
795
796
797
798
799
12 unmodified lines
812
813
814
647
648
649
650
651
652
653
654
655
656
657
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
1 unmodified line
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
35 unmodified lines
909
910
911
710
912
913
914
915
916
917
918
919
920
52 unmodified lines
973
974
975
769
976
977
978
979
980
981
982
983
984
41 unmodified lines
1026
1027
1028
817
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
53 unmodified lines
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
268 unmodified lines
1441
1442
1443
1153
1154
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
11 unmodified lines
1476
1477
1478
1175
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
55 unmodified lines
1547
1548
1549
1237
1550
1551
1552
1553
1554
1555
1556
1557
16 unmodified lines
glamourstyles "charm.land/glamour/v2/styles"
"charm.land/lipgloss/v2"
xansi "github.com/charmbracelet/x/ansi"
"github.com/entireio/cli/cmd/entire/cli/codesearch"
"github.com/entireio/cli/cmd/entire/cli/palette"
"github.com/entireio/cli/cmd/entire/cli/search"
"github.com/entireio/cli/cmd/entire/cli/stringutil"
23 unmodified lines
err error
}
// codeSearchResultsMsg is sent when an async code search call completes.
type codeSearchResultsMsg struct {
resp *codesearch.SearchResponse
err error
gen uint64 // generation counter; stale results are discarded
}
// searchStyles holds lipgloss styles specific to the search TUI.
// Styles shared with the status TUI (bold, dim, green, red, cyan, agent/id)
// are accessed via the embedded statusStyles.
60 unmodified lines
typeFilterCheckpoints typeFilter = typeFilter(search.TypeCheckpoint)
typeFilterCommits typeFilter = typeFilter(search.TypeCommit)
typeFilterSessions typeFilter = typeFilter(search.TypeSession)
typeFilterCode typeFilter = "code"
)
// searchModel is the bubbletea model for interactive search results.
21 unmodified lines
// snippet renderer never re-queries the terminal via OSC during the Update
// loop (which would race against bubbletea's stdin reader and stall).
darkBg bool
// Code search state (behind ENTIRE_CODE_SEARCH=1 feature flag).
codeResults []codesearch.Result // results from peregrine
codeStats codesearch.Stats // aggregate stats
codeLoading bool // true while async code search runs
codeSearchErr string // error from code search
codeSearchOpts codeSearchOpts // opts for code search (set by caller)
codeSearchGen uint64 // generation counter; incremented on each new code search
}
// filteredResults returns results matching the active type filter.
// Returns nil when the Code tab is selected (code results are a different type).
func (m searchModel) filteredResults() []search.Result {
if m.filterType == typeFilterCode {
return nil // code results are in codeResults, not here
}
if m.filterType == typeFilterAll {
return m.results
}
20 unmodified lines
return filtered[start:end]
}
// codePageResults returns the slice of code results for the current page.
func (m searchModel) codePageResults() []codesearch.Result {
start := m.page * resultsPerPage
if start >= len(m.codeResults) {
return nil
}
end := start + resultsPerPage
if end > len(m.codeResults) {
end = len(m.codeResults)
}
return m.codeResults[start:end]
}
// totalPages returns the number of pages based on the filtered result count.
func (m searchModel) totalPages() int {
n := len(m.filteredResults())
// When showing all types, use the API total if it's larger than loaded results
// (we may not have fetched everything yet).
if m.filterType == typeFilterAll && m.total > n {
n = m.total
var n int
if m.filterType == typeFilterCode {
n = len(m.codeResults)
} else {
n = len(m.filteredResults())
// When showing all types, use the API total if it's larger than loaded results
// (we may not have fetched everything yet).
if m.filterType == typeFilterAll && m.total > n {
n = m.total
}
}
if n == 0 {
return 1
24 unmodified lines
commits++
case typeFilterSessions:
sessions++
case typeFilterAll:
case typeFilterAll, typeFilterCode:
// not a valid result type; skip
}
}
return
}
func newSearchModel(results []search.Result, query string, total int, cfg search.Config, ss statusStyles) searchModel {
func newSearchModel(results []search.Result, query string, total int, cfg search.Config, ss statusStyles, codeOpts *codeSearchOpts) searchModel {
styles := newSearchStyles(ss)
ti := textinput.New()
32 unmodified lines
darkBg: termenv.HasDarkBackground(),
filterType: typeFilterCheckpoints, // default the results table to checkpoints
}
if codeOpts != nil {
m.codeSearchOpts = *codeOpts
if codeOpts.query != "" {
m.codeLoading = true
m.codeSearchGen = 1
}
}
m = m.refreshBrowseContent()
return m
}
func (m searchModel) Init() tea.Cmd {
var cmds []tea.Cmd
if m.mode == modeSearch {
return textinput.Blink
cmds = append(cmds, textinput.Blink)
}
return nil
if m.codeLoading {
cmds = append(cmds, performCodeSearch(m.codeSearchOpts, m.codeSearchGen))
}
return tea.Batch(cmds...)
}
func (m searchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop // bubbletea interface
47 unmodified lines
m = m.refreshBrowseContent()
return m, nil
case codeSearchResultsMsg:
if msg.gen != m.codeSearchGen {
return m, nil // stale result from a superseded search
}
m.codeLoading = false
if msg.err != nil {
m.codeSearchErr = msg.err.Error()
} else if msg.resp != nil {
m.codeResults = msg.resp.Results
m.codeStats = msg.resp.Stats
m.codeSearchErr = ""
}
if m.filterType == typeFilterCode {
m.cursor = 0
m.page = 0
m.browseVP.GotoTop()
}
m = m.refreshBrowseContent()
return m, nil
case tea.KeyPressMsg:
switch m.mode {
case modeSearch:
19 unmodified lines
if raw == "" {
return m, nil
}
// Checkpoint search: ParseSearchInput extracts author:/date:/branch:/repo:.
// ValidateRepoFilters only applies to checkpoint search (single repo limit);
// code search handles multiple repos via fan-out.
parsed := search.ParseSearchInput(raw)
if err := search.ValidateRepoFilters(parsed.Repos); err != nil {
m.searchErr = err.Error()
m = m.refreshBrowseContent()
return m, nil
checkpointRepoErr := search.ValidateRepoFilters(parsed.Repos)
m.searchErr = ""
var cmds []tea.Cmd
willFireCodeSearch := false
// Code search uses extractInlineRepoFilters (not ParseSearchInput)
// so author:/date:/branch: tokens are preserved as literal search
// text, matching the --code CLI path.
if codeSearchEnabled() {
codeQuery, inlineRepos := extractInlineRepoFilters(raw)
if codeQuery != "" {
willFireCodeSearch = true
opts := m.codeSearchOpts
opts.query = codeQuery
// Always reset to the model's default repo scope, then apply
// inline overrides. This prevents a stale repo list from a
// previous query leaking into the next one.
opts.repoFilters = m.codeSearchOpts.repoFilters
if len(inlineRepos) > 0 {
if hasAllReposFilter(inlineRepos) {
opts.repoFilters = nil
} else {
opts.repoFilters = filterRepoWildcards(inlineRepos)
}
}
m.codeSearchGen++
m.codeLoading = true
m.codeResults = nil
m.codeSearchErr = ""
cmds = append(cmds, performCodeSearch(opts, m.codeSearchGen))
} else {
// No code query (e.g. repo-only input) — clear stale code
// results and bump the generation so any in-flight search
// from a prior query is discarded when it completes.
m.codeSearchGen++
m.codeLoading = false
m.codeResults = nil
m.codeSearchErr = ""
}
}
// Checkpoint search (only if repo filters are valid for the checkpoint API).
if checkpointRepoErr != nil {
m.searchErr = checkpointRepoErr.Error()
if !willFireCodeSearch {
// Neither search will fire — stay in search mode so the
// user can correct the input without pressing / again.
m = m.refreshBrowseContent()
return m, nil
}
} else {
m.loading = true
cfg := m.searchCfg
cfg.Query = parsed.Query
if cfg.Query == "" {
cfg.Query = search.WildcardQuery
}
cfg.Author = parsed.Author
cfg.Date = parsed.Date
cfg.Branch = parsed.Branch
cfg.Repos = parsed.Repos
m.searchCfg = cfg
cmds = append(cmds, performSearch(cfg))
}
m.mode = modeBrowse
m.input.Blur()
m.loading = true
m.searchErr = ""
cfg := m.searchCfg
cfg.Query = parsed.Query
if cfg.Query == "" {
cfg.Query = search.WildcardQuery
}
cfg.Author = parsed.Author
cfg.Date = parsed.Date
cfg.Branch = parsed.Branch
cfg.Repos = parsed.Repos
m.searchCfg = cfg
m = m.refreshBrowseContent()
return m, performSearch(cfg)
return m, tea.Batch(cmds...)
}
var cmd tea.Cmd
25 unmodified lines
m.browseVP.GotoTop()
m = m.refreshBrowseContent()
return m, nil
case "4":
if codeSearchEnabled() {
m.filterType = typeFilterCode
m.cursor = 0
m.page = 0
m.browseVP.GotoTop()
m = m.refreshBrowseContent()
return m, nil
}
}
pageLen := len(m.pageResults())
var pageLen int
if m.filterType == typeFilterCode {
pageLen = len(m.codePageResults())
} else {
pageLen = len(m.pageResults())
}
switch {
case key.Matches(msg, keys.Quit), key.Matches(msg, keys.Back), msg.String() == "h":
return m, tea.Quit
13 unmodified lines
m = m.refreshBrowseContent()
m.browseVP.GotoTop()
case key.Matches(msg, keys.End):
filtered := m.filteredResults()
if len(filtered) > 0 {
lastLoaded := len(filtered) - 1
var totalItems int
if m.filterType == typeFilterCode {
totalItems = len(m.codeResults)
} else {
totalItems = len(m.filteredResults())
}
if totalItems > 0 {
lastLoaded := totalItems - 1
m.page = min(lastLoaded/resultsPerPage, m.totalPages()-1)
if pageLen := len(m.pageResults()); pageLen > 0 {
m.cursor = pageLen - 1
var lastPageLen int
if m.filterType == typeFilterCode {
lastPageLen = len(m.codePageResults())
} else {
lastPageLen = len(m.pageResults())
}
if lastPageLen > 0 {
m.cursor = lastPageLen - 1
}
m = m.refreshBrowseContent()
m.browseVP.GotoBottom()
4 unmodified lines
m.cursor = 0
m.browseVP.GotoTop()
// Fetch next API page if we've scrolled past loaded results
// (code search loads all results at once, no fetch-more).
start := m.page * resultsPerPage
if start >= len(m.filteredResults()) && !m.fetchingMore {
if m.filterType != typeFilterCode && start >= len(m.filteredResults()) && !m.fetchingMore {
m.fetchingMore = true
m = m.refreshBrowseContent()
return m, fetchMoreResults(m.searchCfg, m.apiPage+1)
8 unmodified lines
m = m.refreshBrowseContent()
}
case key.Matches(msg, keys.Confirm):
if r := m.selectedResult(); r != nil {
if m.filterType == typeFilterCode {
codeResults := m.codePageResults()
if m.cursor >= 0 && m.cursor < len(codeResults) {
m.mode = modeDetail
content := m.renderCodeDetail(codeResults[m.cursor], m.width, true)
m.detailVP = viewport.New(viewport.WithWidth(m.width), viewport.WithHeight(max(m.height-2, 1)))
m.detailVP.SetContent(content)
return m, nil
}
} else if r := m.selectedResult(); r != nil {
m.mode = modeDetail
content := m.renderDetailContent(*r, m.width, true)
m.detailVP = viewport.New(viewport.WithWidth(m.width), viewport.WithHeight(max(m.height-2, 1)))
40 unmodified lines
}
}
func performCodeSearch(opts codeSearchOpts, gen uint64) tea.Cmd {
return func() tea.Msg {
resp, err := searchAllCells(context.Background(), opts)
return codeSearchResultsMsg{resp: resp, err: err, gen: gen}
}
}
func fetchMoreResults(cfg search.Config, page int) tea.Cmd {
return func() tea.Msg {
cfg.Page = page
127 unmodified lines
renderTab("Sessions", typeFilterSessions, ssCount, "2"),
renderTab("Commits", typeFilterCommits, cmCount, "3"),
}
if codeSearchEnabled() {
tabs = append(tabs, renderTab("Code", typeFilterCode, len(m.codeResults), "4"))
}
return strings.Join(tabs, " ")
}
12 unmodified lines
b.WriteString(pad + m.styles.render(m.styles.sectionTitle, "›") + " " + m.styles.render(m.styles.bold, query))
b.WriteString("\n\n")
// Loading / error / empty states
if m.loading {
b.WriteString(pad + m.styles.render(m.styles.dim, "Searching..."))
return b.String(), false
}
if m.searchErr != "" {
b.WriteString(pad + m.styles.render(m.styles.red, "Error: "+m.searchErr))
return b.String(), false
}
if len(m.results) == 0 {
b.WriteString(pad + m.styles.render(m.styles.dim, "No results found."))
// When code search is available, always show type tabs so the user can
// switch to the Code tab even when checkpoint search is loading/errored/empty.
hasCodeTab := codeSearchEnabled()
checkpointBlocked := m.loading || m.searchErr != "" || len(m.results) == 0
if checkpointBlocked && !hasCodeTab {
// No code tab — show the checkpoint-only loading/error/empty state.
switch {
case m.loading:
b.WriteString(pad + m.styles.render(m.styles.dim, "Searching..."))
case m.searchErr != "":
b.WriteString(pad + m.styles.render(m.styles.red, "Error: "+m.searchErr))
default:
b.WriteString(pad + m.styles.render(m.styles.dim, "No results found."))
}
return b.String(), false
}
1 unmodified line
b.WriteString(pad + m.viewTypeTabs())
b.WriteString("\n\n")
// Checkpoint-specific loading/error/empty when on a checkpoint tab.
if checkpointBlocked && m.filterType != typeFilterCode {
switch {
case m.loading:
b.WriteString(pad + m.styles.render(m.styles.dim, "Searching..."))
case m.searchErr != "":
b.WriteString(pad + m.styles.render(m.styles.red, "Error: "+m.searchErr))
default:
b.WriteString(pad + m.styles.render(m.styles.dim, "No results found."))
}
return b.String(), false
}
// Section: RESULTS
b.WriteString(pad + m.styles.render(m.styles.sectionTitle, "RESULTS"))
b.WriteString("\n")
// Code tab has its own loading/empty state.
if m.filterType == typeFilterCode {
if m.codeLoading {
b.WriteString("\n" + pad + m.styles.render(m.styles.dim, "Searching code..."))
return b.String(), false
}
if m.codeSearchErr != "" {
b.WriteString("\n" + pad + m.styles.render(m.styles.red, "Code search error: "+m.codeSearchErr))
return b.String(), false
}
if len(m.codeResults) == 0 {
b.WriteString("\n" + pad + m.styles.render(m.styles.dim, "No code results found."))
return b.String(), false
}
return b.String(), true
}
filtered := m.filteredResults()
if len(filtered) == 0 {
b.WriteString("\n" + pad + m.styles.render(m.styles.dim, "No results for this type."))
35 unmodified lines
// pin one (in which case the list takes the full area and detail is reachable
// via the full-screen view). headerLines is the height of the pinned top chrome.
func (m searchModel) detailPaneHeight(headerLines int) int {
if m.height <= 0 || m.selectedResult() == nil {
hasSelection := m.selectedResult() != nil
if m.filterType == typeFilterCode {
codeResults := m.codePageResults()
hasSelection = m.cursor >= 0 && m.cursor < len(codeResults)
}
if m.height <= 0 || !hasSelection {
return 0
}
avail := m.height - 1 - headerLines - detailGap // footer + gap rows
52 unmodified lines
}
// Right: page X/Y · N results (drops the page clause for a single page).
n := len(m.filteredResults())
var n int
if m.filterType == typeFilterCode {
n = len(m.codeResults)
} else {
n = len(m.filteredResults())
}
right := fmt.Sprintf("%d results", n)
if pages := m.totalPages(); pages > 1 {
right = fmt.Sprintf("page %d/%d · %d results", m.page+1, pages, n)
41 unmodified lines
pad := " "
var b strings.Builder
results := m.pageResults()
rule := pad + m.styles.render(m.styles.dim, strings.Repeat("─", contentWidth)) + "\n"
if m.filterType == typeFilterCode {
codeResults := m.codePageResults()
for i, r := range codeResults {
if i > 0 {
b.WriteString(rule)
}
b.WriteString(m.viewCodeResultItem(r, i == m.cursor, contentWidth))
}
return b.String()
}
results := m.pageResults()
for i, r := range results {
if i > 0 {
b.WriteString(rule)
53 unmodified lines
return b.String()
}
// viewCodeResultItem renders a single two-line code search result (file:line + context).
func (m searchModel) viewCodeResultItem(r codesearch.Result, selected bool, contentWidth int) string {
pad := " "
var b strings.Builder
// ── Title line: gutter + repo:path:line ──
node, caret := "◇", " "
if selected {
node, caret = "◆", "▸"
}
nodeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(palette.Green))
if !m.styles.colorEnabled {
nodeStyle = lipgloss.NewStyle()
}
if selected {
nodeStyle = m.styles.selected
}
gutter := caret + " " + m.styles.render(nodeStyle, node) + " "
location := fmt.Sprintf("%s:%s:%d", r.Repo, r.Path, r.Line)
titleMax := max(contentWidth-gutterWidth, 8)
location = stringutil.TruncateRunes(location, titleMax, "…")
titleStyle := m.styles.bold
if selected {
titleStyle = m.styles.selected
}
b.WriteString(pad + gutter + m.styles.render(titleStyle, location) + "\n")
// ── Context line: the matching source line, truncated ──
indent := strings.Repeat(" ", gutterWidth)
ctx := r.ContextLine
runes := []rune(ctx)
ctxMax := max(contentWidth-gutterWidth, 8)
if len(runes) > ctxMax {
ctx = string(runes[:ctxMax]) + "…"
}
b.WriteString(pad + indent + m.styles.render(m.styles.dim, ctx) + "\n")
return b.String()
}
// renderCodeDetail builds the detail content for a code search result.
func (m searchModel) renderCodeDetail(r codesearch.Result, contentWidth int, showSections bool) string {
w := m.newDetailWriter("Code Match", contentWidth, showSections)
w.section("LOCATION")
w.field("Repo", r.Repo)
w.field("Path", r.Path)
w.field("Line", strconv.Itoa(r.Line))
if r.Column > 0 {
w.field("Column", strconv.Itoa(r.Column))
}
if r.Score > 0 {
w.field("Score", fmt.Sprintf("%.3f", r.Score))
}
w.section("CONTEXT")
for _, line := range r.ContextBefore {
w.b.WriteString(m.styles.render(m.styles.dim, " "+line) + "\n")
}
w.b.WriteString("▸ " + r.ContextLine + "\n")
for _, line := range r.ContextAfter {
w.b.WriteString(m.styles.render(m.styles.dim, " "+line) + "\n")
}
return w.String()
}
// resultNodeStyle returns the accent style for a result's graph node and type
// tag: accent (magenta) for checkpoints, bright magenta for sessions, blue for
// commits. The
268 unmodified lines
// available via the full-screen detail view). Shorter content is padded so
// the pane occupies its full allotment and the footer stays pinned.
func (m searchModel) viewDetailPane(paneH int) string {
r := m.selectedResult()
if r == nil || paneH <= 0 {
if paneH <= 0 {
return strings.TrimSuffix(padToHeight("", paneH), "\n")
}
// Code tab uses codeResults; other tabs use selectedResult().
var r *search.Result
if m.filterType == typeFilterCode {
codeResults := m.codePageResults()
if m.cursor < 0 || m.cursor >= len(codeResults) {
return strings.TrimSuffix(padToHeight("", paneH), "\n")
}
} else {
r = m.selectedResult()
if r == nil {
return strings.TrimSuffix(padToHeight("", paneH), "\n")
}
}
var contentWidth, borderWidth, chrome int
if m.styles.colorEnabled {
// lipgloss v2 .Width(W) is the outer width: it absorbs horizontal padding
11 unmodified lines
}
contentLines := max(paneH-chrome, 1)
lines := strings.Split(m.renderDetailContent(*r, contentWidth, false), "\n")
var detailContent string
if m.filterType == typeFilterCode {
codeResults := m.codePageResults()
if m.cursor >= 0 && m.cursor < len(codeResults) {
detailContent = m.renderCodeDetail(codeResults[m.cursor], contentWidth, false)
}
} else {
detailContent = m.renderDetailContent(*r, contentWidth, false)
}
lines := strings.Split(detailContent, "\n")
if len(lines) > contentLines {
lines = lines[:contentLines]
hint := m.styles.render(m.styles.dim, "▼ enter for more")
55 unmodified lines
if pages > 1 {
left += dot + m.styles.helpItem("n/p", "page")
}
left += dot + m.styles.helpItem("1-3", "type") + dot +
typeHint := "1-3"
if codeSearchEnabled() {
typeHint = "1-4"
}
left += dot + m.styles.helpItem(typeHint, "type") + dot +
m.styles.helpItem(keys.Quit.Help().Key, keys.Quit.Help().Desc)
// The page / results count lives on the status row beneath the list
Mcmd/entire/cli/search_tui.go+369/-52
104 unmodified lines
105
106
107
108
108
109
110
111
1 unmodified line
113
114
115
116
116
117
118
119
116 unmodified lines
236
237
238
239
239
240
241
242
121 unmodified lines
364
365
366
367
367
368
369
370
23 unmodified lines
394
395
396
397
397
398
399
400
12 unmodified lines
413
414
415
416
416
417
418
419
252 unmodified lines
672
673
674
675
675
676
677
678
37 unmodified lines
716
717
718
719
719
720
721
722
15 unmodified lines
738
739
740
741
741
742
743
744
5 unmodified lines
750
751
752
753
753
754
755
756
198 unmodified lines
955
956
957
958
958
959
960
961
3 unmodified lines
965
966
967
968
968
969
970
971
25 unmodified lines
997
998
999
1000
1000
1001
1002
1003
27 unmodified lines
1031
1032
1033
1034
1034
1035
1036
1037
24 unmodified lines
1062
1063
1064
1065
1065
1066
1067
1068
72 unmodified lines
1141
1142
1143
1144
1144
1145
1146
1147
36 unmodified lines
1184
1185
1186
1187
1187
1188
1189
1190
40 unmodified lines
1231
1232
1233
1234
1234
1235
1236
1237
18 unmodified lines
1256
1257
1258
1259
1259
1260
1261
1262
21 unmodified lines
1284
1285
1286
1287
1287
1288
1289
1290
9 unmodified lines
1300
1301
1302
1303
1303
1304
1305
1306
26 unmodified lines
1333
1334
1335
1336
1336
1337
1338
1339
23 unmodified lines
1363
1364
1365
1366
1366
1367
1368
1369
22 unmodified lines
1392
1393
1394
1395
1395
1396
1397
1398
19 unmodified lines
1418
1419
1420
1421
1421
1422
1423
1424
1425
1426
1426
1427
1428
1429
1430
1431
1432
1433
1434
1432
1433
1434
1435
1436
9 unmodified lines
1446
1447
1448
1450
1449
1450
1451
1452
1453
1454
1456
1455
1456
1457
1458
104 unmodified lines
func testModel() searchModel {
ss := statusStyles{colorEnabled: false, width: 100}
cfg := search.Config{ServiceURL: "http://test", Owner: "o", Repo: "r", Limit: 20}
m := newSearchModel(testResults(), "auth", 2, cfg, ss)
m := newSearchModel(testResults(), "auth", 2, cfg, ss, nil)
return initTestViewport(m)
}
1 unmodified line
ss := statusStyles{colorEnabled: false, width: 120}
cfg := search.Config{ServiceURL: "http://test", Owner: "o", Repo: "r", Limit: 20}
results := testMultiTypeResults()
m := newSearchModel(results, "auth", len(results), cfg, ss)
m := newSearchModel(results, "auth", len(results), cfg, ss, nil)
return initTestViewport(m)
}
116 unmodified lines
ss := statusStyles{colorEnabled: false, width: 100}
cfg := search.Config{}
m := initTestViewport(newSearchModel(results, "q", len(results), cfg, ss))
m := initTestViewport(newSearchModel(results, "q", len(results), cfg, ss, nil))
m.page = tt.startPage
m.cursor = tt.startCursor
m = m.refreshBrowseContent()
121 unmodified lines
for _, w := range []int{40, 80, 120} {
for _, h := range []int{12, 20, 24, 40, 60} {
ss := statusStyles{colorEnabled: color, width: w}
m := initTestViewport(newSearchModel(results, "auth", 47, search.Config{}, ss))
m := initTestViewport(newSearchModel(results, "auth", 47, search.Config{}, ss, nil))
m.width, m.height = w, h
m.cursor = 7 // force the list to scroll
m = m.refreshBrowseContent()
23 unmodified lines
}
// Short terminal + 25 results (multiple pages) → the page's rows can't all fit.
overflow := newSearchModel(mk(25), "auth", 25, search.Config{}, statusStyles{width: 80})
overflow := newSearchModel(mk(25), "auth", 25, search.Config{}, statusStyles{width: 80}, nil)
overflow.height, overflow.width = 28, 80
overflow = overflow.refreshBrowseContent()
12 unmodified lines
}
// Tall terminal + few results → everything fits, no hint.
fits := newSearchModel(mk(3), "auth", 3, search.Config{}, statusStyles{width: 80})
fits := newSearchModel(mk(3), "auth", 3, search.Config{}, statusStyles{width: 80}, nil)
fits.height, fits.width = 50, 80
fits = fits.refreshBrowseContent()
if v := fits.viewBrowse(); strings.Contains(v, "more results") {
252 unmodified lines
}
ss := statusStyles{colorEnabled: false, width: 120}
m := newSearchModel(results, "q", len(results), search.Config{}, ss)
m := newSearchModel(results, "q", len(results), search.Config{}, ss, nil)
footer := m.viewHelp()
wantParts := []string{
37 unmodified lines
t.Parallel()
ss := statusStyles{colorEnabled: false, width: 80}
cfg := search.Config{}
m := initTestViewport(newSearchModel(nil, "nothing", 0, cfg, ss))
m := initTestViewport(newSearchModel(nil, "nothing", 0, cfg, ss, nil))
view := m.View().Content
if !strings.Contains(view, "No results found") {
15 unmodified lines
t.Parallel()
ss := statusStyles{colorEnabled: false, width: 0}
cfg := search.Config{}
m := newSearchModel(testResults(), "auth", 2, cfg, ss)
m := newSearchModel(testResults(), "auth", 2, cfg, ss, nil)
m.width = 0
if view := m.View().Content; view != "" {
5 unmodified lines
t.Parallel()
ss := statusStyles{colorEnabled: false, width: 1}
cfg := search.Config{}
m := newSearchModel(testResults(), "auth", 2, cfg, ss)
m := newSearchModel(testResults(), "auth", 2, cfg, ss, nil)
m.width = 1
// Should not panic on width=1 (contentWidth would be negative without guard)
198 unmodified lines
// 0 results = 1 page (empty state)
ss := statusStyles{colorEnabled: false, width: 100}
cfg := search.Config{}
empty := newSearchModel(nil, "", 0, cfg, ss)
empty := newSearchModel(nil, "", 0, cfg, ss, nil)
if got := empty.totalPages(); got != 1 {
t.Errorf("totalPages() with total=0 = %d, want 1", got)
}
3 unmodified lines
for i := range results {
results[i] = search.Result{Type: "checkpoint", Checkpoint: &search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
}
many := newSearchModel(results, "q", 26, cfg, ss)
many := newSearchModel(results, "q", 26, cfg, ss, nil)
if got := many.totalPages(); got != 3 {
t.Errorf("totalPages() with total=26 = %d, want 3", got)
}
25 unmodified lines
for i := range results {
results[i] = search.Result{Type: "checkpoint", Checkpoint: &search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
}
m := newSearchModel(results, "q", 50, cfg, ss)
m := newSearchModel(results, "q", 50, cfg, ss, nil)
if m.apiPage != 1 {
t.Fatalf("initial apiPage = %d, want 1", m.apiPage)
27 unmodified lines
for i := range results {
results[i] = search.Result{Type: "checkpoint", Checkpoint: &search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
}
m := newSearchModel(results, "q", 50, cfg, ss)
m := newSearchModel(results, "q", 50, cfg, ss, nil)
m.filterType = typeFilterAll // fetch-more from the API applies in the All view
// Navigate to page 2 — should trigger fetch
24 unmodified lines
// Navigate to page 2 — should NOT trigger fetch (data already loaded)
updated, cmd := m.Update(tea.KeyPressMsg{Code: 'n', Text: "n"})
72 unmodified lines
for i := range results {
results[i] = search.Result{Type: "checkpoint", Checkpoint: &search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
}
m := newSearchModel(results, "q", 20, cfg, ss)
m := newSearchModel(results, "q", 20, cfg, ss, nil)
if m.page != 0 {
t.Fatalf("initial page = %d, want 0", m.page)
36 unmodified lines
ServiceURL: "http://test", Owner: "o", Repo: "r", Limit: 25,
Author: "alice", Date: "week",
}
m := newSearchModel(testResults(), "auth", 2, cfg, ss)
m := newSearchModel(testResults(), "auth", 2, cfg, ss, nil)
// Enter search mode
m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
40 unmodified lines
for i := range results {
results[i] = search.Result{Type: "checkpoint", Checkpoint: &search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
}
m := newSearchModel(results, "q", 50, cfg, ss)
m := newSearchModel(results, "q", 50, cfg, ss, nil)
m.fetchingMore = true
m = updateModel(t, m, searchMoreResultsMsg{err: errTestSearch})
18 unmodified lines
for i := range results {
results[i] = search.Result{Type: "checkpoint", Checkpoint: &search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
}
m := newSearchModel(results, "q", 100, cfg, ss)
m := newSearchModel(results, "q", 100, cfg, ss, nil)
m.filterType = typeFilterAll // exercise all-types pagination against m.total
if m.totalPages() != 10 {
21 unmodified lines
for i := range results {
results[i] = search.Result{Type: "checkpoint", Checkpoint: &search.CheckpointResult{ID: fmt.Sprintf("id-%02d", i)}}
}
m := initTestViewport(newSearchModel(results, "q", 50, cfg, ss))
m := initTestViewport(newSearchModel(results, "q", 50, cfg, ss, nil))
m.page = 1
m.fetchingMore = true
m = m.refreshBrowseContent()
9 unmodified lines
ss := statusStyles{colorEnabled: false, width: 100}
cfg := search.Config{ServiceURL: "http://test", Owner: "o", Repo: "r", Limit: 25}
m := newSearchModel(testResults(), "old", 2, cfg, ss)
m := newSearchModel(testResults(), "old", 2, cfg, ss, nil)
// Enter search mode and type query with filters
m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
26 unmodified lines
Repo: "default-repo",
Limit: 25,
}
m := newSearchModel(testResults(), "old", 2, cfg, ss)
m := newSearchModel(testResults(), "old", 2, cfg, ss, nil)
m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
m.input.SetValue(newQuery + " repo:entirehq/entire.io")
23 unmodified lines
Limit: 25,
Repos: []string{"entirehq/entire.io"},
}
m := newSearchModel(testResults(), "auth", 2, cfg, ss)
m := newSearchModel(testResults(), "auth", 2, cfg, ss, nil)
m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
m.input.SetValue(newQuery)
22 unmodified lines
Repo: "default-repo",
Limit: 25,
}
m := newSearchModel(testResults(), "old", 2, cfg, ss)
m := newSearchModel(testResults(), "old", 2, cfg, ss, nil)
m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
m.input.SetValue(newQuery + " repo:*")
19 unmodified lines
Repo: "default-repo",
Limit: 25,
}
m := newSearchModel(testResults(), "old", 2, cfg, ss)
m := newSearchModel(testResults(), "old", 2, cfg, ss, nil)
m = updateModel(t, m, tea.KeyPressMsg{Code: '/', Text: "/"})
m.input.SetValue(newQuery + " repo:entirehq/entire.io,entireio/cli")
updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
m, ok := updated.(searchModel)
if !ok {
t.Fatalf("Update returned %T, want searchModel", updated)
}
if cmd != nil {
t.Fatal("expected no search command on invalid multi-repo input")
}
// Multi-repo filters are invalid for checkpoint search and code search is
// off (nil codeOpts) — stay in search mode so the user can correct input.
if m.mode != modeSearch {
t.Errorf("mode = %d, want modeSearch", m.mode)
}
9 unmodified lines
cfg := search.Config{}
// With results: apiPage = 1
withResults := newSearchModel(testResults(), "q", 2, cfg, ss)
withResults := newSearchModel(testResults(), "q", 2, cfg, ss, nil)
if withResults.apiPage != 1 {
t.Errorf("apiPage with results = %d, want 1", withResults.apiPage)
}
// Without results: apiPage = 0
noResults := newSearchModel(nil, "", 0, cfg, ss)
noResults := newSearchModel(nil, "", 0, cfg, ss, nil)
if noResults.apiPage != 0 {
t.Errorf("apiPage without results = %d, want 0", noResults.apiPage)
}
Mcmd/entire/cli/search_tui_test.go+30/-31