Merge branch 'main' into soph/loosen-repo-list-enums · Entire
Log in
Merge branch 'main' into soph/loosen-repo-list-enums
384e722→main·
gtrrz-victor·1w ago·63 files·+5,159 added/-469 removed
Changes
63
.github/workflows
Mpublish-git-remote-entire.yml+1/-1
Mrelease.yml+49/-2
MCHANGELOG.md+50
MCLAUDE.md+19/-2
MREADME.md+4
cmd
entire/cli
Mapi_cmd.go+52/-9
Mapi_cmd_test.go+37/-1
auth
Mcell_data_api.go+22/-2
Mcell_data_api_test.go+20
Mcell_fanout.go+103/-6
Mcell_fanout_test.go+170/-1
checkpoint
Mopen.go+1/-5
Mregistry.go+19
Mregistry_test.go+33
Acheckpoint_backend.go+141
Acheckpoint_backend_test.go+131
codesearch
Acodesearch.go+119
Acodesearch_test.go+153
Mcorecmd.go+42/-6
Acorecmd_json_flag_test.go+98
Mcorecmd_list_test.go+2/-2
Mcorecmd_mutation_test.go+2/-2
Mgrant.go+14/-7
integration_test
Aenable_import_test.go+99
investigate
Mpicker_test.go+8/-2
Mlabs.go-24
Morg.go+11/-8
Mplugin.go+3/-1
Mproject.go+9/-7
Mrecap.go+16
Mrepo.go+11/-7
Mrepo_mirror.go+246/-27
Mrepo_mirror_collaborators.go+3/-1
Mrepo_mirror_create_wizard.go+6/-1
Mrepo_mirror_create_wizard_test.go+56
Mrepo_mirror_probe.go+13/-5
Mrepo_mirror_test.go+434/-7
review
Mcmd.go+23/-24
Mrun.go+13/-22
Mrun_test.go+43/-39
Msynthesis_sink.go+6/-2
Msynthesis_sink_test.go+6/-5
types
Mreviewer.go+5/-4
Mroot.go+9/-6
Msearch_cmd.go+576/-5
Msearch_cmd_test.go+683
Msearch_tui.go+369/-52
Msearch_tui_test.go+30/-31
Msetup.go+167/-77
Asetup_import.go+253
Asetup_import_test.go+284
Msetup_test.go+56
strategy
Mmanual_commit_opf_prompt.go+9/-11
Mmanual_commit_reset.go-7
Mmanual_commit_rewind.go+2/-4
versioncheck
Mautoupdate.go+2/-4
git-remote-entire
Mmain.go+36/-2
Mmain_test.go+92
Mgo.mod+6/-6
Mgo.sum+12/-12
internal/remotehelper/transport
Minforefs.go+3/-1
Mproxy.go+96/-19
Mproxy_test.go+181
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
19 unmodified lines
20
21
22
23
24
25
26
27
116 unmodified lines
144
145
146
145
147
148
149
150
151
152
153
154
155
148
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
19 unmodified lines
jobs:
release:
runs-on: ubuntu-latest
outputs:
prerelease: ${{ steps.release-type.outputs.prerelease }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
116 unmodified lines
done
exit $fail
notify-slack:
# A stable tag only refreshes the stable cask; the nightly channel would
# otherwise not catch up until the next scheduled nightly run cuts a tag from
# a *newer* commit (and create-nightly-tag.sh skips when HEAD already has a
# nightly). To ship the release on both channels immediately, cut a nightly
# tag from the exact stable commit and push it. That push re-triggers this
# workflow on the nightly-tag path, which uploads the entire@nightly cask.
mirror-nightly:
runs-on: ubuntu-latest
needs: [release]
if: ${{ always() && needs.release.result == 'failure' }}
# Only mirror stable releases. On a nightly-tag run the release job already
# published the nightly channel, so this would recurse — skip it there.
if: ${{ needs.release.result == 'success' && needs.release.outputs.prerelease == 'false' }}
steps:
# A cli-scoped GitHub App token (not the default GITHUB_TOKEN) so the tag
# push triggers a fresh workflow run — GITHUB_TOKEN pushes are suppressed
# to prevent recursion, which would leave the nightly cask un-uploaded.
- name: Generate token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.HOMEBREW_TAP_APP_ID }}
private-key: ${{ secrets.HOMEBREW_TAP_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: cli
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: ${{ env.RELEASE_TAG }}
token: ${{ steps.app-token.outputs.token }}
- name: Cut nightly tag from the stable commit
run: |
TAG=$(scripts/create-nightly-tag.sh) && EXIT_CODE=0 || EXIT_CODE=$?
if [ "$EXIT_CODE" -eq 2 ]; then
echo "Nightly already current for this commit — nothing to mirror."
exit 0
elif [ "$EXIT_CODE" -ne 0 ] || [ -z "$TAG" ]; then
echo "::error::Failed to generate nightly tag to mirror ${RELEASE_TAG}"
exit 1
fi
echo "Mirroring ${RELEASE_TAG} to nightly channel as ${TAG}"
git tag "$TAG"
git push origin "$TAG"
notify-slack:
runs-on: ubuntu-latest
needs: [release, mirror-nightly]
if: ${{ always() && (needs.release.result == 'failure' || needs.mirror-nightly.result == 'failure') }}
steps:
- name: Notify Slack of release failure
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
M.github/workflows/release.yml+49/-2
4 unmodified lines
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
4 unmodified lines
The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.8.42] - 2026-07-08
### Added
- Cross-region code search (work in progress, behind `ENTIRE_CODE_SEARCH=1`): `--code` and `--case-sensitive` flags on `entire search` plus a Code tab in the search TUI, backed by peregrine. It fans out across mirror placements — listing repos, grouping them by cell, searching each cell in parallel with per-cell timeouts, and merging/deduping results client-side — rather than only hitting the home cell ([#1616](https://github.com/entireio/cli/pull/1616), [#1674](https://github.com/entireio/cli/pull/1674))
- `entire repo mirror list` gained a `--name` filter (matches the owner/repo form shown in the table) and `--sort` with shell-friendly kebab-case column keys, failing fast on an unknown sort key ([#1665](https://github.com/entireio/cli/pull/1665), [#1679](https://github.com/entireio/cli/pull/1679))
### Changed
- `entire review` no longer imposes a default reviewer timeout — reviewers run until done — while the judge's default rises from 5m to 20m; `--timeout` still governs both ([#1664](https://github.com/entireio/cli/pull/1664))
- `org`, `project`, `repo`, and `grant` are promoted out of `entire labs` into the visible top-level command surface, so they now appear in `entire --help` (canonical paths unchanged) ([#1672](https://github.com/entireio/cli/pull/1672))
- git-remote-entire prints an actionable hint when the cluster host is missing, and only suggests `clone` for a complete forge/owner/repo ref ([#1649](https://github.com/entireio/cli/pull/1649))
### Fixed
- `entire repo mirror get` resolves clone URLs via the owning cluster's login server instead of the control-plane core ([#1676](https://github.com/entireio/cli/pull/1676))
### Housekeeping
- Bump aws-actions/configure-aws-credentials from 6.2.1 to 6.2.2 ([#1670](https://github.com/entireio/cli/pull/1670))
## [0.8.1] - 2026-07-07
### Added
- `--checkpoint-backend branch|refs` on `entire enable` and `entire configure` selects the checkpoint store, with an interactive selector on first-time setup (defaults to `branch`, which writes no config block) ([#1661](https://github.com/entireio/cli/pull/1661))
- First-time `entire enable` now offers to import pre-existing agent history for the agents you select, instead of leaving the hidden `entire import` command to be discovered; non-interactive runs (`--yes` or no TTY) auto-import all eligible agents ([#1595](https://github.com/entireio/cli/pull/1595))
- `entire api` gained `--jurisdiction <slug>` (short `-j`, e.g. `us`, `eu`) to target a specific jurisdiction's entire-api cell instead of your home cell (implies `--to cell`); the command is now also documented in CLAUDE.md ([#1631](https://github.com/entireio/cli/pull/1631))
### Changed
- `entire activity` now lists recent sessions (from `/me/sessions`) instead of recent commits, matching the entire.io Overview feed ([#1650](https://github.com/entireio/cli/pull/1650))
- All TUI colors migrated to the base16 (ANSI 0–15) palette via a new `palette` package, so the UI respects the user's terminal theme; the primary accent moved from orange to magenta, and `entire experts` was migrated too ([#1542](https://github.com/entireio/cli/pull/1542), [#1610](https://github.com/entireio/cli/pull/1610))
- Homebrew auto-update now runs `brew upgrade --yes`, so accepting the update prompt no longer triggers a second Homebrew confirmation ([#1653](https://github.com/entireio/cli/pull/1653))
### Fixed
- The post-run "update available" notice now prints to stderr instead of stdout, so it no longer corrupts `$(entire … --json)` command substitutions and pipes ([#1656](https://github.com/entireio/cli/pull/1656))
- git-remote-entire now re-mints its credential and retries once on a data-plane 401 instead of failing the command, smoothing over mid-TTL token invalidation from core key rotation or clock skew ([#1658](https://github.com/entireio/cli/pull/1658))
- `entire repo mirror create` renders a stale-read 404 from the status poll as the server's "mirror not found" message instead of a raw struct dump, and widens the poll retry budget (~8s → ~30s) to ride out the placement-visibility window ([#1660](https://github.com/entireio/cli/pull/1660))
### Housekeeping
- Cell-routing foundation: a shared `auth.CellClientFactory` (one identity token per jurisdiction across a fan-out), a generic repo→cell resolver, and multi-cell client-side fan-out/merge, so multi-cell commands stop growing parallel copies of the routing plumbing ([#1641](https://github.com/entireio/cli/pull/1641))
- This repo now dogfoods the git-refs checkpoint backend for its own checkpoints ([#1648](https://github.com/entireio/cli/pull/1648))
- git-remote test coverage (1/4): a committed test plan plus an integration backend matrix that exercises real git-hook pushes across both checkpoint backends ([#1636](https://github.com/entireio/cli/pull/1636))
- `explain` follow-up from trail review: naming, test-helper, and doc refinements, no behavior change ([#1569](https://github.com/entireio/cli/pull/1569))
- Release: stable tags now mirror to the nightly channel so `entire@nightly` users aren't stranded below the just-shipped stable build ([#1662](https://github.com/entireio/cli/pull/1662))
- Dependency bumps (charm bubbles/bubbletea/lipgloss, posthog-go; betterleaks held at v1.5.0) ([#1654](https://github.com/entireio/cli/pull/1654))
## [0.8.0] - 2026-07-06
### Added
MCHANGELOG.md+50
21 unmodified lines
22
23
24
25
25
26
27
28
29 unmodified lines
58
59
60
61
62
63
64
65
66
67
68
69
70
1 unmodified line
72
73
74
68
75
76
77
78
79
80
81
82
83
84
85
86
87
88
21 unmodified lines
### Command Layout
The visible CLI is organized around five noun groups plus a small set of
The visible CLI is organized around a set of noun groups plus a small set of
top-level verbs. The groups are the canonical home for each verb; legacy
top-level shortcuts remain functional but hidden, and emit a deprecation hint
pointing at the canonical group form. Newer experimental command families are
29 unmodified lines
takes `--everywhere` (revoke every session on the active core, not just the
current one) and `--all-contexts` (log out of every saved login)
- `doctor`: bare runs the scan-and-fix flow, plus `trace`, `logs`, `bundle`
- `org`: control-plane organization management — `create`, `list`, `get`, `delete`
- `project`: control-plane project management — `create`, `list`, `get`, `delete`
- `repo`: control-plane repository lifecycle — `create`, `list`, `get`, `delete`,
`clone`, plus the `mirror` and `visibility` subtrees. Git content operations
(log, diff, …) are intentionally out of scope.
- `grant`: manage access grants and org membership — `org`, `project`, and `repo`
each support `add` / `list` / `remove`
Experimental command families advertised through `entire labs`:
1 unmodified line
Top-level lifecycle and standalone commands: `enable`, `disable`, `status`,
`login`, `logout`, `clean`, `version`, `dispatch`, `activity`, `help`,
`configure`, `agent-help`.
`configure`, `agent-help`, `api`.
`api` is an authenticated passthrough to Entire's HTTP APIs (gh-style): it
attaches the right bearer and dials the right host so callers don't plumb auth
themselves. `--to core` (default) hits the control plane; `--to cell` hits an
entire-api cell. `--jurisdiction <slug>` (e.g. `us`, `eu`) targets a specific
jurisdiction's cell instead of the caller's home cell and implies `--to cell`
(cell routing + identity-token exchange live in `auth.NewEntireAPICellClient`
via `auth.CellTarget`). `{owner}`/`{repo}`/`{repo_id}` in the path are filled
from the current repo's origin remote. It is visible in `entire help` and
`entire agent-help`, so agents discover it as the supported way to call the API.
`agent-help` renders machine-readable, agent-facing usage live from the Cobra
command tree (so it always matches the installed binary): bare prints a
MCLAUDE.md+19/-2
249 unmodified lines
250
251
252
253
254
255
256
257
258
259
249 unmodified lines
| `entire checkpoint explain` | Explain a session, commit, or checkpoint |
| `entire checkpoint rewind` | Rewind to a previous checkpoint (deprecated, will be removed in a future release) |
| `entire login` | Authenticate the CLI with Entire device auth |
| `entire org` | Manage Entire organizations (create, list, get, delete) |
| `entire project` | Manage Entire projects (create, list, get, delete) |
| `entire repo` | Manage Entire repositories (create, list, get, delete, clone, mirror, visibility) |
| `entire grant` | Manage access grants and org membership (org, project, repo) |
| `entire session` | View and manage agent sessions tracked by Entire |
| `entire session resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) |
| `entire session attach` | Attach to a previously detached session |
MREADME.md+4
23 unmodified lines
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
13 unmodified lines
53
54
55
56
57
58
59
60
1 unmodified line
62
63
64
65
66
67
68
69
59
70
71
72
62
73
74
75
76
77
4 unmodified lines
82
83
84
73
85
86
87
76
88
89
90
91
92
93
94
95
96
30 unmodified lines
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
115
116
152
153
154
155
156
118
157
158
159
160
9 unmodified lines
170
171
172
134
135
173
174
175
176
177
178
179
180
181
23 unmodified lines
const apiMaxResponseBytes = 32 << 20 // 32 MiB cap on a printed response body.
// The two --to backends: the control plane (core) and a per-jurisdiction
// entire-api cell.
const (
apiTargetCore = "core"
apiTargetCell = "cell"
)
type apiFlags struct {
to string
jurisdiction string
method string
rawFields []string
typedFields []string
13 unmodified lines
"chosen backend, so you don't have to plumb auth yourself:\n\n" +
" --to core the control plane (default): orgs, repos, mirrors, clusters, /me\n" +
" --to cell your home entire-api cell: /me/* activity, repo aggregates\n\n" +
"Use --jurisdiction <slug> (e.g. us, eu) to reach a specific jurisdiction's\n" +
"entire-api cell instead of your home one; it implies --to cell.\n\n" +
"<path> is the full path on that host, e.g. /api/v1/clusters. These\n" +
"placeholders are filled from the current repo's origin remote:\n" +
" {owner} {repo} the GitHub owner / repo\n" +
1 unmodified line
"The method is GET unless a field/body is given (then POST); override with -X.",
Example: " entire api /api/v1/clusters\n" +
" entire api --to cell /api/v1/me/activity\n" +
" entire api --jurisdiction eu /api/v1/me/activity\n" +
" entire api --to cell \"/api/v1/me/recap?repo={repo_id}\"\n" +
" entire api -X POST /api/v1/projects -f name=demo",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPI(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], f)
return runAPI(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], f, cmd.Flags().Changed("to"))
},
}
cmd.Flags().StringVar(&f.to, "to", "core", "which backend to call: core or cell")
cmd.Flags().StringVar(&f.to, "to", apiTargetCore, "which backend to call: core or cell")
cmd.Flags().StringVarP(&f.jurisdiction, "jurisdiction", "j", "", "target a specific jurisdiction's cell (e.g. us, eu) instead of your home cell; implies --to cell")
cmd.Flags().StringVarP(&f.method, "method", "X", "", "HTTP method (default GET, or POST when a field/body is given)")
cmd.Flags().StringArrayVarP(&f.rawFields, "raw-field", "f", nil, "add a string parameter in key=value format (repeatable)")
cmd.Flags().StringArrayVarP(&f.typedFields, "field", "F", nil, "add a typed parameter in key=value format; true/false/null/numbers are converted (repeatable)")
4 unmodified lines
return cmd
}
func runAPI(ctx context.Context, w, errW io.Writer, rawPath string, f *apiFlags) error {
func runAPI(ctx context.Context, w, errW io.Writer, rawPath string, f *apiFlags, toExplicit bool) error {
insecure := applyInsecureHTTPAuth(f.insecureHTTP)
client, err := resolveAPIClient(ctx, f.to, insecure)
to, jurisdiction, err := resolveAPITarget(f, toExplicit)
if err != nil {
return err
}
client, err := resolveAPIClient(ctx, to, jurisdiction, insecure)
if err != nil {
return err
}
30 unmodified lines
return writeAPIResponse(w, errW, resp, f.include)
}
// resolveAPITarget applies the --jurisdiction/--to interplay. --jurisdiction
// selects a specific jurisdiction's entire-api cell, so it implies --to cell;
// combining it with an explicit --to core is a contradiction and is rejected.
// The returned jurisdiction is normalized to a bare lowercase slug (e.g. "US"
// or " us " -> "us"); NewEntireAPICellClient validates it as a DNS label.
func resolveAPITarget(f *apiFlags, toExplicit bool) (to, jurisdiction string, err error) {
to = f.to
jurisdiction = strings.ToLower(strings.TrimSpace(f.jurisdiction))
if jurisdiction == "" {
return to, "", nil
}
switch {
case !toExplicit:
to = apiTargetCell // --jurisdiction targets a cell; imply it over the default core.
case strings.ToLower(strings.TrimSpace(f.to)) != apiTargetCell:
return "", "", fmt.Errorf("--jurisdiction targets a cell; use --to cell (not --to %s)", f.to)
}
return to, jurisdiction, nil
}
// resolveAPIClient builds an authenticated client for the chosen backend. Both
// return an *api.Client whose base URL is the backend origin, so <path> is the
// full path (e.g. /api/v1/…) against that host.
func resolveAPIClient(ctx context.Context, to string, insecure bool) (*api.Client, error) {
// full path (e.g. /api/v1/…) against that host. jurisdiction, when non-empty,
// pins the entire-api cell to that jurisdiction (cell path only; resolveAPITarget
// guarantees it is empty for core).
func resolveAPIClient(ctx context.Context, to, jurisdiction string, insecure bool) (*api.Client, error) {
switch strings.ToLower(strings.TrimSpace(to)) {
case "", "core":
case "", apiTargetCore:
target, err := resolveAuthStatusTarget(ctx, auth.Contexts, auth.RefreshedLoginToken)
if err != nil {
return nil, err
9 unmodified lines
}
}
return api.NewClientWithBaseURL(target.token, target.coreURL), nil
case "cell":
client, err := auth.NewEntireAPICellClient(ctx, insecure, nil)
case apiTargetCell:
var target *auth.CellTarget
if jurisdiction != "" {
target = &auth.CellTarget{Jurisdiction: jurisdiction}
}
client, err := auth.NewEntireAPICellClient(ctx, insecure, target)
if err != nil {
return nil, err //nolint:wrapcheck // NewEntireAPICellClient already returns contextual auth errors
}
Mcmd/entire/cli/api_cmd.go+52/-9
141 unmodified lines
142
143
144
145
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
141 unmodified lines
func TestResolveAPIClient_UnknownTarget(t *testing.T) {
t.Parallel()
if _, err := resolveAPIClient(context.Background(), "banana", false); err == nil {
if _, err := resolveAPIClient(context.Background(), "banana", "", false); err == nil {
t.Error("expected error for unknown --to")
}
}
func TestResolveAPITarget(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
flags apiFlags
toExplicit bool
wantTo string
wantJuris string
wantErr bool
}{
// No --jurisdiction: --to is passed through untouched.
{"default", apiFlags{to: apiTargetCore}, false, apiTargetCore, "", false},
// --jurisdiction with default --to: implies cell, slug normalized to lowercase.
{"implied cell", apiFlags{to: apiTargetCore, jurisdiction: " US "}, false, apiTargetCell, "us", false},
// --jurisdiction with explicit --to cell: allowed.
{"explicit cell", apiFlags{to: apiTargetCell, jurisdiction: "eu"}, true, apiTargetCell, "eu", false},
// --jurisdiction with explicit --to core: contradiction, rejected.
{"contradiction", apiFlags{to: apiTargetCore, jurisdiction: "eu"}, true, "", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
to, j, err := resolveAPITarget(&tc.flags, tc.toExplicit)
if tc.wantErr {
if err == nil {
t.Fatalf("resolveAPITarget(%+v) = (%q, %q, nil), want error", tc.flags, to, j)
}
return
}
if err != nil || to != tc.wantTo || j != tc.wantJuris {
t.Fatalf("resolveAPITarget(%+v) = (%q, %q, %v), want (%q, %q, nil)", tc.flags, to, j, err, tc.wantTo, tc.wantJuris)
}
})
}
}
func TestWriteAPIResponse(t *testing.T) {
t.Parallel()
Mcmd/entire/cli/api_cmd_test.go+37/-1
412 unmodified lines
413
414
415
416
417
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
412 unmodified lines
if target != nil && strings.TrimSpace(target.BaseURL) != "" {
return strings.TrimRight(target.BaseURL, "/"), nil
}
if !isBFFOrigin(dataOrigin) {
// Already a cell URL, or a loopback local-dev host: keep it verbatim.
// The configured origin is kept verbatim when it isn't a BFF/apex fronting
// multiple cells — i.e. it's already a direct cell or a loopback dev host —
// EXCEPT when a jurisdiction is explicitly pinned (target.Jurisdiction, e.g.
// `entire api --jurisdiction eu`) against a non-loopback origin. A pinned
// jurisdiction may name a DIFFERENT cell than the configured direct-cell
// origin, so dialing that origin verbatim would send an identity token minted
// for the pinned jurisdiction to the wrong cell; resolve the pinned
// jurisdiction's own cell from the catalog instead. A loopback dev host serves
// a single cell with no jurisdiction catalog, so it always stays verbatim.
explicitJurisdiction := target != nil && strings.TrimSpace(target.Jurisdiction) != ""
if !isBFFOrigin(dataOrigin) && (!explicitJurisdiction || isLoopbackOrigin(dataOrigin)) {
return strings.TrimRight(dataOrigin, "/"), nil
}
return resolveCellAPIBaseURL(ctx, listCoreURL, loginJWT, jurisdiction, httpClient)
}
// isLoopbackOrigin reports whether origin's host is a loopback address, at any
// scheme (isLoopbackHTTP only accepts http). Used to keep a local-dev cell
// verbatim even when a jurisdiction is explicitly pinned.
func isLoopbackOrigin(origin string) bool {
u, err := url.Parse(origin)
if err != nil {
return false
}
return isLoopbackHost(strings.ToLower(u.Hostname()))
}
// isBFFOrigin reports whether origin is a BFF / apex host that fronts multiple
// cells (so the actual cell must be resolved from the cluster catalog), as
// opposed to a direct entire-api cell (host contains ".api.") or a loopback
Mcmd/entire/cli/auth/cell_data_api.go+22/-2
329 unmodified lines
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
329 unmodified lines
if got, err := resolveTargetCellBaseURL(ctx, &CellTarget{BaseURL: "https://eu.api.entire.io/"}, "https://entire.io", "eu", "https://eu.auth.entire.io", "login", nil); err != nil || got != "https://eu.api.entire.io" {
t.Fatalf("target override: got %q, %v", got, err)
}
// A loopback origin with an explicitly pinned jurisdiction stays verbatim:
// local dev serves a single cell with no jurisdiction catalog to consult.
if got, err := resolveTargetCellBaseURL(ctx, &CellTarget{Jurisdiction: "us"}, "http://127.0.0.1:8099", "us", "http://127.0.0.1:9000", "login", nil); err != nil || got != "http://127.0.0.1:8099" {
t.Fatalf("loopback + explicit jurisdiction: got %q, %v", got, err)
}
// A non-loopback DIRECT cell origin with an explicitly pinned jurisdiction
// must NOT be dialed verbatim (it may name a different jurisdiction's cell):
// resolve the pinned jurisdiction's own cell from the catalog instead.
catalog := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != clustersAPIPath {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"clusters":[{"jurisdiction":"eu","isDefault":true,"apiUrl":"https://eu.api.entire.io"}]}`)
}))
defer catalog.Close()
if got, err := resolveTargetCellBaseURL(ctx, &CellTarget{Jurisdiction: "eu"}, "https://aws-us-east-2.api.entire.io", "eu", catalog.URL, "login", catalog.Client()); err != nil || got != "https://eu.api.entire.io" {
t.Fatalf("direct cell + explicit jurisdiction: got %q, %v (want catalog-resolved eu cell)", got, err)
}
}
// TestNewEntireAPICellClient_TargetRoutesToRepoCell proves the repo-scoped path:
Mcmd/entire/cli/auth/cell_data_api_test.go+20
1 unmodified line
2
3
4
5
6
7
8
44 unmodified lines
53
54
55
56
57
58
59
60
61
62
63
64
57
58
65
66
67
68
60
69
70
62
63
71
72
73
74
75
76
77
78
69
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
23 unmodified lines
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
17 unmodified lines
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
1 unmodified line
import (
"context"
"net/url"
"sort"
"strings"
"sync"
44 unmodified lines
// includes the jurisdiction so entries whose index row carries no cell don't
// collapse across jurisdictions into one group routed by whichever repo came
// first — they stay per-jurisdiction and route via the jurisdiction fallback.
//
// When a RepoIndexEntry has Placements, each placement is added to the group
// for its own cell/jurisdiction with its placement-specific repo ID. This
// ensures mirror placements in other regions (e.g. a US-homed repo with an EU
// mirror) are searched in both cells — matching the BFF's fan-out behavior.
// When Placements is empty, the top-level Cell/Jurisdiction/ID are used as
// before (backward compat for index responses that predate placements).
func groupReposByCell(repos []coreapi.RepoIndexEntry) []cellGroup {
byCell := make(map[string]*cellGroup)
for _, r := range repos {
id := strings.TrimSpace(r.ID)
addToGroup := func(id, cell, jurisdiction, clusterSlug string) {
id = strings.TrimSpace(id)
if id == "" {
continue
return
}
cell := strings.ToLower(strings.TrimSpace(r.Cell))
jurisdiction := strings.ToLower(strings.TrimSpace(r.Jurisdiction))
cell = strings.ToLower(strings.TrimSpace(cell))
jurisdiction = strings.ToLower(strings.TrimSpace(jurisdiction))
clusterSlug = strings.ToLower(strings.TrimSpace(clusterSlug))
key := cell + "\x00" + jurisdiction
g, ok := byCell[key]
if !ok {
g = &cellGroup{
cell: cell,
clusterSlug: strings.ToLower(strings.TrimSpace(r.ClusterSlug)),
clusterSlug: clusterSlug,
jurisdiction: jurisdiction,
}
byCell[key] = g
}
// Upgrade an empty slug if a later entry provides one (a mirror
// placement may create the group before the home placement adds the
// slug).
if g.clusterSlug == "" && clusterSlug != "" {
g.clusterSlug = clusterSlug
}
g.repoIDs = append(g.repoIDs, id)
}
for _, r := range repos {
if len(r.Placements) > 0 {
for _, p := range r.Placements {
// Placements don't carry a cluster slug; the top-level slug
// applies only to the home placement. RepoPlacement.Mirror is
// the contract-guaranteed home(false)/mirror(true) marker, so
// assign the slug to the home placement and leave mirrors
// slugless — resolveCellBaseURLs falls back to cell/jurisdiction
// matching for groups without a slug. (Keying off Mirror rather
// than p.Cell == r.Cell means the join still works if the index
// omits the top-level Cell alongside the placement array.)
slug := ""
if !p.Mirror {
slug = r.ClusterSlug
}
addToGroup(p.ID, p.Cell, p.Jurisdiction, slug)
}
} else {
addToGroup(r.ID, r.Cell, r.Jurisdiction, r.ClusterSlug)
}
}
cells := make([]cellGroup, 0, len(byCell))
for _, g := range byCell {
cells = append(cells, *g)
23 unmodified lines
return
}
bySlug := make(map[string]coreapi.Cluster, len(clusters.Clusters))
byJurisdiction := make(map[string]coreapi.Cluster, len(clusters.Clusters))
for _, cl := range clusters.Clusters {
bySlug[strings.ToLower(strings.TrimSpace(cl.Slug))] = cl
// Prefer the default cluster per jurisdiction — matches the auth
// layer's resolution when routing by jurisdiction alone. A non-default
// cluster is kept only when no default has been seen yet.
j := strings.ToLower(strings.TrimSpace(cl.Jurisdiction))
if j != "" {
existing, exists := byJurisdiction[j]
if !exists || (cl.IsDefault && !existing.IsDefault) {
byJurisdiction[j] = cl
}
}
}
for i := range cells {
cl, ok := bySlug[cells[i].clusterSlug]
if !ok && cells[i].cell != "" {
// Try matching the group's cell name against catalog apiUrl
// hosts (e.g. cell "aws-eu-central-1" matches
// "https://aws-eu-central-1.api.entire.io"). This is more
// precise than jurisdiction when a jurisdiction has multiple
// cells — mirroring matchClusterByHost in cell_target.go.
cl, ok = matchClusterByCellInURL(clusters.Clusters, cells[i].cell)
}
if !ok && cells[i].jurisdiction != "" {
// Last resort: jurisdiction-level fallback using the default
// cluster. Less precise, but still routes to the right
// jurisdiction when the cell name doesn't appear in any URL.
if cl, ok = byJurisdiction[cells[i].jurisdiction]; ok {
// This binds the group to the jurisdiction's DEFAULT cluster,
// which may not be the cell hosting this placement's repo. If
// the placement lives in a non-default cell of the jurisdiction
// the query can hit a cell that returns nothing — a silent
// mirror miss. Log it so such a miss is diagnosable.
logging.Debug(ctx, "cell fan-out: jurisdiction-default fallback used (cell name not in any catalog URL); may mis-route within jurisdiction",
"cell", cells[i].cell, "jurisdiction", cells[i].jurisdiction, "resolved_cluster", cl.Slug)
}
}
if !ok {
logging.Debug(ctx, "cell fan-out: cluster not in catalog, using jurisdiction routing",
"cluster_slug", cells[i].clusterSlug, "cell", cells[i].cell)
17 unmodified lines
}
}
// matchClusterByCellInURL finds a catalog cluster whose ApiUrl or PublicUrl
// host contains the cell name as a prefix (e.g. cell "aws-eu-central-1"
// matches "https://aws-eu-central-1.api.entire.io"). This is more precise
// than a jurisdiction-level fallback when multiple clusters share a
// jurisdiction — each cluster serves a different cell.
func matchClusterByCellInURL(clusters []coreapi.Cluster, cell string) (coreapi.Cluster, bool) {
prefix := strings.ToLower(strings.TrimSpace(cell)) + "."
for _, cl := range clusters {
for _, rawURL := range []string{cl.ApiUrl.Or(""), cl.PublicUrl} {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
continue
}
u, err := url.Parse(rawURL)
if err != nil {
continue
}
if strings.HasPrefix(strings.ToLower(u.Hostname()), prefix) {
return cl, true
}
}
}
return coreapi.Cluster{}, false
}
// cellTarget converts the group's routing coordinates into the auth layer's
// CellTarget: full target when the catalog resolved a baseURL,
// jurisdiction-only when it didn't, nil (home routing) when neither is known.
Mcmd/entire/cli/cell_fanout.go+103/-6
46 unmodified lines
47
48
49
50
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
154
155
156
157
158
159
36 unmodified lines
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
46 unmodified lines
if got := strings.Join(us.repoIDs, ","); got != "01B,01C" {
t.Fatalf("us repoIDs = %q, want 01B,01C", got)
}
if us.clusterSlug != "us-prod" || us.jurisdiction != "us" {
if us.clusterSlug != testClusterSlugUS || us.jurisdiction != "us" {
t.Fatalf("us group coordinates = %+v, want us-prod/us", us)
}
}
// TestGroupReposByCell_Placements verifies that when a repo has Placements,
// each placement is grouped into its own cell group with the placement-specific
// repo ID. This is the fix for cross-region fan-out: a US-homed repo with an
// EU mirror produces two cell groups so both cells are searched.
func TestGroupReposByCell_Placements(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{
// US-homed repo with an EU mirror — the real-world scenario.
ID: "01US", Cell: "aws-us-east-2", ClusterSlug: "us-prod", Jurisdiction: "us",
Placements: []coreapi.RepoPlacement{
{ID: "01US", Cell: "aws-us-east-2", Jurisdiction: "us"},
{ID: "01EU", Cell: "aws-eu-central-1", Jurisdiction: "eu", Mirror: true},
},
},
{
// Repo without placements (legacy index) — top-level fields used.
ID: "01LEGACY", Cell: "aws-us-east-2", ClusterSlug: "us-prod", Jurisdiction: "us",
},
}
cells := groupReposByCell(repos)
if len(cells) != 2 {
t.Fatalf("groups = %d, want 2 (one per cell): %+v", len(cells), cells)
}
// Sorted: aws-eu-central-1 < aws-us-east-2.
eu := cells[0]
us := cells[1]
if eu.cell != "aws-eu-central-1" || eu.jurisdiction != "eu" {
t.Fatalf("eu group = %+v", eu)
}
if got := strings.Join(eu.repoIDs, ","); got != "01EU" {
t.Fatalf("eu repoIDs = %q, want 01EU", got)
}
// EU placement has no cluster slug (cell differs from top-level).
if eu.clusterSlug != "" {
t.Fatalf("eu clusterSlug = %q, want empty", eu.clusterSlug)
}
if us.cell != "aws-us-east-2" || us.jurisdiction != "us" {
t.Fatalf("us group = %+v", us)
}
// US group has both the placement ID and the legacy entry.
if got := strings.Join(us.repoIDs, ","); got != "01US,01LEGACY" {
t.Fatalf("us repoIDs = %q, want 01US,01LEGACY", got)
}
// Home placement inherits the top-level cluster slug.
if us.clusterSlug != testClusterSlugUS {
t.Fatalf("us clusterSlug = %q, want us-prod", us.clusterSlug)
}
}
// TestGroupReposByCell_PlacementEmptyID verifies that placements with empty IDs
// are skipped, matching the top-level behavior.
func TestGroupReposByCell_PlacementEmptyID(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{
ID: "01A", Cell: "aws-us-east-2", Jurisdiction: "us",
Placements: []coreapi.RepoPlacement{
{ID: "", Cell: "aws-us-east-2", Jurisdiction: "us"}, // empty ID → skipped
},
},
}
cells := groupReposByCell(repos)
if len(cells) != 0 {
t.Fatalf("groups = %d, want 0 (all placement IDs empty): %+v", len(cells), cells)
}
}
// TestGroupReposByCell_PlacementSlugFromMirrorFlag verifies the home
// placement's cluster slug is assigned via RepoPlacement.Mirror rather than by
// string-matching the top-level Cell. When the index omits the top-level Cell
// alongside the placement array, the string-match would find no home and drop
// every group to the fuzzier fallback; keying off Mirror keeps the precise
// slug->catalog join.
func TestGroupReposByCell_PlacementSlugFromMirrorFlag(t *testing.T) {
t.Parallel()
repos := []coreapi.RepoIndexEntry{
{
// Top-level Cell intentionally empty; the home placement is
// identified by Mirror=false, not by matching the top-level Cell.
ID: "01US", ClusterSlug: "us-prod", Jurisdiction: "us",
Placements: []coreapi.RepoPlacement{
{ID: "01US", Cell: "aws-us-east-2", Jurisdiction: "us", Mirror: false},
{ID: "01EU", Cell: "aws-eu-central-1", Jurisdiction: "eu", Mirror: true},
},
},
}
cells := groupReposByCell(repos)
if len(cells) != 2 {
t.Fatalf("groups = %d, want 2: %+v", len(cells), cells)
}
// Sorted: aws-eu-central-1 < aws-us-east-2.
eu := cells[0]
us := cells[1]
if us.cell != "aws-us-east-2" || us.clusterSlug != testClusterSlugUS {
t.Fatalf("home group = %+v, want cell aws-us-east-2 with slug us-prod", us)
}
if eu.cell != "aws-eu-central-1" || eu.clusterSlug != "" {
t.Fatalf("mirror group = %+v, want cell aws-eu-central-1 with empty slug", eu)
}
}
// TestResolveCellBaseURLs_RefusesBaseURLWithoutJurisdiction pins the guard: a
// concrete baseURL is only usable together with the jurisdiction its token
// must be minted for; a catalog row with no jurisdiction leaves the group on
36 unmodified lines
}
}
// TestResolveCellBaseURLs_JurisdictionFallbackForPlacements verifies that
// groups without a cluster slug (from placement-derived groups) resolve their
// baseURL via jurisdiction matching against the cluster catalog.
func TestResolveCellBaseURLs_JurisdictionFallbackForPlacements(t *testing.T) {
t.Parallel()
cells := []cellGroup{
// Home group with slug — resolved via slug join.
{cell: "aws-us-east-2", clusterSlug: "us-prod", jurisdiction: "us"},
// Mirror group without slug — must fall back to jurisdiction join.
{cell: "aws-eu-central-1", clusterSlug: "", jurisdiction: "eu"},
}
fake := &fakeCellCore{clusters: []coreapi.Cluster{
{Slug: "us-prod", Jurisdiction: "us", ApiUrl: coreapi.NewOptString("https://aws-us-east-2.api.entire.io")},
{Slug: "eu-prod", Jurisdiction: "eu", ApiUrl: coreapi.NewOptString("https://aws-eu-central-1.api.entire.io")},
}}
resolveCellBaseURLs(context.Background(), fake, cells)
if cells[0].baseURL != "https://aws-us-east-2.api.entire.io" {
t.Fatalf("us baseURL = %q, want resolved via slug", cells[0].baseURL)
}
if cells[1].baseURL != "https://aws-eu-central-1.api.entire.io" {
t.Fatalf("eu baseURL = %q, want resolved via jurisdiction fallback", cells[1].baseURL)
}
}
// TestResolveCellBaseURLs_CellURLMatchOverJurisdiction verifies that when a
// jurisdiction has multiple clusters, the resolver matches the group's cell
// name against cluster ApiUrl hosts rather than picking an arbitrary one.
// This prevents binding a mirror group to the wrong cell's baseURL.
func TestResolveCellBaseURLs_CellURLMatchOverJurisdiction(t *testing.T) {
t.Parallel()
cells := []cellGroup{
// Mirror group whose cell name appears in the second cluster's URL.
{cell: "aws-eu-central-1", clusterSlug: "", jurisdiction: "eu"},
}
fake := &fakeCellCore{clusters: []coreapi.Cluster{
// Different EU cell — must NOT be picked even though it's first and default.
{Slug: "eu-west-prod", Jurisdiction: "eu", IsDefault: true, ApiUrl: coreapi.NewOptString("https://aws-eu-west-1.api.entire.io")},
// Matching cell — should be picked by cell-URL matching.
{Slug: "eu-central-prod", Jurisdiction: "eu", ApiUrl: coreapi.NewOptString("https://aws-eu-central-1.api.entire.io")},
}}
resolveCellBaseURLs(context.Background(), fake, cells)
if cells[0].baseURL != "https://aws-eu-central-1.api.entire.io" {
t.Fatalf("eu baseURL = %q, want cell-matched URL, not default cluster", cells[0].baseURL)
}
}
// TestResolveCellBaseURLs_JurisdictionFallbackPrefersDefault verifies that
// when cell-URL matching doesn't find a match, the jurisdiction fallback
// picks the cluster with IsDefault=true.
func TestResolveCellBaseURLs_JurisdictionFallbackPrefersDefault(t *testing.T) {
t.Parallel()
cells := []cellGroup{
// Cell name doesn't appear in any cluster URL — falls through to jurisdiction.
{cell: "aws-eu-unknown-1", clusterSlug: "", jurisdiction: "eu"},
}
fake := &fakeCellCore{clusters: []coreapi.Cluster{
// Non-default listed first — must not win.
{Slug: "eu-staging", Jurisdiction: "eu", ApiUrl: coreapi.NewOptString("https://eu-staging.api.entire.io")},
// Default cluster — should be preferred.
{Slug: "eu-prod", Jurisdiction: "eu", IsDefault: true, ApiUrl: coreapi.NewOptString("https://eu-default.api.entire.io")},
}}
resolveCellBaseURLs(context.Background(), fake, cells)
if cells[0].baseURL != "https://eu-default.api.entire.io" {
t.Fatalf("eu baseURL = %q, want default cluster's URL", cells[0].baseURL)
}
}
func TestResolveCellBaseURLs_CatalogErrorLeavesJurisdictionRouting(t *testing.T) {
t.Parallel()
cells := []cellGroup{{cell: euWestCell, clusterSlug: "eu-prod", jurisdiction: "eu"}}
Mcmd/entire/cli/cell_fanout_test.go+170/-1
138 unmodified lines
139
140
141
142
143
142
143
144
146
147
148
145
146
147
138 unmodified lines
// the primary's record through the repo and its refs, so a non-git-backed
// primary is rejected rather than silently half-supported.
func buildPrimary(ctx context.Context, env OpenEnv, typ string, raw json.RawMessage) (PersistentStore, error) {
b, err := lookupBackend(typ)
if err != nil {
if err := ValidatePrimaryBackend(typ); err != nil {
return nil, fmt.Errorf("checkpoints.primary: %w", err)
}
if !b.gitBacked {
return nil, fmt.Errorf("checkpoints.primary.type %q cannot be the primary: only git-backed backends (e.g. %q) may be the primary", typ, BackendTypeGitBranch)
}
return build(ctx, env, typ, raw)
}
Mcmd/entire/cli/checkpoint/open.go+1/-5
92 unmodified lines
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
92 unmodified lines
return b, nil
}
// ValidatePrimaryBackend reports an error unless typ names a registered backend
// that may serve as the primary. An unknown type is rejected with an error
// listing the registered backend types; a registered but non-git-backed type is
// rejected separately, since only git-backed backends may be the primary. This
// is the single source of the "primary must be git-backed" rule — buildPrimary
// delegates here, and selection surfaces (entire enable / configure
// --checkpoint-backend) call it to reject a bad backend before writing it to
// settings, rather than failing later in Open.
func ValidatePrimaryBackend(typ string) error {
b, err := lookupBackend(typ)
if err != nil {
return err
}
if !b.gitBacked {
return fmt.Errorf("checkpoint backend %q cannot be the primary: only git-backed backends (e.g. %q, %q) may be the primary", typ, BackendTypeGitBranch, BackendTypeGitRefs)
}
return nil
}
// build constructs the store for the named backend type.
func build(ctx context.Context, env OpenEnv, typ string, cfg json.RawMessage) (PersistentStore, error) {
b, err := lookupBackend(typ)
Mcmd/entire/cli/checkpoint/registry.go+19
36 unmodified lines
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
36 unmodified lines
assert.Contains(t, err.Error(), BackendTypeGitBranch)
}
func TestValidatePrimaryBackend_GitBackedTypesAllowed(t *testing.T) {
t.Parallel()
require.NoError(t, ValidatePrimaryBackend(BackendTypeGitBranch))
require.NoError(t, ValidatePrimaryBackend(BackendTypeGitRefs))
}
func TestValidatePrimaryBackend_UnknownTypeRejected(t *testing.T) {
t.Parallel()
err := ValidatePrimaryBackend("definitely-not-a-backend")
require.Error(t, err)
assert.Contains(t, err.Error(), `unknown checkpoint backend type "definitely-not-a-backend"`)
// The error lists registered types so a typo is debuggable.
assert.Contains(t, err.Error(), BackendTypeGitBranch)
}
func TestValidatePrimaryBackend_NonGitBackedRejected(t *testing.T) {
t.Parallel()
// Register a mirror-only (non-git-backed) backend and confirm it cannot be the
// primary. The unique type name avoids colliding with the built-ins.
const typ = "test-mirror-only-primary-check"
Register(typ, func(context.Context, OpenEnv, json.RawMessage) (PersistentStore, error) {
return nil, nil //nolint:nilnil // never constructed; validation fails before build
})
err := ValidatePrimaryBackend(typ)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be the primary")
assert.Contains(t, err.Error(), BackendTypeGitRefs)
}
func TestRegistry_GitBranchFactoryIgnoresConfig(t *testing.T) {
t.Parallel()
Mcmd/entire/cli/checkpoint/registry_test.go+33
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
package cli
import (
"context"
"fmt"
"io"
"slices"
"strings"
"charm.land/huh/v2"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/settings"
)
// Friendly aliases for the two selectable checkpoint backends. Users type these
// on --checkpoint-backend; they map to the canonical backend types stored in
// settings (checkpoint.BackendTypeGitBranch / checkpoint.BackendTypeGitRefs).
const (
checkpointBackendBranchAlias = "branch"
checkpointBackendRefsAlias = "refs"
)
// resolveCheckpointBackendType maps a user-facing backend name to the canonical
// settings backend type and validates it may serve as the primary. It accepts
// the friendly aliases "branch"/"refs" and the canonical "git-branch"/"git-refs"
// (case-insensitive). An unknown or non-git-backed value is rejected via the
// checkpoint registry, so the error text stays in sync with the backend list.
func resolveCheckpointBackendType(name string) (string, error) {
typ := strings.ToLower(strings.TrimSpace(name))
switch typ {
case checkpointBackendBranchAlias:
typ = checkpoint.BackendTypeGitBranch
case checkpointBackendRefsAlias:
typ = checkpoint.BackendTypeGitRefs
}
if err := checkpoint.ValidatePrimaryBackend(typ); err != nil {
return "", fmt.Errorf("invalid --%s: %w", flagCheckpointBackend, err)
}
return typ, nil
}
// applyCheckpointBackend sets the primary checkpoint backend on settings,
// preserving any existing mirrors except one whose type would collide with the
// new primary (the one-of-each-type topology rule enforced in checkpoint.Open).
// Switching the primary on an existing repo is safe: new checkpoints use the new
// backend while read routing keeps prior checkpoints readable in their original
// format.
func applyCheckpointBackend(s *EntireSettings, typ string) {
cfg := s.Checkpoints
if cfg == nil {
cfg = &settings.CheckpointsConfig{}
}
cfg.Primary = settings.BackendConfig{Type: typ}
cfg.Mirrors = slices.DeleteFunc(cfg.Mirrors, func(m settings.BackendConfig) bool {
return m.Type == typ
})
s.Checkpoints = cfg
}
// applyCheckpointBackendFlag resolves and applies a --checkpoint-backend value to
// settings when it is non-empty; a no-op otherwise. Used by the fresh-repo enable
// paths (interactive setup and --agent), which mutate an in-memory settings
// object before their own save. Existing-repo enable and configure use
// updateCheckpointBackend instead.
func applyCheckpointBackendFlag(s *EntireSettings, backend string) error {
if backend == "" {
return nil
}
typ, err := resolveCheckpointBackendType(backend)
if err != nil {
return err
}
applyCheckpointBackend(s, typ)
return nil
}
// updateCheckpointBackend persists opts.CheckpointBackend to the target settings
// file. Used by `entire configure` and by `entire enable` on repos that are
// already set up (both operate on an on-disk file rather than the in-memory
// settings the fresh-setup flow builds).
func updateCheckpointBackend(ctx context.Context, w io.Writer, opts EnableOptions) error {
typ, err := resolveCheckpointBackendType(opts.CheckpointBackend)
if err != nil {
return err
}
targetFile, configDisplay := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings)
targetFileAbs, err := paths.AbsPath(ctx, targetFile)
if err != nil {
targetFileAbs = targetFile
}
s, err := settings.LoadFromFile(targetFileAbs)
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
applyCheckpointBackend(s, typ)
if err := saveSettingsToTarget(ctx, s, targetFile); err != nil {
return fmt.Errorf("failed to save settings: %w", err)
}
fmt.Fprintf(w, "✓ Checkpoint backend set to %s (%s)\n", typ, configDisplay)
return nil
}
// promptCheckpointBackend asks the user to choose a checkpoint storage backend
// during first-time interactive setup. The default is the git-branch backend;
// the git-refs backend is offered as the selectable alternative. It returns the
// canonical backend type, or "" when the user kept the default (or cancelled) so
// the caller can skip writing a redundant config block. Callers must gate this
// on an interactive terminal.
//
// Cancellation (Ctrl+C or a cancelled ctx) is treated like keeping the default:
// it prints a "cancelled" line and returns ("", nil) so enable continues with
// the default backend, matching the optional-prompt behavior elsewhere in setup.
func promptCheckpointBackend(ctx context.Context, w io.Writer) (string, error) {
choice := checkpoint.BackendTypeGitBranch
form := NewAccessibleForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Checkpoint storage backend").
Description("How Entire stores committed session checkpoints in your repo.").
Options(
huh.NewOption("Branch — one shared branch, entire/checkpoints/v1 (default)", checkpoint.BackendTypeGitBranch),
huh.NewOption("Refs — one git ref per checkpoint", checkpoint.BackendTypeGitRefs),
).
Value(&choice),
),
)
if err := form.RunWithContext(ctx); err != nil {
return "", handleFormCancellation(w, "Checkpoint backend selection", err)
}
if choice == checkpoint.BackendTypeGitBranch {
return "", nil
}
return choice, nil
}
Acmd/entire/cli/checkpoint_backend.go+141
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
package cli
import (
"context"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/settings"
)
func TestResolveCheckpointBackendType(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want string
wantErr bool
}{
{in: "branch", want: checkpoint.BackendTypeGitBranch},
{in: "refs", want: checkpoint.BackendTypeGitRefs},
{in: "git-branch", want: checkpoint.BackendTypeGitBranch},
{in: "git-refs", want: checkpoint.BackendTypeGitRefs},
{in: " REFS ", want: checkpoint.BackendTypeGitRefs}, // trimmed + case-insensitive
{in: "Branch", want: checkpoint.BackendTypeGitBranch},
{in: "", wantErr: true},
{in: "bogus", wantErr: true},
}
for _, tc := range tests {
got, err := resolveCheckpointBackendType(tc.in)
if tc.wantErr {
require.Error(t, err, "input %q", tc.in)
continue
}
require.NoError(t, err, "input %q", tc.in)
assert.Equal(t, tc.want, got, "input %q", tc.in)
}
}
func TestApplyCheckpointBackend_SetsPrimaryFromNil(t *testing.T) {
t.Parallel()
s := &EntireSettings{}
applyCheckpointBackend(s, checkpoint.BackendTypeGitRefs)
require.NotNil(t, s.Checkpoints)
assert.Equal(t, checkpoint.BackendTypeGitRefs, s.Checkpoints.Primary.Type)
assert.Empty(t, s.Checkpoints.Mirrors)
}
func TestApplyCheckpointBackend_PreservesUnrelatedMirror(t *testing.T) {
t.Parallel()
s := &EntireSettings{Checkpoints: &settings.CheckpointsConfig{
Primary: settings.BackendConfig{Type: checkpoint.BackendTypeGitBranch},
Mirrors: []settings.BackendConfig{{Type: "fs"}},
}}
applyCheckpointBackend(s, checkpoint.BackendTypeGitRefs)
assert.Equal(t, checkpoint.BackendTypeGitRefs, s.Checkpoints.Primary.Type)
require.Len(t, s.Checkpoints.Mirrors, 1)
assert.Equal(t, "fs", s.Checkpoints.Mirrors[0].Type)
}
func TestApplyCheckpointBackend_DropsCollidingMirror(t *testing.T) {
t.Parallel()
// A git-refs mirror alongside a git-branch primary is valid; promoting the
// primary to git-refs would collide (one-of-each-type), so the mirror is dropped.
s := &EntireSettings{Checkpoints: &settings.CheckpointsConfig{
Primary: settings.BackendConfig{Type: checkpoint.BackendTypeGitBranch},
Mirrors: []settings.BackendConfig{{Type: checkpoint.BackendTypeGitRefs}},
}}
applyCheckpointBackend(s, checkpoint.BackendTypeGitRefs)
assert.Equal(t, checkpoint.BackendTypeGitRefs, s.Checkpoints.Primary.Type)
assert.Empty(t, s.Checkpoints.Mirrors, "mirror colliding with the new primary must be dropped")
}
func TestApplyCheckpointBackendFlag_EmptyIsNoOp(t *testing.T) {
t.Parallel()
s := &EntireSettings{}
require.NoError(t, applyCheckpointBackendFlag(s, ""))
assert.Nil(t, s.Checkpoints, "empty flag must not write a checkpoints block")
}
func TestApplyCheckpointBackendFlag_Invalid(t *testing.T) {
t.Parallel()
s := &EntireSettings{}
err := applyCheckpointBackendFlag(s, "bogus")
require.Error(t, err)
assert.Contains(t, err.Error(), flagCheckpointBackend)
assert.Nil(t, s.Checkpoints)
}
func TestUpdateCheckpointBackend_WritesAndReloads(t *testing.T) {
// Uses t.Chdir (process-global cwd), so no t.Parallel.
tmpDir := t.TempDir()
t.Chdir(tmpDir)
ctx := context.Background()
require.NoError(t, updateCheckpointBackend(ctx, io.Discard, EnableOptions{CheckpointBackend: "refs"}))
cfg, err := settings.LoadCheckpointsConfig(ctx)
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, checkpoint.BackendTypeGitRefs, cfg.Primary.Type)
assert.True(t, checkpoint.PrimaryIsRefs(cfg))
// Switching back to branch overrides the prior selection.
require.NoError(t, updateCheckpointBackend(ctx, io.Discard, EnableOptions{CheckpointBackend: "branch"}))
cfg, err = settings.LoadCheckpointsConfig(ctx)
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, checkpoint.BackendTypeGitBranch, cfg.Primary.Type)
assert.False(t, checkpoint.PrimaryIsRefs(cfg))
}
func TestUpdateCheckpointBackend_InvalidValue(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
err := updateCheckpointBackend(context.Background(), io.Discard, EnableOptions{CheckpointBackend: "bogus"})
require.Error(t, err)
assert.Contains(t, err.Error(), flagCheckpointBackend)
}
Acmd/entire/cli/checkpoint_backend_test.go+131
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
20 unmodified lines
21
22
23
24
24
25
27
26
27
28
29
30
31
32
33
34
35
29
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
176 unmodified lines
229
230
231
218
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
2 unmodified lines
254
255
256
227
257
258
259
260
153 unmodified lines
414
415
416
417
418
419
420
421
422
423
424
425
14 unmodified lines
440
441
442
407
443
444
445
446
20 unmodified lines
// addControlPlaneFlags registers the persistent flags shared by every
// control-plane command group. Persistent so they're inherited by nested
// subcommands (e.g. `entire repo mirror list`):
// - --json: emit the raw wire JSON instead of the default human table.
// - --insecure-http-auth: permit the token exchange over plain http://
// (local/dev deployments where the core isn't behind TLS). Hidden, as
// elsewhere in the CLI.
// elsewhere in the CLI. Applies to every subcommand because they all build
// a control-plane client.
//
// --json is deliberately NOT persistent here: it only makes sense on the read
// and mutation verbs that render a wire payload, so it's registered per-command
// with addJSONFlag. A persistent --json was inherited by side-effect verbs
// (delete, clone, mirror create/remove, grant remove) that silently ignored it;
// cobra can't hide a persistent flag from a subset of children, so the flag
// lives on exactly the commands that honor it.
func addControlPlaneFlags(cmd *cobra.Command) {
cmd.PersistentFlags().Bool("json", false, "Output raw JSON instead of a table")
cmd.PersistentFlags().Bool("insecure-http-auth", false, "Allow authentication over plain HTTP (insecure, for local development only)")
if err := cmd.PersistentFlags().MarkHidden("insecure-http-auth"); err != nil {
panic(fmt.Sprintf("hide insecure-http-auth flag: %v", err))
}
}
// addJSONFlag registers the local --json flag on a command that renders a wire
// payload (list/get/create/mutation verbs routed through the runCore* helpers).
// Local, not persistent, so only these commands advertise and accept it — see
// addControlPlaneFlags for why. Read it with jsonRequested.
func addJSONFlag(cmd *cobra.Command) {
cmd.Flags().Bool("json", false, "Output raw JSON instead of a table")
}
// jsonRequested reports whether --json was set on cmd or an ancestor. A
// lookup error means the flag isn't defined on this command tree, which is
// treated as "not requested".
176 unmodified lines
// field/value list (default) or raw JSON (--json), reusing the same column
// definition as the matching list view.
func runCoreObject[T any](cmd *cobra.Command, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) (*T, error)) error {
return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error {
return runCore(cmd, renderCoreObject(cmd, headers, row, fn))
}
// runCoreObjectForCluster is runCoreObject for a resource-provider command (see
// runCoreForCluster): identical field/JSON rendering, but dialing the core that
// fronts clusterHost rather than the active context.
func runCoreObjectForCluster[T any](cmd *cobra.Command, clusterHost string, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) (*T, error)) error {
return runCoreForCluster(cmd, clusterHost, renderCoreObject(cmd, headers, row, fn))
}
// renderCoreObject builds the run-function shared by runCoreObject and
// runCoreObjectForCluster: fetch via fn, then render as a field/value list
// (default) or raw JSON (--json). Kept separate from the client-selection so
// the two object variants differ only in which core they dial (mirroring
// renderCoreList).
func renderCoreObject[T any](cmd *cobra.Command, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) (*T, error)) func(context.Context, *coreapi.Client) error {
return func(ctx context.Context, c *coreapi.Client) error {
item, err := fn(ctx, c)
if err != nil {
return err
2 unmodified lines
return printJSON(cmd.OutOrStdout(), item)
}
return printFields(cmd.OutOrStdout(), headers, row(*item))
})
}
}
// tableStyles holds the foreground styles for the human table/field views,
153 unmodified lines
// without standing up the auth/context/TLS stack.
var activeCoreClient = func(context.Context) (*coreapi.Client, error) { return coreapi.New() }
// clusterCoreClient builds the control-plane client for cluster-addressed
// commands (see runCoreForCluster). Same test seam as activeCoreClient —
// production wiring is coreapi.NewForCluster, which does live /.well-known
// discovery that command-level tests must not reach.
var clusterCoreClient func(ctx context.Context, clusterHost string) (*coreapi.Client, error) = coreapi.NewForCluster
// runCore is the shared base for every active-context control-plane command:
// it owns the preamble only — silence usage, build the client, map API
// errors — and leaves all rendering to fn. The delete/revoke verbs call it
14 unmodified lines
// cluster_host". See coreapi.NewForCluster.
func runCoreForCluster(cmd *cobra.Command, clusterHost string, fn func(ctx context.Context, c *coreapi.Client) error) error {
return runCoreClient(cmd, func(ctx context.Context) (*coreapi.Client, error) {
return coreapi.NewForCluster(ctx, clusterHost)
return clusterCoreClient(ctx, clusterHost)
}, fn)
}
Mcmd/entire/cli/corecmd.go+42/-6
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
package cli
import (
"sort"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
// TestControlPlaneJSONFlag_OnlyOnHonoringCommands pins the structural fix that
// moved --json off the shared control-plane persistent flag and onto a local
// flag registered only where the command actually renders JSON.
//
// The old design registered --json persistently on each group root, so it was
// inherited by every subcommand — including side-effect verbs (delete, clone,
// mirror create/remove, grant remove) that ignored it, silently accepting a
// no-op flag. Now the flag exists exactly on the commands that honor it, so the
// non-honoring commands reject --json with "unknown flag" and their help never
// advertises it.
func TestControlPlaneJSONFlag_OnlyOnHonoringCommands(t *testing.T) {
t.Parallel()
// path (relative to the group root) -> honors --json.
want := map[string]bool{
// org
"org create": true,
"org list": true,
"org get": true,
"org delete": false,
// project
"project create": true,
"project list": true,
"project get": true,
"project delete": false,
// repo
"repo create": true,
"repo list": true,
"repo get": true,
"repo delete": false,
"repo clone": false,
"repo mirror create": false,
"repo mirror list": true,
"repo mirror get": true,
"repo mirror remove": false,
"repo mirror collaborators list": true,
"repo visibility get": true,
"repo visibility set": true,
// grant
"grant org add": true,
"grant org list": true,
"grant org remove": false,
"grant project add": true,
"grant project list": true,
"grant project remove": false,
"grant repo add": true,
"grant repo list": true,
"grant repo remove": false,
}
got := map[string]bool{}
for _, root := range []*cobra.Command{newOrgCmd(), newProjectCmd(), newRepoCmd(), newGrantCmd()} {
collectJSONFlag(t, root, root.Name(), got)
}
// Every command we expect an answer for must exist in the tree, and vice
// versa — a drift in either direction (renamed/removed command, or a new
// leaf we forgot to classify) should fail loudly.
require.Equal(t, sortedKeys(want), sortedKeys(got), "command tree drifted from the expected --json map")
for path, expected := range want {
require.Equal(t, expected, got[path], "command %q: --json presence mismatch", path)
}
}
// collectJSONFlag walks the command tree rooted at cmd, recording for each leaf
// command whether --json is visible on it (local flags merged with inherited).
func collectJSONFlag(t *testing.T, cmd *cobra.Command, path string, out map[string]bool) {
t.Helper()
children := cmd.Commands()
if len(children) == 0 {
// Merge parent persistent flags so an accidentally-inherited --json is
// still caught here, not just a locally-registered one.
out[path] = cmd.Flags().Lookup("json") != nil || cmd.InheritedFlags().Lookup("json") != nil
return
}
for _, child := range children {
collectJSONFlag(t, child, path+" "+child.Name(), out)
}
}
func sortedKeys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
Acmd/entire/cli/corecmd_json_flag_test.go+98
37 unmodified lines
38
39
40
41
42
41
42
43
44
45
37 unmodified lines
// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam.
func TestRunCoreList_EmptyJSONIsArray(t *testing.T) {
srv := serveOrgList(t, nil)
// org list's --json is persistent on the group root, so drive the full
// group command with "list" as a subcommand arg.
// Drive the full group command so the test covers Cobra's command-tree
// wiring as well as the leaf's local --json flag.
out, _, err := runCoreCmd(t, newOrgCmd, srv.URL, "list", "--json")
require.NoError(t, err)
require.JSONEq(t, "[]", out, "empty --json list must be [], not null")
Mcmd/entire/cli/corecmd_list_test.go+2/-2
40 unmodified lines
41
42
43
44
45
44
45
46
47
48
40 unmodified lines
// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam.
func TestOrgCreate_JSONOnRequest(t *testing.T) {
srv := newCreateOrgServer(t)
// org create's --json is persistent on the group root, so drive the
// full group command with "create" as a subcommand arg.
// Drive the full group command so the test covers Cobra's command-tree
// wiring as well as the leaf's local --json flag.
out, _, err := runCoreCmd(t, newOrgCmd, srv.URL, "create", "acme", "--json")
require.NoError(t, err)
require.Contains(t, out, `"name": "acme"`)
Mcmd/entire/cli/corecmd_mutation_test.go+2/-2
41 unmodified lines
42
43
44
45
45
46
47
47
48
49
50
1 unmodified line
52
53
54
55
56
57
55
56
57
58
59
85 unmodified lines
145
146
147
148
149
150
151
152
153
153
154
155
156
17 unmodified lines
174
175
176
177
178
179
180
181
76 unmodified lines
258
259
260
261
262
263
264
265
263
266
267
268
269
17 unmodified lines
287
288
289
290
291
292
293
294
115 unmodified lines
410
411
412
413
414
415
416
24 unmodified lines
441
442
443
444
445
446
447
41 unmodified lines
}
}
// newGrantCmd is the hidden `entire grant` command group: manage access
// newGrantCmd is the `entire grant` command group: manage access
// grants and org membership on the Entire control plane. Org, project, and
// repo each support add / list / remove. Surfaced via `entire labs`.
// repo each support add / list / remove.
//
// Grantees are addressed by a provider-qualified handle (e.g. github:alice),
// which the CLI resolves to the provider account behind the scenes. `remove`
1 unmodified line
// repo) are addressed by name or ULID.
func newGrantCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "grant",
Short: "Manage Entire access grants and org membership",
Hidden: true,
Use: "grant",
Short: "Manage Entire access grants and org membership",
}
addControlPlaneFlags(cmd)
cmd.AddCommand(newGrantOrgCmd())
85 unmodified lines
},
}
cmd.Flags().StringVar(&role, "role", "", "Org role: owner, admin, or member (default member)")
addJSONFlag(cmd)
return cmd
}
func newGrantOrgListCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "list <org>",
Short: "List org members",
Args: cobra.ExactArgs(1),
17 unmodified lines
})
},
}
addJSONFlag(cmd)
return cmd
}
func newGrantOrgRemoveCmd() *cobra.Command {
76 unmodified lines
}
cmd.Flags().StringVar(&role, "role", "", "Project role: reader, writer, or admin (required)")
markRequired(cmd, "role")
addJSONFlag(cmd)
return cmd
}
func newGrantProjectListCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "list <project>",
Short: "List project members",
Args: cobra.ExactArgs(1),
17 unmodified lines
})
},
}
addJSONFlag(cmd)
return cmd
}
func newGrantProjectRemoveCmd() *cobra.Command {
115 unmodified lines
cmd.Flags().StringVar(&role, "role", "", "Repo role: reader, writer, or admin (required)")
bindRepoProjectFlag(cmd, &project)
markRequired(cmd, "role")
addJSONFlag(cmd)
return cmd
}
24 unmodified lines
},
}
bindRepoProjectFlag(cmd, &project)
addJSONFlag(cmd)
return cmd
}
Mcmd/entire/cli/grant.go+14/-7
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
//go:build integration
package integration
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// claudeImportFixture is a two-turn Claude transcript used to verify enable-time import.
const claudeImportFixture = `{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}
{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}
{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}
`
// freshRepoEnv builds a repo with an initial commit but WITHOUT Entire enabled,
// so `entire enable` runs its real first-time flow.
func freshRepoEnv(t *testing.T) *TestEnv {
t.Helper()
env := NewTestEnv(t)
env.InitRepo()
env.WriteFile("README.md", "# Test Repository")
env.GitAdd("README.md")
env.GitCommit("Initial commit")
return env
}
func TestEnableOffersImport_FirstRunAutoImportsWithYes(t *testing.T) {
t.Parallel()
env := freshRepoEnv(t)
// Pre-existing Claude history for this repo.
require.NoError(t, os.WriteFile(
filepath.Join(env.ClaudeProjectDir, "sess1.jsonl"),
[]byte(claudeImportFixture), 0o644))
// --yes ("accept all defaults") auto-imports the selected agent's
// discoverable history on first-time enable, even non-interactively.
out := env.RunCLI("enable", "--agent", "claude-code", "--yes", "--telemetry=false")
require.Contains(t, out, "Ready.", "enable should complete; got: %s", out)
require.Contains(t, out, "Imported 2 turn(s)", "first-time enable --yes should import discovered history; got: %s", out)
// The imported turns are real checkpoints on the v1 metadata branch.
require.Contains(t, env.RunCLI("checkpoint", "list"), "[imported]",
"imported checkpoints should be listed")
}
func TestEnableOffersImport_NonInteractiveWithoutYesHints(t *testing.T) {
t.Parallel()
env := freshRepoEnv(t)
// A non-interactive (no-TTY) enable without --yes must NOT silently import;
// it points at the manual command instead.
out := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false")
require.Contains(t, out, "Ready.", "enable should complete; got: %s", out)
require.NotContains(t, out, "Imported", "non-interactive enable without --yes must not auto-import; got: %s", out)
require.Contains(t, out, "entire import", "should point at the manual import command; got: %s", out)
// Nothing was written to the checkpoint metadata branch.
require.NotContains(t, env.RunCLI("checkpoint", "list"), "[imported]",
"no checkpoints should be imported without --yes")
}
func TestEnableOffersImport_NoHistoryIsSilent(t *testing.T) {
t.Parallel()
env := freshRepoEnv(t)
// No transcripts written: nothing discoverable.
out := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false")
require.Contains(t, out, "Ready.", "enable should complete; got: %s", out)
require.NotContains(t, out, "Imported", "no history => import offer must be a silent no-op; got: %s", out)
}
func TestEnableOffersImport_NotOfferedOnReEnable(t *testing.T) {
t.Parallel()
env := freshRepoEnv(t)
require.NoError(t, os.WriteFile(
filepath.Join(env.ClaudeProjectDir, "sess1.jsonl"),
[]byte(claudeImportFixture), 0o644))
// First enable imports (--yes accepts the import).
first := env.RunCLI("enable", "--agent", "claude-code", "--yes", "--telemetry=false")
require.Contains(t, first, "Imported 2 turn(s)", "first enable should import; got: %s", first)
// Re-enable must not re-offer or re-import, even though history is still present.
second := env.RunCLI("enable", "--agent", "claude-code", "--yes", "--telemetry=false")
require.NotContains(t, second, "Imported", "re-enable must not offer import again; got: %s", second)
require.False(t, strings.Contains(second, "already imported"),
"re-enable must not run import at all; got: %s", second)
}
Acmd/entire/cli/integration_test/enable_import_test.go+99
32 unmodified lines
33
34
35
36
37
38
39
40
37
41
42
43
40 unmodified lines
84
85
86
87
88
89
90
91
85
92
93
94
32 unmodified lines
// TestRunInvestigateConfigPicker_FiltersNonInstalled verifies that an
// agent with a spawner but no hooks installed is filtered out.
// Not parallel: installs a process-global picker-form override
// (SetPickerFormFnForTest). Running it in parallel with another override-
// installing test lets one clobber the other's override mid-run — see the
// contract on pickerFormOverride in picker.go.
func TestRunInvestigateConfigPicker_FiltersNonInstalled(t *testing.T) {
t.Parallel()
cleanup := investigate.SetPickerFormFnForTest(func(_ context.Context, eligible []investigate.AgentChoice, picks *[]string, maxTurns, quorum *int) error {
// Capture eligible into picks for assertion via the cfg.Agents.
names := make([]string, 0, len(eligible))
40 unmodified lines
}
}
// Not parallel: installs a process-global picker-form override
// (SetPickerFormFnForTest), which must not run concurrently with another
// override-installing test — see the contract on pickerFormOverride in
// picker.go.
func TestRunInvestigateConfigPicker_QuorumExceedsAgents(t *testing.T) {
t.Parallel()
cleanup := investigate.SetPickerFormFnForTest(func(_ context.Context, eligible []investigate.AgentChoice, picks *[]string, maxTurns, quorum *int) error {
_ = eligible
*picks = []string{"agent-a"}
Mcmd/entire/cli/investigate/picker_test.go+8/-2
44 unmodified lines
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
48
49
50
54 unmodified lines
105
106
107
128
129
130
131
108
109
110
44 unmodified lines
Invocation: "entire session tokens",
Summary: "Show token usage and recommendations for a session",
},
{
CommandPath: []string{"org"},
Invocation: "entire org",
Summary: "Manage Entire organizations (create, list, get, delete)",
},
{
CommandPath: []string{"project"},
Invocation: "entire project",
Summary: "Manage Entire projects (create, list, get, delete)",
},
{
CommandPath: []string{"repo"},
Invocation: "entire repo",
Summary: "Manage Entire repositories (create, list, get, delete, clone, mirror, visibility)",
},
{
CommandPath: []string{"grant"},
Invocation: "entire grant",
Summary: "Manage access grants and org membership (org, project, repo)",
},
{
CommandPath: []string{"blame"},
Invocation: "entire blame",
54 unmodified lines
entire tokens --help
entire tokens profile --help
entire session tokens --help
entire org --help
entire project --help
entire repo --help
entire grant --help
entire blame --help
entire why --help
entire experts --help
Mcmd/entire/cli/labs.go-24
8 unmodified lines
9
10
11
12
13
14
12
13
14
15
17
18
19
16
17
18
19
20
32 unmodified lines
53
54
55
56
57
58
59
60
62
61
62
63
64
13 unmodified lines
78
79
80
81
82
83
84
85
85
86
87
88
89
7 unmodified lines
97
98
99
100
101
102
103
104
8 unmodified lines
"github.com/entireio/cli/internal/coreapi"
)
// newOrgCmd is the hidden `entire org` command group: create, list, get, and
// delete organizations on the Entire control plane. Surfaced via `entire
// labs` while the control-plane surface matures.
// newOrgCmd is the `entire org` command group: create, list, get, and
// delete organizations on the Entire control plane.
func newOrgCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "org",
Short: "Manage Entire organizations",
Hidden: true,
Use: "org",
Short: "Manage Entire organizations",
}
addControlPlaneFlags(cmd)
cmd.AddCommand(newOrgCreateCmd())
32 unmodified lines
},
}
cmd.Flags().StringVar(®ion, "region", "", "Jurisdiction slug (defaults to the server's home jurisdiction)")
addJSONFlag(cmd)
return cmd
}
func newOrgListCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "list",
Short: "List organizations you can see",
Args: cobra.NoArgs,
13 unmodified lines
})
},
}
addJSONFlag(cmd)
return cmd
}
func newOrgGetCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "get <org>",
Short: "Show an organization by name or ULID",
Args: cobra.ExactArgs(1),
7 unmodified lines
})
},
}
addJSONFlag(cmd)
return cmd
}
func newOrgDeleteCmd() *cobra.Command {
Mcmd/entire/cli/org.go+11/-8
46 unmodified lines
47
48
49
50
50
51
52
53
54
55
46 unmodified lines
exitCode = runPlugin(ctx, pluginName, binPath, pluginArgs)
if exitCode == 0 {
maybeTrackPluginInvocation(ctx, pluginName)
versioncheck.CheckAndNotify(ctx, os.Stdout, versioninfo.Version)
// Stderr, matching the built-in PersistentPostRun: the plugin's own
// stdout may be machine-readable and piped.
versioncheck.CheckAndNotify(ctx, os.Stderr, versioninfo.Version)
}
return true, exitCode
}
Mcmd/entire/cli/plugin.go+3/-1
8 unmodified lines
9
10
11
12
13
14
12
13
14
15
17
18
19
16
17
18
19
20
66 unmodified lines
87
88
89
90
91
92
93
62 unmodified lines
156
157
158
159
160
161
162
163
164
164
165
166
167
7 unmodified lines
175
176
177
178
179
180
181
182
8 unmodified lines
"github.com/entireio/cli/internal/coreapi"
)
// newProjectCmd is the hidden `entire project` command group: create, list,
// get, and delete projects on the Entire control plane. Surfaced via `entire
// labs`.
// newProjectCmd is the `entire project` command group: create, list,
// get, and delete projects on the Entire control plane.
func newProjectCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "project",
Short: "Manage Entire projects",
Hidden: true,
Use: "project",
Short: "Manage Entire projects",
}
addControlPlaneFlags(cmd)
cmd.AddCommand(newProjectCreateCmd())
66 unmodified lines
cmd.Flags().StringVar(&ownerType, "owner-type", "org", "Owner kind: org or account")
cmd.Flags().StringVar(®ion, "region", "", "Jurisdiction slug (defaults to the server's home jurisdiction)")
markRequired(cmd, "owner")
addJSONFlag(cmd)
return cmd
}
62 unmodified lines
}
cmd.Flags().StringVar(&name, "name", "", "Filter by exact project name")
cmd.Flags().StringVar(&org, "org", "", "List projects owned by this org (name or ULID)")
addJSONFlag(cmd)
return cmd
}
func newProjectGetCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "get <project>",
Short: "Show a project by name or ULID",
Args: cobra.ExactArgs(1),
7 unmodified lines
})
},
}
addJSONFlag(cmd)
return cmd
}
func newProjectDeleteCmd() *cobra.Command {
Mcmd/entire/cli/project.go+9/-7
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
11 unmodified lines
12
13
14
15
15
16
17
18
19
20
19
20
21
23
24
25
22
23
24
25
26
120 unmodified lines
147
148
149
150
151
152
153
154
156
155
156
157
158
18 unmodified lines
177
178
179
180
181
182
183
184
13 unmodified lines
198
199
200
201
202
203
204
82 unmodified lines
287
288
289
290
291
292
293
27 unmodified lines
321
322
323
324
325
326
327
11 unmodified lines
"github.com/entireio/cli/internal/coreapi"
)
// newRepoCmd is the hidden `entire repo` command group: control-plane
// newRepoCmd is the `entire repo` command group: control-plane
// repository lifecycle (create, list within a project, get, delete), the
// `mirror` and `visibility` subtrees, plus the `clone` convenience that
// resolves a mirror and shells out to `git clone`. Other git content
// operations (log, diff, …) remain intentionally out of scope here. Surfaced
// via `entire labs`.
// operations (log, diff, …) remain intentionally out of scope here.
func newRepoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "repo",
Short: "Manage Entire repositories",
Hidden: true,
Use: "repo",
Short: "Manage Entire repositories",
}
addControlPlaneFlags(cmd)
cmd.AddCommand(newRepoCreateCmd())
120 unmodified lines
cmd.Flags().StringVar(&projectID, "project", "", "Owning project (name or ULID) (required)")
cmd.Flags().StringVar(&clusterHost, "cluster-host", "", "Public host of the cluster to pin the repo to (defaults to the jurisdiction default)")
markRequired(cmd, "project")
addJSONFlag(cmd)
return cmd
}
func newRepoListCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "list <project>",
Short: "List repositories in a project",
Long: "List repositories in a project, addressed by name or ULID.",
18 unmodified lines
})
},
}
addJSONFlag(cmd)
return cmd
}
func newRepoGetCmd() *cobra.Command {
13 unmodified lines
},
}
bindRepoProjectFlag(cmd, &project)
addJSONFlag(cmd)
return cmd
}
82 unmodified lines
},
}
bindRepoProjectFlag(cmd, &project)
addJSONFlag(cmd)
return cmd
}
27 unmodified lines
},
}
bindRepoProjectFlag(cmd, &project)
addJSONFlag(cmd)
return cmd
}
Mcmd/entire/cli/repo.go+11/-7
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
20
21
22
23
24
25
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
30
31
32
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
34
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
3 unmodified lines
200
201
202
44
203
204
205
206
287 unmodified lines
494
495
496
338
339
497
498
499
500
501
502
341
503
504
505
506
507
346
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
354
528
529
530
531
9 unmodified lines
541
542
543
370
544
545
546
547
548
549
550
373
551
552
553
554
7 unmodified lines
562
563
564
387
565
566
567
568
13 unmodified lines
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
418
607
608
609
421
422
423
610
611
612
613
614
615
616
617
618
619
620
428
429
621
622
623
624
625
626
627
434
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
8 unmodified lines
660
661
662
451
663
664
665
666
56 unmodified lines
723
724
725
726
727
728
729
730
731
732
733
734
735
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 is a table column with two separable identities: key is the canonical
// name a caller types for --sort (and the value parseSortColumn returns, so the
// sort switches compare against these constants directly); header is the text
// shown in the table. They differ only where the header carries a display hint
// the sort key shouldn't — e.g. NAME's inline "(owner/repo)" — which keeps
// --sort matching a simple equality on key with no header parsing.
type column struct {
key string
header string
}
// Keys are lower-case, single shell tokens (kebab-case for multi-word columns)
// so `--sort clone-url` needs no quoting; headers stay upper-case display text.
var (
colName = column{key: "name", header: "NAME (owner/repo)"}
colCloneURL = column{key: "clone-url", header: "CLONE URL"}
colPrivate = column{key: "private", header: "PRIVATE"}
colAccess = column{key: "access", header: "ACCESS"}
colStatus = column{key: "status", header: "STATUS"}
)
// columnHeaders is the display-header view of a column set, for the table/field
// renderers (runCoreList/runCoreObject) which take plain header strings.
func columnHeaders(cols []column) []string {
h := make([]string, len(cols))
for i, c := range cols {
h[i] = c.header
}
return h
}
// 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>). `--name` filters on the owner/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 = []column{colName, 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 column it names 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. A spec matches a
// column by its key (case-insensitive) — a plain equality, since key holds no
// display hint. An unknown name errors naming the valid keys. Returning the
// matched column lets callers switch on the col* constants directly.
func parseSortColumn(spec string, columns []column) (col column, 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 _, c := range columns {
if strings.EqualFold(c.key, name) {
return c, desc, nil
}
}
valid := make([]string, len(columns))
for i, c := range columns {
valid[i] = c.key
}
return column{}, false, fmt.Errorf("unknown sort column %q; valid columns: %s", name, strings.Join(valid, ", "))
}
// 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. `name`/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: // name -> 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: // name -> 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
}
// filterByName keeps items whose owner/repo name contains substr (case-
// insensitive). The control plane already filters by owner/provider/cluster
// server-side but not by name, so `repo mirror list --name` narrows that last
// dimension client-side. nameOf returns the item's displayed identifier — the
// callers pass the owner/repo form shown in the NAME 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 filterByName[T any](items []T, nameOf 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(nameOf(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 = []column{colName, colAccess, colStatus}
func availableMirrorRow(m coreapi.AvailableMirror) []string {
return []string{m.Owner + "/" + m.Repo, string(m.Access), string(m.Status)}
287 unmodified lines
return fmt.Errorf("initial clone of mirror %s failed", created.MirrorId)
case coreapi.MirrorStatusProcessing:
// Still processing when the poll returned: the wait timed out (or a
// transport error broke the poll). awaitMirrorReady's err carries which.
return err
// poll call errored). awaitMirrorReady's err carries which. Route it
// through renderCoreError so an API error (e.g. a 404 problem+json)
// renders as the server's Detail rather than ogen's raw decoded struct;
// a timeout error passes through unchanged.
return renderCoreError(err)
default:
return err
return renderCoreError(err)
}
}
func newRepoMirrorListCmd() *cobra.Command {
var cluster, provider, owner string
var cluster, provider, owner, name 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) {
return runCoreList(cmd, "No repos available to mirror.", columnHeaders(availableMirrorColumns), availableMirrorRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.AvailableMirror, error) {
// Computed live from GitHub using your own login, so name the
// core being dialled (same rationale as the existing-mirror
// banner). --cluster/--provider don't apply here: the
9 unmodified lines
if err != nil {
return nil, err
}
return out.Available, nil
avail := filterByName(out.Available, func(m coreapi.AvailableMirror) string { return m.Owner + "/" + m.Repo }, name)
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) {
return runCoreList(cmd, "No mirrors found.", columnHeaders(mirrorColumns), mirrorRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.Mirror, error) {
// mirror list is identity-scoped: it shows the mirrors visible
// from the active login's federation, so naming that login server
// makes a surprising empty result legible — e.g. mirrors in a
7 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 = filterByName(mirrors, func(m coreapi.Mirror) string { return m.Owner + "/" + m.Repo }, name)
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(&name, "name", "", "Filter by owner/repo substring, matching the NAME column (case-insensitive)")
cmd.Flags().StringVar(&sortSpec, "sort", "", "Sort by column key (e.g. name, clone-url; prefix '-' for descending). Default: name ascending")
cmd.Flags().BoolVar(&showAvailable, "show-available", false, "Instead of existing mirrors, list GitHub repos you could onboard as mirrors (ignores --cluster/--provider)")
addJSONFlag(cmd)
return cmd
}
func newRepoMirrorGetCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "get <mirror>",
Short: "Show a mirror by ULID or clone URL",
Long: "Show a mirror. <mirror> is either a mirror ULID or an entire:// clone " +
"URL\n(entire://<cluster>/gh/<owner>/<repo>) — the form `mirror list` " +
"prints and `git clone` accepts.",
Long: "Show a mirror. <mirror> is either a mirror ULID or an entire:// clone URL\n" +
"(entire://<cluster>/gh/<owner>/<repo>) — the form `mirror list` prints and\n" +
"`git clone` accepts; a trailing .git, as pasted from `git remote -v`, is\n" +
"accepted too. A clone URL is looked up on the login server fronting its\n" +
"cluster, so it resolves even when that cluster belongs to a federation other\n" +
"than the active auth context; a ULID is looked up on the active context's\n" +
"login server.",
Example: " entire repo mirror get 01KS6KFJR2XS6PZ188MVYE07AN\n" +
" entire repo mirror get entire://aws-us-east-2.entire.io/gh/octocat/hello-world",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runCoreObject(cmd, mirrorColumns, mirrorRow, func(ctx context.Context, c *coreapi.Client) (*coreapi.Mirror, error) {
mirrorID, err := resolveMirrorRef(ctx, c, args[0])
ref := args[0]
show := func(ctx context.Context, c *coreapi.Client) (*coreapi.Mirror, error) {
mirrorID, err := resolveMirrorRef(ctx, c, ref)
if err != nil {
return nil, err
}
return c.GetMirror(ctx, coreapi.GetMirrorParams{MirrorId: mirrorID})
})
}
// A ULID carries no cluster coordinate, so it can only be looked up
// on the active context's core. A clone URL names its cluster — dial
// the core fronting that cluster (discovered from its well-known and
// authenticated with the matching local context, the same path
// create/remove use), so the lookup works when the mirror lives in a
// federation other than the active login instead of failing with
// "no mirror matching".
if looksLikeULID(ref) {
return runCoreObject(cmd, columnHeaders(mirrorColumns), mirrorRow, show)
}
clusterHost, _, _, _, err := parseMirrorCloneURL(ref)
if err != nil {
cmd.SilenceUsage = true
return badMirrorRefErr(err)
}
return runCoreObjectForCluster(cmd, clusterHost, columnHeaders(mirrorColumns), mirrorRow, show)
},
}
addJSONFlag(cmd)
return cmd
}
// resolveMirrorRef turns a mirror reference into its ULID. A ULID passes
8 unmodified lines
}
clusterHost, provider, owner, repo, err := parseMirrorCloneURL(ref)
if err != nil {
return "", fmt.Errorf("%w; pass a mirror ULID or a clone URL (entire://<cluster>/gh/<owner>/<repo>)", err)
return "", badMirrorRefErr(err)
}
mirrors, err := fetchAllPages(ctx, func(ctx context.Context, cursor string) ([]coreapi.Mirror, string, error) {
params := coreapi.ListMirrorsParams{
56 unmodified lines
return fmt.Errorf("no mirror matching %q (run `entire repo mirror list` to see clone URLs, or pass a ULID)", ref)
}
// badMirrorRefErr wraps a clone-URL parse failure with the accepted <mirror>
// forms. Shared by the pre-dial parse in `mirror get` and resolveMirrorRef so
// both boundaries report identically.
func badMirrorRefErr(err error) error {
return fmt.Errorf("%w; pass a mirror ULID or a clone URL (entire://<cluster>/gh/<owner>/<repo>)", err)
}
func newRepoMirrorRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <github-url> [cluster-host]",
Mcmd/entire/cli/repo_mirror.go+246/-27
47 unmodified lines
48
49
50
51
51
52
53
54
28 unmodified lines
83
84
85
86
87
88
47 unmodified lines
}
func newRepoMirrorCollaboratorsListCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "list <github-url> [cluster-host]",
Short: "List the users with access to a mirror",
Long: "Lists the principals that can pull the mirror of <github-url> on " +
28 unmodified lines
})
},
}
addJSONFlag(cmd)
return cmd
}
Mcmd/entire/cli/repo_mirror_collaborators.go+3/-1
614 unmodified lines
615
616
617
618
619
620
621
622
623
624
625
626
622
627
628
629
630
614 unmodified lines
// nonTerminal classifies a still-processing/unknown result: the poll ended
// without a terminal status, so the wait timed out or a poll call errored.
// A poll that errored carries an ogen API error (e.g. a 404 problem+json);
// route it through renderCoreError so it renders as the server's Detail
// ("mirror not found") instead of the raw decoded struct — the same
// treatment the create-failure branch above gives its error. A timeout
// error isn't an API error, so renderCoreError passes it through unchanged.
nonTerminal := func() {
if errors.Is(err, context.DeadlineExceeded) {
res.status, res.err = mirrorStatusTimedOut, err
} else {
res.status, res.err = mirrorStatusError, err
res.status, res.err = mirrorStatusError, renderCoreError(err)
}
}
switch outcome.status {
Mcmd/entire/cli/repo_mirror_create_wizard.go+6/-1
2 unmodified lines
3
4
5
6
7
8
9
10
11
31 unmodified lines
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
2 unmodified lines
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
31 unmodified lines
require.Equal(t, []string{mirrorsAPIPath}, *paths, "suspended must not poll GetMirror")
}
// TestCreateOneMirror_PollErrorRendersCleanDetail pins the fix for a create
// that succeeds but whose readiness poll keeps 404ing (the us-east-2 symptom:
// CreateMirror returns a placement + clone URL, but GetMirror on it reports
// "mirror not found"). The per-mirror error must render the server's problem
// Detail, not ogen's raw decoded ErrorModel struct — so it goes through
// renderCoreError like the create-failure branch, and the clone URL is still
// captured from the successful create.
//
// Not parallel: shortens the package-level mirrorPollInterval.
func TestCreateOneMirror_PollErrorRendersCleanDetail(t *testing.T) {
prev := mirrorPollInterval
mirrorPollInterval = time.Millisecond
t.Cleanup(func() { mirrorPollInterval = prev })
ctx := t.Context()
created := &coreapi.CreatedMirror{Created: true, MirrorId: "m1", MirrorUrl: "entire://c/gh/o/r"}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == mirrorsAPIPath:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := printJSON(w, created); err != nil {
t.Errorf("encode created response: %v", err)
}
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/mirrors/"):
// The status poll can't find the placement the create just returned.
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusNotFound)
if _, err := w.Write([]byte(`{"title":"Not Found","detail":"mirror not found","status":404}`)); err != nil {
t.Errorf("write problem response: %v", err)
}
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
c, err := coreapi.NewWithBearer(srv.URL, "tok")
require.NoError(t, err)
target := mirrorTarget{owner: "o", repo: "r", region: regionChoice{host: "c"}}
res := createOneMirror(ctx, target, c, nil, false, time.Second, nil)
require.Equal(t, mirrorStatusError, res.status)
require.Equal(t, "entire://c/gh/o/r", res.cloneURL, "a successful create still yields the clone URL")
require.Error(t, res.err)
require.EqualError(t, res.err, "mirror not found", "must render the server's problem Detail")
// Guard against ogen's raw `code 404: {Schema:... Set:true}` struct dump leaking.
require.NotContains(t, res.err.Error(), "Set:", "must not leak the decoded ErrorModel struct")
require.NotContains(t, res.err.Error(), "decode response")
require.NotContains(t, res.err.Error(), "code 404")
}
func TestRunMirrorCreateWizard_RequiresTTY(t *testing.T) {
t.Parallel()
// In-process tests are non-interactive, so the wizard must refuse before
Mcmd/entire/cli/repo_mirror_create_wizard_test.go+56
67 unmodified lines
68
69
70
71
72
73
74
75
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
67 unmodified lines
var mirrorPollInterval = 2 * time.Second
// maxConsecutivePollErrors bounds how many back-to-back GetMirror failures the
// clone wait tolerates before giving up. A brief network/API glitch during a
// long initial clone shouldn't fail the create, but a persistent error
// (deleted mirror, revoked auth) should surface rather than spin to the
// deadline. The counter resets on any successful poll.
const maxConsecutivePollErrors = 5
// clone wait tolerates before giving up. Two failure modes share this budget: a
// brief network/API glitch during a long clone, and — the common one — the
// stale-read window right after create, where the control plane returns 404
// "mirror not found" because the just-written repo#list grant / placement row
// isn't yet visible to the region's minimize_latency + follower reads (~4.8s
// nominal, but it spikes under concurrent multi-region creates). At the 2s
// cadence, 15 tolerated errors ≈ 30s — enough to ride out that window, while a
// genuinely persistent error (deleted mirror, revoked auth) still surfaces well
// before the 30m --wait-timeout. This is a stopgap: the durable fix is
// server-side, making GetMirror check the grant fully-consistent and read the
// row from the CRDB leaseholder so a fresh mirror is visible on the first poll.
// The counter resets on any successful poll.
const maxConsecutivePollErrors = 15
var (
// errMirrorCloneFailed reports the mirror's initial clone reached the
Mcmd/entire/cli/repo_mirror_probe.go+13/-5
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
401
385
386
387
388
389
390
391
402
403
404
405
406
407
82 unmodified lines
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
182 unmodified lines
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
244 unmodified lines
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
377 unmodified lines
return recCh
}
// execMirrorList runs `list` under a parent that carries the control-plane
// persistent flags (--insecure-http-auth); --json is a local flag on the list
// command itself, so tests can exercise --json and the client-side --name/--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 --name 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("--name narrows the table by owner/repo substring", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--name", "cli")
require.Contains(t, stdout, "acme/cli")
require.NotContains(t, stdout, "acme/web")
require.NotContains(t, stdout, "other/api")
})
t.Run("--name matches the owner/repo form shown in the NAME column", func(t *testing.T) {
// A value copied straight from the displayed NAME column must match the
// row it came from; filtering on the bare repo name would drop it.
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--name", "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 -name reverses the order", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--sort", "-name")
requireOrder(t, stdout, "other/api", "acme/web", "acme/cli")
})
t.Run("--sort name resolves the NAME column by its key", func(t *testing.T) {
// The NAME header carries an inline "(owner/repo)" display hint, but the
// sort key is the plain "name" — --sort matches on key, not header.
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--sort", "name")
requireOrder(t, stdout, "acme/cli", "acme/web", "other/api")
})
t.Run("--name applies to --json and keeps [] not null", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
// The JSON keys come from the raw coreapi model, unaffected by the NAME
// column/flag rename — the wire field stays "repo".
stdout, _ := runMirrorList(t, "--name", "cli", "--json")
require.Contains(t, stdout, `"repo": "cli"`)
require.NotContains(t, stdout, `"repo": "web"`)
serveMirrorList(t, mirrors, nil)
stdout, _ = runMirrorList(t, "--name", "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 name keeps the cluster tiebreak (matches default)", func(t *testing.T) {
// A repo on two clusters plus a lexically-earlier repo. Explicit
// `--sort name` must order like the default: owner/repo ascending, and
// within the duplicate tie, cluster ascending (aws before eu). Guards
// against `--sort name` 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", "name")
// 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",
)
// -name reverses the whole ordering, tiebreak included.
serveMirrorList(t, dupes, nil)
stdout, _ = runMirrorList(t, "--sort", "-name")
requireOrder(t, stdout,
"entire://eu-west-1.entire.io/gh/acme/web",
"entire://aws-us-east-2.entire.io/gh/acme/web",
)
})
t.Run("--name/--sort apply under --show-available", func(t *testing.T) {
// --name 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", "--name", "cli", "--sort", "access")
require.NotContains(t, stdout, "other/srv", "--name 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-name 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", " -name")
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) {
182 unmodified lines
})
}
// TestRepoMirrorGet_Routing pins which core `mirror get <ref>` dials. A clone
// URL names its cluster, so it must be resolved on the core fronting that
// cluster (clusterCoreClient), not the active context — the original bug:
// `mirror get entire://<cluster>/…` for a cluster in a federation other than
// the active login failed with "no mirror matching" until the user switched
// contexts. A ULID carries no cluster coordinate and stays on the active
// context; an unparseable ref must error before dialing anything.
//
// Not parallel: swaps the package-level activeCoreClient/clusterCoreClient
// seams.
func TestRepoMirrorGet_Routing(t *testing.T) {
const mirrorULID = "0123456789ABCDEFGHJKMNPQRS"
const clusterHost = "eukanuba.partial.to"
const cloneURL = "entire://" + clusterHost + "/gh/entirehq/librarian"
// mirrorServer answers both the list (clone-URL resolution) and the
// GetMirror-by-ULID calls for the librarian mirror.
mirrorServer := func(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case mirrorsAPIPath:
assert.NoError(t, printJSON(w, &coreapi.ListMirrorsOutputBody{Mirrors: []coreapi.Mirror{
{MirrorId: mirrorULID, Owner: "entirehq", Repo: "librarian", ClusterHost: clusterHost},
}}))
case mirrorsAPIPath + "/" + mirrorULID:
assert.NoError(t, printJSON(w, &coreapi.Mirror{
MirrorId: mirrorULID, Owner: "entirehq", Repo: "librarian", ClusterHost: clusterHost,
IsPrivate: coreapi.NewOptBool(true),
}))
default:
t.Errorf("unexpected request path %q", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
seamActive := func(t *testing.T, fn func(context.Context) (*coreapi.Client, error)) {
t.Helper()
prev := activeCoreClient
activeCoreClient = fn
t.Cleanup(func() { activeCoreClient = prev })
}
seamCluster := func(t *testing.T, fn func(context.Context, string) (*coreapi.Client, error)) {
t.Helper()
prev := clusterCoreClient
clusterCoreClient = fn
t.Cleanup(func() { clusterCoreClient = prev })
}
runGet := func(t *testing.T, ref string) (string, error) {
t.Helper()
cmd := newRepoCmd()
var out, errW bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errW)
cmd.SetArgs([]string{"mirror", "get", ref})
err := cmd.ExecuteContext(t.Context())
return out.String(), err
}
t.Run("clone URL dials the cluster's core, not the active context", func(t *testing.T) {
srv := mirrorServer(t)
seamActive(t, func(context.Context) (*coreapi.Client, error) {
t.Error("clone-URL get dialed the active context's core")
return nil, errors.New("wrong core")
})
var gotHost string
seamCluster(t, func(_ context.Context, host string) (*coreapi.Client, error) {
gotHost = host
return coreapi.NewWithBearer(srv.URL, "tok")
})
out, err := runGet(t, cloneURL)
require.NoError(t, err)
require.Equal(t, clusterHost, gotHost, "must resolve on the clone URL's cluster")
require.Contains(t, out, "entirehq/librarian")
require.Contains(t, out, cloneURL)
})
t.Run("ULID dials the active context", func(t *testing.T) {
srv := mirrorServer(t)
seamActive(t, func(context.Context) (*coreapi.Client, error) {
return coreapi.NewWithBearer(srv.URL, "tok")
})
seamCluster(t, func(_ context.Context, host string) (*coreapi.Client, error) {
t.Errorf("ULID get dialed cluster core %q; a ULID has no cluster coordinate", host)
return nil, errors.New("wrong core")
})
out, err := runGet(t, mirrorULID)
require.NoError(t, err)
require.Contains(t, out, "entirehq/librarian")
})
t.Run("unparseable ref errors before dialing any core", func(t *testing.T) {
seamActive(t, func(context.Context) (*coreapi.Client, error) {
t.Error("unparseable ref dialed the active context's core")
return nil, errors.New("no dial expected")
})
seamCluster(t, func(context.Context, string) (*coreapi.Client, error) {
t.Error("unparseable ref dialed a cluster core")
return nil, errors.New("no dial expected")
})
_, err := runGet(t, "not-a-url")
require.Error(t, err)
require.ErrorContains(t, err, "pass a mirror ULID or a clone URL")
})
}
func TestMirrorRow(t *testing.T) {
t.Parallel()
tests := []struct {
244 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("-name reverses the whole ordering, tiebreak included", func(t *testing.T) {
t.Parallel()
m := base()
require.NoError(t, sortMirrors(m, "-name"))
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-name 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, " -name"))
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(), "name")
})
}
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, " -name"))
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+434/-7
133 unmodified lines
134
135
136
137
138
139
140
141
137
138
139
140
141
142
143
144
145
146
78 unmodified lines
225
226
227
226
227
228
229
230
231
232
228
229
230
231
232
233
234
235
236
14 unmodified lines
251
252
253
253
254
255
256
257
455 unmodified lines
713
714
715
715
716
717
718
719
720
721
722
723
724
716
717
718
719
720
721
722
723
724
725
726
503 unmodified lines
1230
1231
1232
1234
1233
1234
1235
1236
133 unmodified lines
--models list the models each agent advertises (optionally --agent NAME)
--profile NAME select a profile (also accepted as positional arg)
--prompt TEXT add one-off per-run instructions for this invocation
--timeout DUR max time each reviewer may run before it's cancelled and marked
failed; also bounds the consolidating judge, whose timeout or
error fails the review with no verdict (default 20m; 0 disables
both bounds). A timed-out reviewer's siblings and the judge
still proceed.
--timeout DUR optional hard cap on each reviewer before it's cancelled and
marked failed. No default — reviewers run until they finish,
like a directly-invoked skill. A positive value also bounds
the consolidating judge, which otherwise keeps its own 20m
default (the judge is never unbounded; its timeout or error
fails the review with no verdict). A timed-out reviewer's
siblings and the judge still proceed.
--base REF scope against REF instead of mainline. Useful for stacked
PRs where the base is the parent feature branch, not main.
Default: first existing of origin/HEAD, origin/main,
78 unmodified lines
if findings {
return runReviewFindings(ctx, cmd, positionalArg, deps.NewSilentError)
}
// Map the flag to the RunConfig timeout convention: a non-positive
// value (the user passed --timeout 0) means "disable", encoded as the
// negative sentinel, which disables BOTH the per-reviewer bound and the
// judge's deadline. A positive value passes through and bounds both.
// (The flag's default is nonzero, so 0 only appears on --timeout 0.)
timeoutArg := resolveReviewerTimeoutArg(reviewTimeout)
return runReview(ctx, cmd, agentOverride, modelOverride, baseOverride, profileName, perRunPrompt, timeoutArg, deps)
// The flag flows through unmapped: RunConfig.ReviewerTimeout is
// two-state (positive = hard cap, anything else = no cap), so the
// default 0, an explicit --timeout 0, and a negative all mean
// "reviewers run until done". The judge derives its own bound via
// judgeTimeoutArg and is never uncapped.
return runReview(ctx, cmd, agentOverride, modelOverride, baseOverride, profileName, perRunPrompt, reviewTimeout, deps)
},
}
cmd.Flags().BoolVar(&configure, "configure", false, "set up a review profile; shows available agents and accepts --set-* flags for non-interactive config")
14 unmodified lines
cmd.Flags().StringVar(&profileOverride, "profile", "", "review profile to run (default: review_default_profile or general)")
cmd.Flags().StringVar(&perRunPrompt, "prompt", "", "one-off instructions appended to this review run")
cmd.Flags().StringVar(&baseOverride, "base", "", "git ref to scope the review against (default: origin/HEAD → origin/main → origin/master → main → master)")
cmd.Flags().DurationVar(&reviewTimeout, "timeout", defaultReviewerTimeout, "max time each reviewer may run before it is cancelled and marked failed; also bounds the consolidating judge, whose timeout or error fails the review (0 disables both)")
cmd.Flags().DurationVar(&reviewTimeout, "timeout", 0, "optional hard cap per reviewer (default: none — reviewers run until they finish, like a skill invoked directly in a session). When set, it also bounds the consolidating judge; unset, the judge keeps its own 20m default")
// The listing modes and the action modes each select a distinct command
// behavior; combining them silently runs one and drops the rest, so reject
// the combination up front with a clear cobra error.
455 unmodified lines
return names
}
// resolveReviewerTimeoutArg maps the --timeout flag value to the RunConfig
// timeout convention used by reviewerTimeout and the judge's providerContext: a
// non-positive value (the user passed --timeout 0) becomes the negative
// "disabled" sentinel; a positive value passes through unchanged. The flag's
// default is nonzero, so 0 only reaches here when the user explicitly set it.
func resolveReviewerTimeoutArg(flagValue time.Duration) time.Duration {
if flagValue <= 0 {
return -1
}
return flagValue
// judgeTimeoutArg maps the reviewer --timeout value to the judge's
// ProviderTimeout. The judge is a single text-generation call with no event
// stream, so unlike reviewers it always keeps a bound: an explicit positive
// --timeout governs it, anything else (unset, 0, or a negative like
// `--timeout -5m`) maps to 0 so the synthesis default (20m) applies — a
// reviewer-side "no cap" must never leak through as "judge unbounded".
func judgeTimeoutArg(reviewerArg time.Duration) time.Duration {
return max(reviewerArg, 0)
}
// runReview executes the main review flow.
503 unmodified lines
profileName: profileName,
task: profile.Task,
masterName: masterLabel,
judgeTimeout: timeout,
judgeTimeout: judgeTimeoutArg(timeout),
onSynthesisResult: func(result string) {
aggregateOutput = result
},
Mcmd/entire/cli/review/cmd.go+23/-24
33 unmodified lines
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
37
38
39
40
41
42
43
44
45
46
47
48
49
52
53
54
55
56
57
58
59
50
51
52
53
33 unmodified lines
return ""
}
// defaultReviewerTimeout bounds a single reviewer's run when the caller
// doesn't set RunConfig.ReviewerTimeout. A stuck agent is cancelled (its
// process killed) and marked failed rather than hanging the review forever.
//
// A full reviewer pass (read the diff, run skills, write the report) regularly
// runs past 10m, especially for the consolidating judge, so the default is 20m;
// override with --timeout (0 disables).
const defaultReviewerTimeout = 20 * time.Minute
// reviewerTimeout resolves the effective per-reviewer timeout, distinguishing
// the three RunConfig.ReviewerTimeout states the zero value alone can't:
// - positive: use it.
// - zero (unset): use defaultReviewerTimeout.
// - negative: disabled — return 0, and callers treat 0 as "no timeout".
// reviewerTimeout resolves the effective per-reviewer wall cap. There is
// deliberately NO default: reviewers run until they finish, exactly like the
// same skill invoked in a user's own session. Review time is dominated by
// long-running subagents inside the reviewer (measured: a single legitimate
// review subagent ran 12.6 minutes with zero parent output) — every
// wall-clock default we shipped killed real work at some diff size, and no
// reliable liveness signal exists for a headless child that would let a
// watchdog distinguish "working via a quiet subagent" from "hung". A stuck
// reviewer is Ctrl+C in interactive runs (process-group kill handles it);
// unattended callers that need a bound pass --timeout explicitly.
// - positive: hard cap.
// - zero or negative: no cap.
func reviewerTimeout(cfg reviewtypes.RunConfig) time.Duration {
switch {
case cfg.ReviewerTimeout > 0:
return cfg.ReviewerTimeout
case cfg.ReviewerTimeout < 0:
return 0
default:
return defaultReviewerTimeout
}
return max(cfg.ReviewerTimeout, 0)
}
var errReviewerTimeoutCause = errors.New("reviewer timeout elapsed")
Mcmd/entire/cli/review/run.go+13/-22
995 unmodified lines
996
997
998
999
1000
999
1000
1001
1002
1003
3 unmodified lines
1007
1008
1009
1010
1011
1012
1013
1014
1010
1011
1012
1013
1014
1015
1016
1016
1017
1018
1019
1020
1021
1022
1023
1017
1018
1019
1025
1026
1027
1028
1020
1021
1022
1023
1024
1025
1026
1027
1032
1033
1034
1035
1028
1029
1030
1031
1032
1033
1037
1038
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1042
1043
1044
1045
1046
1045
1046
1047
1048
1049
1050
1051
1052
8 unmodified lines
1061
1062
1063
1061
1062
1063
1064
1065
1064
1065
1066
1067
1068
1069
1070
1067
1068
1069
1071
1072
1073
1074
1075
1072
1076
1077
1078
1079
995 unmodified lines
func TestReviewerTimeout(t *testing.T) {
t.Parallel()
if got := reviewerTimeout(reviewtypes.RunConfig{}); got != defaultReviewerTimeout {
t.Errorf("unset = %v, want default %v", got, defaultReviewerTimeout)
if got := reviewerTimeout(reviewtypes.RunConfig{}); got != 0 {
t.Errorf("unset = %v, want 0 (no default cap)", got)
}
if got := reviewerTimeout(reviewtypes.RunConfig{ReviewerTimeout: 5 * time.Minute}); got != 5*time.Minute {
t.Errorf("explicit = %v, want 5m", got)
3 unmodified lines
}
}
// TestResolveReviewerTimeoutArg pins the --timeout flag -> RunConfig sentinel
// mapping: a non-positive flag value (the user passed --timeout 0) becomes the
// negative "disabled" sentinel that turns off both the reviewer bound and the
// judge's deadline; a positive value passes through unchanged.
func TestResolveReviewerTimeoutArg(t *testing.T) {
// TestReviewerTimeout_NoDefaultCap pins the deliberate absence of a default
// wall cap: an unset RunConfig.ReviewerTimeout means the reviewer runs until
// it finishes, like a skill invoked directly in a session. Every wall-clock
// default we shipped killed legitimate work at some diff size (reviewers
// spend 10+ minute stretches inside subagents with zero parent output).
func TestReviewerTimeout_NoDefaultCap(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in time.Duration
want time.Duration
}{
{"explicit zero disables", 0, -1},
{"negative disables", -5 * time.Minute, -1},
{"positive passes through", 20 * time.Minute, 20 * time.Minute},
if got := reviewerTimeout(reviewtypes.RunConfig{}); got != 0 {
t.Errorf("reviewerTimeout(unset) = %v, want 0 (no cap)", got)
}
for _, tc := range cases {
if got := resolveReviewerTimeoutArg(tc.in); got != tc.want {
t.Errorf("%s: resolveReviewerTimeoutArg(%v) = %v, want %v", tc.name, tc.in, got, tc.want)
}
if got := reviewerTimeout(reviewtypes.RunConfig{ReviewerTimeout: -1}); got != 0 {
t.Errorf("reviewerTimeout(negative) = %v, want 0 (no cap)", got)
}
if got := reviewerTimeout(reviewtypes.RunConfig{ReviewerTimeout: 30 * time.Minute}); got != 30*time.Minute {
t.Errorf("reviewerTimeout(30m) = %v, want the explicit cap", got)
}
}
// TestDefaultReviewerTimeoutValue pins the literal default so an accidental edit
// to the constant is caught (the other timeout tests compare against the
// constant itself and would silently follow a change).
func TestDefaultReviewerTimeoutValue(t *testing.T) {
// TestJudgeTimeoutArg pins the judge mapping: the judge is one bounded API
// call and always keeps a limit — an explicit positive --timeout governs it,
// and a reviewer-side "no cap" (zero or negative, e.g. `--timeout -5m`) must
// not leak through as "judge unbounded".
func TestJudgeTimeoutArg(t *testing.T) {
t.Parallel()
if defaultReviewerTimeout != 20*time.Minute {
t.Errorf("defaultReviewerTimeout = %v, want 20m", defaultReviewerTimeout)
if got := judgeTimeoutArg(0); got != 0 {
t.Errorf("judgeTimeoutArg(0) = %v, want 0 (judge default applies)", got)
}
if got := judgeTimeoutArg(-5 * time.Minute); got != 0 {
t.Errorf("judgeTimeoutArg(-5m) = %v, want 0 (judge default applies)", got)
}
if got := judgeTimeoutArg(30 * time.Minute); got != 30*time.Minute {
t.Errorf("judgeTimeoutArg(30m) = %v, want 30m", got)
}
}
// TestTimeoutFlag_ResolvesThroughCommand drives the real --timeout flag through
// the command (parse only, no RunE) and the resolver, covering the full
// flag -> resolveReviewerTimeoutArg chain: 0 (and the default) and a positive
// override. Guards the documented "0 disables" contract against a regression
// that bypasses the resolver.
// TestTimeoutFlag_ResolvesThroughCommand drives the real --timeout flag
// through the command (parse only, no RunE), pinning the two-state contract
// the flag value carries directly into RunConfig.ReviewerTimeout: the default
// and an explicit 0 both mean "no cap" (reviewerTimeout returns 0), and a
// positive override is the hard cap.
func TestTimeoutFlag_ResolvesThroughCommand(t *testing.T) {
t.Parallel()
parseTimeout := func(args []string) time.Duration {
8 unmodified lines
return d
}
// Default (no flag) is the nonzero default and resolves to a positive bound.
if d := parseTimeout(nil); d != defaultReviewerTimeout {
t.Errorf("default --timeout = %v, want %v", d, defaultReviewerTimeout)
} else if got := resolveReviewerTimeoutArg(d); got != defaultReviewerTimeout {
t.Errorf("default resolves to %v, want %v", got, defaultReviewerTimeout)
// Default (no flag) is zero: reviewers run until they finish unless the
// user explicitly caps them.
if d := parseTimeout(nil); d != 0 {
t.Errorf("default --timeout = %v, want 0 (no cap)", d)
} else if got := reviewerTimeout(reviewtypes.RunConfig{ReviewerTimeout: d}); got != 0 {
t.Errorf("default resolves to %v, want 0 (no cap)", got)
}
// --timeout 0 resolves to the negative disable sentinel (reviewers + judge).
if got := resolveReviewerTimeoutArg(parseTimeout([]string{"--timeout", "0"})); got != -1 {
t.Errorf("--timeout 0 resolves to %v, want -1 (disabled)", got)
// Explicit --timeout 0 behaves the same as the default.
if got := reviewerTimeout(reviewtypes.RunConfig{ReviewerTimeout: parseTimeout([]string{"--timeout", "0"})}); got != 0 {
t.Errorf("--timeout 0 resolves to %v, want 0 (no cap)", got)
}
// A positive override passes through unchanged.
if got := resolveReviewerTimeoutArg(parseTimeout([]string{"--timeout", "30m"})); got != 30*time.Minute {
if got := reviewerTimeout(reviewtypes.RunConfig{ReviewerTimeout: parseTimeout([]string{"--timeout", "30m"})}); got != 30*time.Minute {
t.Errorf("--timeout 30m resolves to %v, want 30m", got)
}
}
Mcmd/entire/cli/review/run_test.go+43/-39
89 unmodified lines
90
91
92
93
94
93
94
95
96
97
98
99
100
101
89 unmodified lines
// defaultSynthesisProviderTimeout bounds the judge's single consolidation call
// when SynthesisSink.ProviderTimeout is unset. The judge reads every reviewer's
// report and writes the combined verdict in one text-generation call, which
// regularly needs more than the original 2m, so the default is 5m.
const defaultSynthesisProviderTimeout = 5 * time.Minute
// regularly needs more than the original 2m. 20m matches the judge's previous
// effective bound: before the reviewer default was dropped, the --timeout flag
// default (20m) always flowed into ProviderTimeout on the no-flag path, so
// keeping 5m here would have silently tightened the judge 4x — and a judge
// timeout discards an entire multi-reviewer run with no verdict.
const defaultSynthesisProviderTimeout = 20 * time.Minute
// AgentEvent is a no-op; SynthesisSink only acts in RunFinished.
func (SynthesisSink) AgentEvent(_ string, _ reviewtypes.Event) {}
Mcmd/entire/cli/review/synthesis_sink.go+6/-2
311 unmodified lines
312
313
314
315
315
316
317
318
319
7 unmodified lines
327
328
329
329
330
331
331
332
332
333
334
335
336
29 unmodified lines
366
367
368
368
369
370
371
372
311 unmodified lines
}
// TestSynthesisSink_DefaultProviderTimeoutValue pins the judge's default
// deadline (~5m) when ProviderTimeout is unset, so an accidental change to
// deadline (~20m, the flag default's previous effective bound) when
// ProviderTimeout is unset, so an accidental change to
// defaultSynthesisProviderTimeout is caught rather than passing silently.
func TestSynthesisSink_DefaultProviderTimeoutValue(t *testing.T) {
t.Parallel()
7 unmodified lines
if !provider.hadDeadline {
t.Fatal("unset ProviderTimeout must apply the default deadline")
}
// The default is 5m; allow generous slack for scheduling between context
// The default is 20m; allow generous slack for scheduling between context
// creation and the provider reading the deadline.
if provider.remaining < 4*time.Minute || provider.remaining > 5*time.Minute {
t.Fatalf("default deadline remaining = %v, want ~5m", provider.remaining)
if provider.remaining < 19*time.Minute || provider.remaining > 20*time.Minute {
t.Fatalf("default deadline remaining = %v, want ~20m", provider.remaining)
}
}
29 unmodified lines
if !provider.hadDeadline {
t.Fatal("explicit ProviderTimeout must apply a deadline")
}
// Generous slack: the deadline should be ~1h out, far above the 5m default.
// Generous slack: the deadline should be ~1h out, far above the 20m default.
if provider.remaining < 30*time.Minute {
t.Fatalf("deadline remaining = %v, want ~1h (explicit timeout not honored, fell back to default)", provider.remaining)
}
Mcmd/entire/cli/review/synthesis_sink_test.go+6/-5
130 unmodified lines
131
132
133
134
135
136
137
134
135
136
137
138
139
140
141
130 unmodified lines
// ReviewerTimeout bounds how long a single reviewer may run before the
// orchestrator cancels it (its process is killed and the run is marked
// failed-by-timeout) so a stuck agent can't hang the review forever. Zero
// or negative means use the orchestrator default (defaultReviewerTimeout).
// Sibling reviewers and the judge are unaffected by one reviewer's
// timeout.
// failed-by-timeout). Positive is a hard cap; zero or negative means no
// cap — reviewers run until they finish, like a skill invoked directly
// in a session (there is deliberately no default: every wall-clock
// default shipped killed legitimate long-running work). Sibling
// reviewers and the judge are unaffected by one reviewer's timeout.
ReviewerTimeout time.Duration
// EnrichSummary optionally updates the completed run summary before sinks
Mcmd/entire/cli/review/types/reviewer.go+5/-4
67 unmodified lines
68
69
70
71
72
71
72
73
74
75
76
77
78
15 unmodified lines
94
95
96
97
98
99
100
101
102
103
104
98
99
100
101
105
106
107
67 unmodified lines
}
// Version check and notification (synchronous with 2s timeout)
// Runs AFTER command completes to avoid interfering with interactive modes
versioncheck.CheckAndNotify(cmd.Context(), cmd.OutOrStdout(), versioninfo.Version)
// Runs AFTER command completes to avoid interfering with interactive modes.
// Stderr, never stdout: this hook also fires after --json commands whose
// stdout is piped into jq or captured by scripts — a notice on stdout
// corrupts that output while staying invisible in the caller's logs.
versioncheck.CheckAndNotify(cmd.Context(), cmd.ErrOrStderr(), versioninfo.Version)
},
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
15 unmodified lines
cmd.AddCommand(newLabsCmd()) // 'labs' (experimental workflow discovery)
cmd.AddCommand(newPluginGroupCmd()) // 'plugin' (managed install/list/remove)
cmd.AddCommand(newImportCmd()) // 'import' (hidden; import pre-existing agent history)
cmd.AddCommand(newOrgCmd()) // 'org' — control-plane org management
cmd.AddCommand(newProjectCmd()) // 'project' — control-plane project management
cmd.AddCommand(newRepoCmd()) // 'repo' — control-plane repo lifecycle
cmd.AddCommand(newGrantCmd()) // 'grant' — control-plane access grants
// Top-level lifecycle and standalone commands.
cmd.AddCommand(cliReview.NewCommand(buildReviewDeps())) // `review`; hidden during maturation
cmd.AddCommand(investigate.NewCommand(buildInvestigateDeps())) // hidden during maturation; runs a multi-agent investigation
cmd.AddCommand(newOrgCmd()) // hidden during maturation; control-plane org management
cmd.AddCommand(newProjectCmd()) // hidden during maturation; control-plane project management
cmd.AddCommand(newRepoCmd()) // hidden during maturation; control-plane repo lifecycle
cmd.AddCommand(newGrantCmd()) // hidden during maturation; control-plane access grants
cmd.AddCommand(newCleanCmd())
cmd.AddCommand(newSetupCmd()) // 'configure' — non-agent settings; agent CRUD lives under 'agent'
cmd.AddCommand(newEnableCmd())
Mcmd/entire/cli/root.go+9/-6
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
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
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. A repo that appears in more than one
// cell is a mirror placement returning the SAME content (this PR fans out
// across placements, so e.g. a US-homed repo with an EU mirror is now
// searched in both cells) — not additional matches. Keep one representative
// entry per repo (the max of each count; mirror copies are identical, max
// only guards against minor per-cell skew) instead of summing, and record
// the duplicated portion so the aggregate stats can drop the double-count.
type repoStatAcc struct {
idx int
sumMatches, maxMatches int
sumFiles, maxFiles int
cellCount int
}
accByRepo := make(map[string]*repoStatAcc, len(merged.RepoStats))
var dedupedStats []codesearch.RepoStats
for _, rs := range merged.RepoStats {
acc, ok := accByRepo[rs.Repo]
if !ok {
acc = &repoStatAcc{idx: len(dedupedStats)}
accByRepo[rs.Repo] = acc
dedupedStats = append(dedupedStats, codesearch.RepoStats{Repo: rs.Repo})
}
acc.cellCount++
acc.sumMatches += rs.MatchCount
acc.sumFiles += rs.FileCount
acc.maxMatches = max(acc.maxMatches, rs.MatchCount)
acc.maxFiles = max(acc.maxFiles, rs.FileCount)
}
var overcountMatches, overcountFiles, overcountRepos int
for _, acc := range accByRepo {
dedupedStats[acc.idx].MatchCount = acc.maxMatches
dedupedStats[acc.idx].FileCount = acc.maxFiles
overcountMatches += acc.sumMatches - acc.maxMatches
overcountFiles += acc.sumFiles - acc.maxFiles
overcountRepos += acc.cellCount - 1
}
merged.RepoStats = dedupedStats
// The per-cell Stats were summed above, so a mirrored repo's matches were
// counted once per cell. Subtract the duplicated copies identified via
// RepoStats so the totals reflect distinct content, not the same content
// seen from every mirror cell. This preserves per-cell truncation (the
// base is peregrine's own totals; we only remove the provable duplicate
// portion) and zero-match repos (they contribute 0 to the subtraction).
// A repo with matches but no RepoStats row, or a zero-match mirror repo,
// can't be de-duplicated from the response and keeps its summed
// contribution — a mild over-count, far less misleading than reporting
// every mirrored match twice. Clamp at zero against inconsistent input.
merged.Stats.TotalMatches = max(0, merged.Stats.TotalMatches-overcountMatches)
merged.Stats.TotalFiles = max(0, merged.Stats.TotalFiles-overcountFiles)
merged.Stats.ReposSearched = max(0, merged.Stats.ReposSearched-overcountRepos)
// 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+576/-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
23
64 unmodified lines
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
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
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"
testClusterSlugUS = "us-prod"
)
// 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}
cellVal := func() *codesearch.SearchResponse {
return &codesearch.SearchResponse{
Results: []codesearch.Result{dup},
Stats: codesearch.Stats{TotalMatches: 1, TotalFiles: 1, ReposSearched: 1},
RepoStats: []codesearch.RepoStats{{Repo: "acme/web", MatchCount: 1, FileCount: 1}},
}
}
results := []cellCallResult[*codesearch.SearchResponse]{
{group: cellGroup{cell: "", jurisdiction: ""}, value: cellVal()},
{group: cellGroup{cell: "aws-us-east-2", jurisdiction: "us"}, value: cellVal()},
}
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))
}
// Stats must not double-count the overlapping match either.
if merged.Stats.TotalMatches != 1 {
t.Errorf("TotalMatches = %d, want 1 (overlapping cells must not double-count)", merged.Stats.TotalMatches)
}
if merged.Stats.ReposSearched != 1 {
t.Errorf("ReposSearched = %d, want 1 (one logical repo)", merged.Stats.ReposSearched)
}
if len(merged.RepoStats) != 1 || merged.RepoStats[0].MatchCount != 1 {
t.Errorf("RepoStats = %+v, want one entry with MatchCount 1", merged.RepoStats)
}
}
func TestMergeSearchResults_MirrorPlacementsDoNotDoubleCount(t *testing.T) {
t.Parallel()
// A US-homed repo with an EU mirror indexes the same content, so the
// fan-out queries both cells and each returns the SAME matches. Merged
// results dedupe by repo+path+line; the stats must dedupe too, or the
// summary reports "6 matches across 4 files in 2 repos" for 3 unique
// results (and falsely claims truncation). Regression guard for the
// mirror fan-out this trail introduced.
matches := []codesearch.Result{
{Repo: "acme/web", Path: "main.go", Line: 1, Column: 0, Score: 0.9},
{Repo: "acme/web", Path: "main.go", Line: 2, Column: 0, Score: 0.8},
{Repo: "acme/web", Path: "util.go", Line: 5, Column: 0, Score: 0.7},
}
cell := func(name, jur string) cellCallResult[*codesearch.SearchResponse] {
return cellCallResult[*codesearch.SearchResponse]{
group: cellGroup{cell: name, jurisdiction: jur},
value: &codesearch.SearchResponse{
Query: "handleRequest",
Stats: codesearch.Stats{TotalMatches: 3, TotalFiles: 2, ReposSearched: 1, DurationMs: 10},
RepoStats: []codesearch.RepoStats{{Repo: "acme/web", MatchCount: 3, FileCount: 2}},
Results: matches,
},
}
}
results := []cellCallResult[*codesearch.SearchResponse]{
cell("aws-us-east-2", "us"),
cell(testCellEU, "eu"),
}
merged, err := mergeSearchResults(context.Background(), 0, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(merged.Results) != 3 {
t.Fatalf("len(Results) = %d, want 3 (mirror duplicates removed)", len(merged.Results))
}
if merged.Stats.TotalMatches != 3 {
t.Errorf("TotalMatches = %d, want 3 (mirror must not double-count)", merged.Stats.TotalMatches)
}
if merged.Stats.TotalFiles != 2 {
t.Errorf("TotalFiles = %d, want 2 (mirror must not double-count)", merged.Stats.TotalFiles)
}
if merged.Stats.ReposSearched != 1 {
t.Errorf("ReposSearched = %d, want 1 (one logical repo across two cells)", merged.Stats.ReposSearched)
}
if merged.Stats.DurationMs != 10 {
t.Errorf("DurationMs = %v, want 10 (slowest cell preserved)", merged.Stats.DurationMs)
}
if len(merged.RepoStats) != 1 {
t.Fatalf("len(RepoStats) = %d, want 1 (deduped by repo)", len(merged.RepoStats))
}
if merged.RepoStats[0].MatchCount != 3 || merged.RepoStats[0].FileCount != 2 {
t.Errorf("RepoStats[0] = %+v, want representative {3,2} not summed {6,4}", merged.RepoStats[0])
}
}
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+683
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
36 unmodified lines
37
38
39
40
41
42
43
15 unmodified lines
59
60
61
61
62
63
64
65
66
62
63
64
65
66
67
68
69
70
71
72
73
74
34 unmodified lines
109
110
111
112
113
114
115
116
117
118
14 unmodified lines
133
134
135
127
136
137
138
139
1 unmodified line
141
142
143
135
144
145
146
147
574 unmodified lines
722
723
724
725
726
727
728
24 unmodified lines
753
754
755
756
757
758
759
760
761
762
763
20 unmodified lines
784
785
786
787
788
789
790
33 unmodified lines
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
72 unmodified lines
912
913
914
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
915
916
917
918
11 unmodified lines
930
931
932
933
934
935
936
144 unmodified lines
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
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
42 unmodified lines
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
65 unmodified lines
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
188 unmodified lines
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1402
1403
1404
1491
1492
1493
1494
1495
1496
1407
1497
1498
1499
1500
1410
1411
1501
1502
1503
1504
1505
1506
6 unmodified lines
1513
1514
1515
1424
1425
1426
1427
1428
1429
1430
1516
1517
1518
1519
1520
1521
1522
1523
1524
59 unmodified lines
1584
1585
1586
1496
1497
1498
1499
1500
1501
1587
1588
1589
1590
1591
1592
1593
1594
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
45 unmodified lines
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
40 unmodified lines
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
38 unmodified lines
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
36 unmodified lines
const (
agentFlagName = "agent"
flagCheckpointRemote = "checkpoint-remote"
flagCheckpointBackend = "checkpoint-backend"
flagSkipPushSessions = "skip-push-sessions"
flagSummarizeModel = "summarize-model"
flagSummarizeAgent = "summarize-provider"
15 unmodified lines
// EnableOptions holds the flags for `entire enable`.
type EnableOptions struct {
LocalDev bool
UseLocalSettings bool
UseProjectSettings bool
ForceHooks bool
SkipPushSessions bool
CheckpointRemote string
LocalDev bool
UseLocalSettings bool
UseProjectSettings bool
ForceHooks bool
SkipPushSessions bool
CheckpointRemote string
// CheckpointBackend selects the persistent checkpoint storage backend
// ("branch"/"refs" or the canonical "git-branch"/"git-refs"). Empty leaves
// the current/default (git-branch) backend in place.
CheckpointBackend string
Telemetry bool
AbsoluteGitHookPath bool
// SuppressDoneMessage tells `runEnableInteractive` to skip its final
34 unmodified lines
return cmd.Flags().Changed(flagCheckpointRemote) || cmd.Flags().Changed(flagSkipPushSessions)
}
func hasCheckpointBackendFlag(cmd *cobra.Command) bool {
return cmd.Flags().Changed(flagCheckpointBackend)
}
func hasSummaryProviderFlags(cmd *cobra.Command) bool {
return cmd.Flags().Changed(flagSummarizeAgent) || cmd.Flags().Changed(flagSummarizeModel)
}
14 unmodified lines
// hasConfigureSettingsFlags reports whether configure was invoked with any
// flag that mutates settings or hooks. Bare invocation prints help instead.
func hasConfigureSettingsFlags(cmd *cobra.Command) bool {
return hasStrategyFlags(cmd) || hasSummaryProviderFlags(cmd) || hasSummaryTimeoutFlag(cmd) || hasGlobalSettingsFlags(cmd)
return hasStrategyFlags(cmd) || hasCheckpointBackendFlag(cmd) || hasSummaryProviderFlags(cmd) || hasSummaryTimeoutFlag(cmd) || hasGlobalSettingsFlags(cmd)
}
// enableUsesSetupFlow reports whether `entire enable` should delegate to the
1 unmodified line
// Bare `enable` and `enable --local/--project` remain state-toggle operations;
// any other setup-mutating flag should share configure's behavior.
func enableUsesSetupFlow(cmd *cobra.Command, agentName string) bool {
if agentName != "" || hasStrategyFlags(cmd) || cmd.Flags().Changed(flagSearchSkill) || cmd.Flags().Changed(flagAgentHelpSkill) {
if agentName != "" || hasStrategyFlags(cmd) || hasCheckpointBackendFlag(cmd) || cmd.Flags().Changed(flagSearchSkill) || cmd.Flags().Changed(flagAgentHelpSkill) {
return true
}
return hasGlobalSettingsFlags(cmd) || cmd.Flags().Changed("yes")
574 unmodified lines
entire configure --absolute-git-hook-path # Reinstall git hook with absolute path
entire configure --force # Reinstall git hook
entire configure --checkpoint-remote github:org/checkpoints
entire configure --checkpoint-backend refs # Store each checkpoint as its own git ref
entire configure --summarize-provider claude-code
entire configure --summarize-timeout-seconds 300 # 5m deadline for explain --generate`,
RunE: func(cmd *cobra.Command, _ []string) error {
24 unmodified lines
return err
}
}
if hasCheckpointBackendFlag(cmd) {
if err := updateCheckpointBackend(ctx, cmd.OutOrStdout(), opts); err != nil {
return err
}
}
if hasSummaryProviderFlags(cmd) {
if err := updateSummaryGenerationSettings(ctx, cmd.OutOrStdout(), summarizeProvider, summarizeModel, opts); err != nil {
return err
20 unmodified lines
cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Reinstall the Entire git hook")
cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push")
cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)")
cmd.Flags().StringVar(&opts.CheckpointBackend, flagCheckpointBackend, "", "Checkpoint storage backend: branch (default) or refs (one git ref per checkpoint)")
cmd.Flags().StringVar(&summarizeProvider, flagSummarizeAgent, "", "Set the provider used by explain --generate (e.g., claude-code, codex, gemini, pi, cursor, copilot-cli)")
cmd.Flags().StringVar(&summarizeModel, flagSummarizeModel, "", "Set the model hint used by explain --generate")
cmd.Flags().IntVar(&summarizeTimeoutSeconds, flagSummarizeTimeout, 0, "Set the hard deadline (seconds) for explain --generate summary generation. 0 clears (falls back to 5m default).")
33 unmodified lines
}
reportRepoEnabled(ctx, insecureHTTPAuth)
}()
// Validate --checkpoint-backend up front so a bad value fails before we
// bootstrap a repo or install any hooks.
if hasCheckpointBackendFlag(cmd) {
if _, err := resolveCheckpointBackendType(opts.CheckpointBackend); err != nil {
cmd.SilenceUsage = true
return err
}
}
// Check if we're in a git repository first. If not, offer to
// bootstrap one (git init + optional GitHub repo). If the user
// declines, fall back to the legacy prerequisite error.
72 unmodified lines
// Any setup-mutating flags should behave like `configure` on repos that
// are already set up. Bare `enable` remains the lightweight re-enable path.
if settings.IsSetUpAny(ctx) {
usedSetupFlow := enableUsesSetupFlow(cmd, agentName)
if usedSetupFlow {
if hasStrategyFlags(cmd) {
if err := updateStrategyOptions(ctx, cmd.OutOrStdout(), opts); err != nil {
return err
}
}
if enableNeedsAgentManagement(cmd) {
var selectFn func(available []string) ([]string, error)
if opts.Yes {
selectFn = selectAllAgents
}
if err := runManageAgents(ctx, cmd.OutOrStdout(), opts, selectFn); err != nil {
return err
}
}
}
enabled, err := IsEnabled(ctx)
if err == nil && enabled {
w := cmd.OutOrStdout()
if !usedSetupFlow {
fmt.Fprintln(w, "Entire is already enabled.")
}
printEnabledStatus(ctx, w)
return nil
}
return runEnable(ctx, cmd.OutOrStdout(), opts.UseProjectSettings)
return runEnableOnConfiguredRepo(ctx, cmd, opts)
}
// Fresh repo — run full setup flow
11 unmodified lines
cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Force reinstall hooks (removes existing Entire hooks first)")
cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push")
cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)")
cmd.Flags().StringVar(&opts.CheckpointBackend, flagCheckpointBackend, "", "Checkpoint storage backend: branch (default) or refs (one git ref per checkpoint)")
cmd.Flags().BoolVar(&opts.Telemetry, flagTelemetry, true, "Enable anonymous usage analytics")
cmd.Flags().BoolVar(&opts.AbsoluteGitHookPath, flagAbsoluteGitHookPath, false, "Embed full binary path in git hooks (for GUI git clients that don't source shell profiles)")
cmd.Flags().BoolVar(&opts.SearchSkill, flagSearchSkill, false, "Install the optional Entire search skill for selected agent(s)")
144 unmodified lines
// runEnableInteractive runs the interactive enable flow.
// agents must be provided by the caller (via detectOrSelectAgent).
// runEnableOnConfiguredRepo handles `entire enable` when the repo is already set
// up. Setup-mutating flags (strategy options, checkpoint backend, agent
// management) behave like `configure`; a bare re-enable just flips the enabled
// flag or reports current status.
func runEnableOnConfiguredRepo(ctx context.Context, cmd *cobra.Command, opts EnableOptions) error {
w := cmd.OutOrStdout()
usedSetupFlow := enableUsesSetupFlow(cmd, "")
if usedSetupFlow {
if hasStrategyFlags(cmd) {
if err := updateStrategyOptions(ctx, w, opts); err != nil {
return err
}
}
if hasCheckpointBackendFlag(cmd) {
if err := updateCheckpointBackend(ctx, w, opts); err != nil {
return err
}
}
if enableNeedsAgentManagement(cmd) {
var selectFn func(available []string) ([]string, error)
if opts.Yes {
selectFn = selectAllAgents
}
if err := runManageAgents(ctx, w, opts, selectFn); err != nil {
return err
}
}
}
enabled, err := IsEnabled(ctx)
if err == nil && enabled {
if !usedSetupFlow {
fmt.Fprintln(w, "Entire is already enabled.")
}
printEnabledStatus(ctx, w)
return nil
}
return runEnable(ctx, w, opts.UseProjectSettings)
}
func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent, opts EnableOptions) error {
// Capture first-run status before we write any settings: setupEntireDirectory
// and saveSettings below make IsSetUpAny report true. maybeOfferSessionImport
// uses this so the import offer only fires on the very first enable.
firstRun := !settings.IsSetUpAny(ctx)
// Uninstall hooks for agents that were previously active but are no longer selected
if err := uninstallDeselectedAgentHooks(ctx, w, agents); err != nil {
return fmt.Errorf("failed to clean up deselected agents: %w", err)
42 unmodified lines
opts.applyStrategyOptions(settings)
// Checkpoint storage backend. An explicit --checkpoint-backend always wins.
// Otherwise, on the first interactive setup, offer a choice (default: branch).
// Non-interactive or --yes first runs keep the default git-branch backend.
if opts.CheckpointBackend == "" && firstRun && !opts.Yes && interactive.CanPromptInteractively() {
chosen, err := promptCheckpointBackend(ctx, w)
if err != nil {
return err
}
opts.CheckpointBackend = chosen // "" keeps the default (or cancelled) backend
}
if err := applyCheckpointBackendFlag(settings, opts.CheckpointBackend); err != nil {
return err
}
// Determine which settings file to write to
// First run always creates settings.json (no prompt)
entireDirAbs, err := paths.AbsPath(ctx, paths.EntireDir)
65 unmodified lines
return fmt.Errorf("failed to setup strategy: %w", err)
}
// Offer to import pre-existing agent history for the just-selected agents.
// First-run only; best-effort (never fails enable).
maybeOfferSessionImport(ctx, w, agents, opts, firstRun)
if opts.SuppressDoneMessage {
// Bootstrap finalize will print its own completion summary after
// making the initial commit and pushing.
188 unmodified lines
return count, nil
}
// promptAgentSelection shows the interactive multi-select agent picker and
// returns the chosen agent names. It is a package-level var so tests can
// substitute it — no real TTY/form is available under `go test`.
var promptAgentSelection = func(options []huh.Option[string]) ([]string, error) {
var selected []string
form := NewAccessibleForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Select the agents you want to use").
Description("Use space to select, enter to confirm.").
Options(options...).
Validate(func(sel []string) error {
if len(sel) == 0 {
return errors.New("please select at least one agent")
}
return nil
}).
Value(&selected),
),
)
if err := form.Run(); err != nil {
return nil, fmt.Errorf("agent selection cancelled: %w", err)
}
return selected, nil
}
// detectOrSelectAgent tries to auto-detect agents, or prompts the user to select.
// Returns the detected/selected agents and any error.
//
// On first run (no hooks installed):
// - Single detected built-in agent: used automatically
// - Single detected external agent: interactive multi-select prompt
// - Multiple/no detected agents: interactive multi-select prompt
// - Shows the interactive multi-select (TTY available and no selectFn override)
// - Pre-selects detected built-in agents so the user can confirm with enter
// or add more; detected external agents are shown but not pre-selected
// - Non-interactive (no TTY): uses detected agents, else the default agent
//
// On re-run (hooks already installed):
// - Always shows the interactive multi-select
// - Shows the interactive multi-select (TTY available and no selectFn override)
// - Pre-selects only agents that have hooks installed (respects prior deselection)
// - Non-interactive (no TTY): keeps the currently installed agents
//
// selectFn overrides the interactive prompt for testing. When nil, the real form is used.
// It receives available agent names and returns the selected names.
// selectFn overrides the prompt with a caller-supplied selection (--yes uses
// selectAllAgents; tests inject their own), bypassing the form even on a TTY.
// When nil, the real multi-select form is shown.
func detectOrSelectAgent(ctx context.Context, w io.Writer, selectFn func(available []string) ([]string, error)) ([]agent.Agent, error) {
// Check for agents with hooks already installed (re-run detection)
installedAgentNames := GetAgentsWithHooksInstalled(ctx)
6 unmodified lines
if !hasInstalledHooks {
switch {
case len(detected) == 1:
if isBuiltInAgent(detected[0]) {
// When a selectFn is provided (e.g. --yes), skip the single-agent
// shortcut so the caller's selection logic runs instead.
if selectFn == nil {
fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type())
return detected, nil
}
// Announce the single detected built-in agent; it is pre-selected
// in the multi-select form below so the user can confirm it or add
// more. --yes (selectFn != nil) uses the caller's selection and
// skips the announcement.
if selectFn == nil && isBuiltInAgent(detected[0]) {
fmt.Fprintf(w, "Detected agent: %s\n\n", detected[0].Type())
}
case len(detected) > 1:
59 unmodified lines
availableNames = append(availableNames, opt.Value)
}
var selectedAgentNames []string
if selectFn != nil {
var err error
selectedAgentNames, err = selectFn(availableNames)
if err != nil {
return nil, err
// selectFn overrides the prompt with a caller-supplied selection (--yes,
// tests). When nil, show the real interactive multi-select. Routing both
// through selectFn keeps a single selection step, so there is no "skip the
// picker" path a lone detected agent can slip back into.
if selectFn == nil {
selectFn = func([]string) ([]string, error) {
return promptAgentSelection(options)
}
if len(selectedAgentNames) == 0 {
return nil, errors.New("no agents selected")
}
} else {
form := NewAccessibleForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Select the agents you want to use").
Description("Use space to select, enter to confirm.").
Options(options...).
Validate(func(selected []string) error {
if len(selected) == 0 {
return errors.New("please select at least one agent")
}
return nil
}).
Value(&selectedAgentNames),
),
)
if err := form.Run(); err != nil {
return nil, fmt.Errorf("agent selection cancelled: %w", err)
}
}
selectedAgentNames, err := selectFn(availableNames)
if err != nil {
return nil, err
}
if len(selectedAgentNames) == 0 {
return nil, errors.New("no agents selected")
}
selectedAgents := make([]agent.Agent, 0, len(selectedAgentNames))
for _, name := range selectedAgentNames {
45 unmodified lines
// setupAgentHooksNonInteractive sets up hooks for a specific agent non-interactively.
// If strategyName is provided, it sets the strategy; otherwise uses default.
func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Agent, opts EnableOptions) error {
// Capture first-run status before setupEntireDirectory/saveEnabledState make
// IsSetUpAny report true, so the import offer fires only on first enable.
firstRun := !settings.IsSetUpAny(ctx)
agentName := ag.Name()
// Check if agent supports hooks
if _, ok := agent.AsHookSupport(ag); !ok {
40 unmodified lines
opts.applyStrategyOptions(settings)
// Apply an explicit --checkpoint-backend (no prompt on this non-interactive path).
if err := applyCheckpointBackendFlag(settings, opts.CheckpointBackend); err != nil {
return err
}
// Handle telemetry for non-interactive mode
// Note: if telemetry is nil (not configured), it defaults to disabled
if !opts.Telemetry || os.Getenv("ENTIRE_TELEMETRY_OPTOUT") != "" {
38 unmodified lines
return fmt.Errorf("failed to setup strategy: %w", err)
}
// Offer to import pre-existing history for the just-configured agent.
// First-run only; best-effort (never fails enable).
maybeOfferSessionImport(ctx, w, []agent.Agent{ag}, opts, firstRun)
if opts.SuppressDoneMessage {
// Bootstrap finalize will print its own completion summary.
return nil
Mcmd/entire/cli/setup.go+167/-77
package cli
import ( "context" "fmt" "io" "time"
"charm.land/huh/v2"
"github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agentimport" "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" )
// eligibleImport pairs a just-selected agent with its importer and the number // of sessions discoverable for the current repo within the lookback window. type eligibleImport struct { imp agentimport.Importer displayName string sessionCount int }
// Seams for testing the orchestration in maybeOfferSessionImport without disk // discovery, a real TTY, or real checkpoint writes. Production wiring uses the // real implementations below. var ( sessionImportDiscover = discoverImportableAgents sessionImportPrompt = promptImportSelection sessionImportRun = runSelectedImports )
// maybeOfferSessionImport offers, on first-time enable only, to import
// pre-existing agent history for the just-selected agents. Granularity is
// agent-level: choosing an agent imports all its discoverable sessions (30-day
// lookback, matching entire import). It is best-effort — discovery or import
// failures are logged and reported to the user but never fail enable.
//
// Import only happens on an explicit choice: an interactive run presents a
// multi-select (nothing pre-checked) and imports what the user selects; --yes
// ("accept all defaults") auto-imports all eligible agents. A non-interactive
// run without --yes (a script, a piped shell, or an agent with no TTY) makes
// no choice, so it imports nothing and just points at entire import — silently
// importing history there would be surprising.
func maybeOfferSessionImport(ctx context.Context, w io.Writer, agents []agent.Agent, opts EnableOptions, firstRun bool) {
if !firstRun {
return
}
repoRoot, err := paths.WorktreeRoot(ctx) if err != nil { // No worktree root => nothing to import against. Enabling still succeeds. logging.Warn(ctx, "session import offer skipped: no worktree root", "error", err) return }
eligible := sessionImportDiscover(ctx, agents, repoRoot) if len(eligible) == 0 { return }
selected := eligible
if !opts.Yes {
if !interactive.CanPromptInteractively() {
// Non-interactive without --yes: don't silently import. Leave a
// pointer so scripted/agent enables can still import on demand.
logging.Info(ctx, "session import offer skipped: non-interactive without --yes", "eligible", len(eligible))
fmt.Fprintf(w, "Found importable history for %s. Run 'entire import
sessionImportRun(ctx, w, repoRoot, selected) }
// discoverImportableAgents keeps the selected agents that have a registered // importer and at least one discoverable session for the repo. func discoverImportableAgents(ctx context.Context, agents []agent.Agent, repoRoot string) []eligibleImport { now := time.Now() var out []eligibleImport for _, ag := range agents { imp := importerForAgent(ag) if imp == nil { continue } sessions, err := imp.Discover(repoRoot, "", now, nil) if err != nil { logging.Warn(ctx, "session import discovery failed", "agent", string(ag.Type()), "error", err) continue } if len(sessions) == 0 { continue } out = append(out, eligibleImport{ imp: imp, displayName: string(ag.Type()), sessionCount: len(sessions), }) } return out }
// importerForAgent finds the importer for an agent by matching AgentType, which // is the shared display-name identity between the two seams (importer Name and // AgentType are distinct concepts, so match on type rather than name). func importerForAgent(ag agent.Agent) agentimport.Importer { for _, imp := range agentimport.All() { if imp.AgentType() == ag.Type() { return imp } } return nil }
// promptImportSelection asks the user which discovered agents to import. With a // single eligible agent a multi-select's "space to select / select none to // skip" wording is confusing (there is nothing to choose between), so that case // uses a plain Import/Skip confirmation instead. An empty selection (or user // abort) returns an empty slice, which the caller treats as "skip import". func promptImportSelection(ctx context.Context, w io.Writer, eligible []eligibleImport) ([]eligibleImport, error) { if len(eligible) == 1 { return promptImportConfirmSingle(ctx, w, eligible[0]) }
byName := make(map[string]eligibleImport, len(eligible)) options := make([]huh.Option[string], 0, len(eligible)) for _, e := range eligible { byName[e.imp.Name()] = e label := fmt.Sprintf("%s (%s, last %d days)", e.displayName, pluralSessions(e.sessionCount), agentimport.LookbackDays) options = append(options, huh.NewOption(label, e.imp.Name())) }
var chosen []string form := NewAccessibleForm( huh.NewGroup( huh.NewMultiSelectstring. Title("Import existing sessions into Entire? (optional)"). Description("Space to select, enter to confirm. Select none to skip."). Options(options...). Value(&chosen), ), ) if err := form.RunWithContext(ctx); err != nil { // Cancellation (including a cancelled ctx) returns nil here => skip // import; other errors are surfaced for the caller to downgrade. return nil, handleFormCancellation(w, "Import", err) }
out := make([]eligibleImport, 0, len(chosen)) for _, name := range chosen { if e, ok := byName[name]; ok { out = append(out, e) } } return out, nil }
// promptImportConfirmSingle offers a single discovered agent's history with a // plain Import/Skip confirmation. Declining (or aborting) returns an empty // slice so the caller skips the import. func promptImportConfirmSingle(ctx context.Context, w io.Writer, e eligibleImport) ([]eligibleImport, error) { var confirmed bool form := NewAccessibleForm( huh.NewGroup( huh.NewConfirm(). Title(fmt.Sprintf("Import existing %s sessions into Entire? (optional)", e.displayName)). Description(fmt.Sprintf("%s from the last %d days. Enter to confirm.", pluralSessions(e.sessionCount), agentimport.LookbackDays)). Affirmative("Import"). Negative("Skip"). Value(&confirmed), ), ) if err := form.RunWithContext(ctx); err != nil { // Cancellation (including a cancelled ctx) returns nil here => skip // import; other errors are surfaced for the caller to downgrade. return nil, handleFormCancellation(w, "Import", err) } if !confirmed { return nil, nil } return []eligibleImport{e}, nil }
// runSelectedImports imports each chosen agent's history, mirroring the
// standalone entire import command. Per-agent failures are logged and
// reported but do not stop the remaining imports or fail enable.
func runSelectedImports(ctx context.Context, w io.Writer, repoRoot string, selected []eligibleImport) {
repo, err := openRepository(ctx)
if err != nil {
logging.Warn(ctx, "session import skipped: open repository failed", "error", err)
fmt.Fprintf(w, "Note: could not import agent history: %v\n", err)
return
}
defer repo.Close()
// Gate on the checkpoint policy before writing any checkpoint data, matching
// the standalone entire import command. Best-effort: an unsupported or
// unreadable policy skips the import (logged and noted) instead of failing
// enable, since the offer must never break enable.
if err := ensureCheckpointPolicyAllowsCheckpointData(ctx, repo); err != nil {
logging.Warn(ctx, "session import skipped: checkpoint policy not satisfied", "error", err)
fmt.Fprintf(w, "Note: skipping agent history import: %v\n", err)
return
}
// Load repo/user-configured redaction before any checkpoint write, matching // import_cmd.go; without it only always-on secret scanning would run. strategy.EnsureRedactionConfigured()
for _, e := range selected { res, err := agentimport.Run(ctx, repo, e.imp, agentimport.Options{ RepoRoot: repoRoot, Now: time.Now(), }) if err != nil { logging.Warn(ctx, "session import failed", "agent", e.imp.Name(), "error", err) fmt.Fprintf(w, "Note: could not import %s history: %v\n", e.displayName, err) continue } fmt.Fprintf(w, "Imported %d turn(s) from %d session(s) (%d already imported).\n", res.TurnsImported, res.SessionsScanned, res.TurnsSkipped) } }
// pluralSessions renders a session count with correct pluralization. func pluralSessions(n int) string { if n == 1 { return "1 session" } return fmt.Sprintf("%d sessions", n) }
// pluralAgents renders an agent count with correct pluralization. func pluralAgents(n int) string { if n == 1 { return "1 agent" } return fmt.Sprintf("%d agents", n) }
Acmd/entire/cli/setup\_import.go+253
package cli
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/agentimport"
"github.com/entireio/cli/cmd/entire/cli/checkpointpolicy"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/go-git/go-git/v6/plumbing"
)
// fakeAgent satisfies agent.Agent via an embedded nil interface; only Type() is
// implemented because that is all the import-offer code calls. Calling any other
// method would panic, which is the intended guard.
type fakeAgent struct {
agent.Agent
typ types.AgentType
}
func (f fakeAgent) Type() types.AgentType { return f.typ }
func TestPluralSessions(t *testing.T) {
t.Parallel()
cases := map[int]string{0: "0 sessions", 1: "1 session", 2: "2 sessions", 42: "42 sessions"}
for n, want := range cases {
if got := pluralSessions(n); got != want {
t.Errorf("pluralSessions(%d) = %q, want %q", n, got, want)
}
}
}
func TestImporterForAgent_MatchesByType(t *testing.T) {
t.Parallel()
// Every registered importer must be resolvable from an agent carrying the
// same AgentType — this is the contract the offer relies on.
for _, imp := range agentimport.All() {
ag := fakeAgent{typ: imp.AgentType()}
got := importerForAgent(ag)
if got == nil {
t.Errorf("importerForAgent(%q) = nil, want importer %q", imp.AgentType(), imp.Name())
continue
}
if got.Name() != imp.Name() {
t.Errorf("importerForAgent(%q) = %q, want %q", imp.AgentType(), got.Name(), imp.Name())
}
}
}
func TestImporterForAgent_UnknownTypeReturnsNil(t *testing.T) {
t.Parallel()
if got := importerForAgent(fakeAgent{typ: "Definitely Not A Real Agent"}); got != nil {
t.Errorf("importerForAgent(unknown) = %q, want nil", got.Name())
}
}
// withImportSeams overrides the package seams and restores them after the test.
// Tests using it must not call t.Parallel (shared package state).
func withImportSeams(t *testing.T, discover func(context.Context, []agent.Agent, string) []eligibleImport, prompt func(context.Context, io.Writer, []eligibleImport) ([]eligibleImport, error), run func(context.Context, io.Writer, string, []eligibleImport)) {
t.Helper()
oldDiscover, oldPrompt, oldRun := sessionImportDiscover, sessionImportPrompt, sessionImportRun
t.Cleanup(func() {
sessionImportDiscover, sessionImportPrompt, sessionImportRun = oldDiscover, oldPrompt, oldRun
})
if discover != nil {
sessionImportDiscover = discover
}
if prompt != nil {
sessionImportPrompt = prompt
}
if run != nil {
sessionImportRun = run
}
}
func TestMaybeOfferSessionImport_FirstRunGate(t *testing.T) {
// Not parallel: overrides package seams. No repo needed — the gate returns
// before any discovery.
called := false
withImportSeams(t,
func(context.Context, []agent.Agent, string) []eligibleImport {
called = true
return []eligibleImport{{displayName: "X", sessionCount: 1}}
}, nil, nil)
maybeOfferSessionImport(context.Background(), io.Discard, nil, EnableOptions{}, false /* firstRun */)
if called {
t.Error("discovery ran on a non-first-run enable; the offer must be gated to first run")
}
}
func TestMaybeOfferSessionImport_NonInteractiveAutoImportsAll(t *testing.T) {
// Not parallel: overrides seams and chdirs into a temp repo.
dir := t.TempDir()
testutil.InitRepo(t, dir)
t.Chdir(dir)
eligible := []eligibleImport{
{displayName: testAgentClaude, sessionCount: 3},
{displayName: "Codex", sessionCount: 1},
}
var ran []eligibleImport
promptCalled := false
withImportSeams(t,
func(context.Context, []agent.Agent, string) []eligibleImport { return eligible },
func(context.Context, io.Writer, []eligibleImport) ([]eligibleImport, error) {
promptCalled = true
return nil, nil
},
func(_ context.Context, _ io.Writer, _ string, sel []eligibleImport) { ran = sel },
)
// opts.Yes forces the non-interactive path even if a TTY is present.
maybeOfferSessionImport(context.Background(), io.Discard, nil, EnableOptions{Yes: true}, true)
if promptCalled {
t.Error("prompt shown under --yes; non-interactive enable must not prompt")
}
if len(ran) != len(eligible) {
t.Fatalf("imported %d agents, want all %d", len(ran), len(eligible))
}
}
func TestMaybeOfferSessionImport_NonInteractiveWithoutYesSkips(t *testing.T) {
// Not parallel: overrides seams and chdirs into a temp repo.
dir := t.TempDir()
testutil.InitRepo(t, dir)
t.Chdir(dir)
// No ENTIRE_TEST_TTY => CanPromptInteractively() is false (non-interactive),
// e.g. a scripted or agent-driven enable.
promptCalled := false
var ran []eligibleImport
withImportSeams(t,
func(context.Context, []agent.Agent, string) []eligibleImport {
return []eligibleImport{{displayName: testAgentClaude, sessionCount: 3}}
},
func(context.Context, io.Writer, []eligibleImport) ([]eligibleImport, error) {
promptCalled = true
return nil, nil
},
func(_ context.Context, _ io.Writer, _ string, sel []eligibleImport) { ran = sel },
)
// No --yes and no TTY: neither prompt nor auto-import; just hint at the
// manual command.
var buf bytes.Buffer
maybeOfferSessionImport(context.Background(), &buf, nil, EnableOptions{}, true)
if promptCalled {
t.Error("prompt shown in a non-interactive context")
}
if len(ran) != 0 {
t.Errorf("auto-imported %d agent(s) without --yes in a non-interactive context; expected skip", len(ran))
}
if got := buf.String(); !strings.Contains(got, "entire import") {
t.Errorf("expected a pointer to 'entire import', got %q", got)
}
}
func TestMaybeOfferSessionImport_NoEligibleIsNoOp(t *testing.T) {
dir := t.TempDir()
testutil.InitRepo(t, dir)
t.Chdir(dir)
runCalled := false
withImportSeams(t,
func(context.Context, []agent.Agent, string) []eligibleImport { return nil },
nil,
func(context.Context, io.Writer, string, []eligibleImport) { runCalled = true },
)
maybeOfferSessionImport(context.Background(), io.Discard, nil, EnableOptions{Yes: true}, true)
if runCalled {
t.Error("import ran with nothing discoverable; expected a silent no-op")
}
}
func TestMaybeOfferSessionImport_InteractiveUsesSelection(t *testing.T) {
dir := t.TempDir()
testutil.InitRepo(t, dir)
t.Chdir(dir)
// Force interactive so the prompt branch is taken.
t.Setenv("ENTIRE_TEST_TTY", "1")
eligible := []eligibleImport{
{displayName: testAgentClaude, sessionCount: 3},
{displayName: "Codex", sessionCount: 1},
}
var ran []eligibleImport
withImportSeams(t,
func(context.Context, []agent.Agent, string) []eligibleImport { return eligible },
func(_ context.Context, _ io.Writer, e []eligibleImport) ([]eligibleImport, error) {
return e[:1], nil // user picks only the first
},
func(_ context.Context, _ io.Writer, _ string, sel []eligibleImport) { ran = sel },
)
maybeOfferSessionImport(context.Background(), io.Discard, nil, EnableOptions{}, true)
if len(ran) != 1 || ran[0].displayName != testAgentClaude {
t.Fatalf("imported %+v, want only the user-selected Claude Code", ran)
}
}
func TestMaybeOfferSessionImport_EmptySelectionSkips(t *testing.T) {
dir := t.TempDir()
testutil.InitRepo(t, dir)
t.Chdir(dir)
t.Setenv("ENTIRE_TEST_TTY", "1")
runCalled := false
withImportSeams(t,
func(context.Context, []agent.Agent, string) []eligibleImport {
return []eligibleImport{{displayName: testAgentClaude, sessionCount: 3}}
},
func(context.Context, io.Writer, []eligibleImport) ([]eligibleImport, error) { return nil, nil },
func(context.Context, io.Writer, string, []eligibleImport) { runCalled = true },
)
maybeOfferSessionImport(context.Background(), io.Discard, nil, EnableOptions{}, true)
if runCalled {
t.Error("import ran after an empty selection; expected skip")
}
}
func TestRunSelectedImports_UnsatisfiablePolicySkips(t *testing.T) {
// Not parallel: chdirs into a temp repo and reads CWD-based git state.
dir := t.TempDir()
testutil.InitRepo(t, dir)
t.Chdir(dir)
ctx := context.Background()
// Install a checkpoint policy this CLI cannot satisfy (a future format).
// The gate must skip the import, matching the standalone `entire import`
// command's ensureCheckpointPolicyAllowsCheckpointData check.
repo, err := openRepository(ctx)
if err != nil {
t.Fatalf("open repository: %v", err)
}
future := checkpointpolicy.Policy{CheckpointVersion: "branch-v99", CheckpointMinVersion: "branch-v99"}
if _, err := checkpointpolicy.WriteLocal(ctx, repo, plumbing.ZeroHash, future); err != nil {
t.Fatalf("write local policy: %v", err)
}
repo.Close()
// A nil importer would panic if the import loop ran, so the gate returning
// before the loop is exactly what keeps this from blowing up.
var buf bytes.Buffer
runSelectedImports(ctx, &buf, dir, []eligibleImport{{displayName: testAgentClaude}})
if got := buf.String(); !strings.Contains(got, "skipping agent history import") {
t.Errorf("expected a skip note for an unsatisfiable checkpoint policy, got %q", got)
}
}
func TestMaybeOfferSessionImport_PromptErrorIsBestEffort(t *testing.T) {
dir := t.TempDir()
testutil.InitRepo(t, dir)
t.Chdir(dir)
t.Setenv("ENTIRE_TEST_TTY", "1")
runCalled := false
withImportSeams(t,
func(context.Context, []agent.Agent, string) []eligibleImport {
return []eligibleImport{{displayName: testAgentClaude, sessionCount: 3}}
},
func(context.Context, io.Writer, []eligibleImport) ([]eligibleImport, error) {
return nil, errors.New("terminal exploded")
},
func(context.Context, io.Writer, string, []eligibleImport) { runCalled = true },
)
// A prompt failure must never fail enable: the offer is best-effort, so this
// simply returns and does not panic or propagate.
maybeOfferSessionImport(context.Background(), io.Discard, nil, EnableOptions{}, true)
if runCalled {
t.Error("import ran after a prompt error; expected skip")
}
}
Acmd/entire/cli/setup_import_test.go+284
11 unmodified lines
12
13
14
15
16
17
18
1157 unmodified lines
1176
1177
1178
1179
1180
1181
1182
1183
1184
46 unmodified lines
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
11 unmodified lines
"strings"
"testing"
"charm.land/huh/v2"
"github.com/entireio/cli/cmd/entire/cli/agent"
_ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
"github.com/entireio/cli/cmd/entire/cli/agent/external"
1157 unmodified lines
t.Fatalf("Failed to create .claude directory: %v", err)
}
// No TTY here, so this exercises the non-interactive fallback: the single
// detected agent is used without a picker. The interactive path pre-selects
// it in the multi-select instead (see FirstRun_SingleBuiltIn test below).
var buf bytes.Buffer
agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
if err != nil {
46 unmodified lines
}
}
func TestDetectOrSelectAgent_FirstRun_SingleBuiltIn_ShowsPickerPreSelected(t *testing.T) {
// Not parallel: uses t.Chdir/t.Setenv and swaps the package-level
// promptAgentSelection seam.
setupTestRepo(t)
t.Setenv("ENTIRE_TEST_TTY", "1")
// Create .claude directory so exactly one built-in agent (Claude Code) is detected.
if err := os.MkdirAll(".claude", 0o755); err != nil {
t.Fatalf("Failed to create .claude directory: %v", err)
}
// First run: no hooks installed yet.
if installed := GetAgentsWithHooksInstalled(context.Background()); len(installed) != 0 {
t.Fatalf("Expected no installed hooks on first run, got %v", installed)
}
// Stub the real picker so we can assert it is shown (rather than the agent
// being auto-used) and inspect which options it was given. Driving the
// selectFn == nil path is what makes this a real regression guard: the old
// shortcut returned early precisely when selectFn == nil, so a test that
// injected a selectFn would have passed even before the fix.
prev := promptAgentSelection
t.Cleanup(func() { promptAgentSelection = prev })
var offered []string
var shown bool
promptAgentSelection = func(options []huh.Option[string]) ([]string, error) {
shown = true
for _, o := range options {
offered = append(offered, o.Value)
}
return []string{string(agent.AgentNameClaudeCode)}, nil
}
var buf bytes.Buffer
agents, err := detectOrSelectAgent(context.Background(), &buf, nil)
if err != nil {
t.Fatalf("detectOrSelectAgent() error = %v", err)
}
// A lone detected built-in agent must no longer be auto-used: the picker
// must be shown so the user can confirm it or add more.
if !shown {
t.Fatal("Expected the picker to be shown for a single detected agent, but it was auto-used")
}
if !slices.Contains(offered, string(agent.AgentNameClaudeCode)) {
t.Errorf("Expected the detected agent among the picker options, got %v", offered)
}
if len(agents) != 1 || agents[0].Name() != agent.AgentNameClaudeCode {
t.Fatalf("Expected the picked agent [claude-code] to be returned, got %v", agents)
}
}
func TestDetectOrSelectAgent_OnlyExternalDetected_WithTTY_PromptsUser(t *testing.T) {
// Cannot use t.Parallel() because we use t.Chdir, t.Setenv, and global agent registration
if _, err := exec.LookPath("sh"); err != nil {
Mcmd/entire/cli/setup_test.go+56
13 unmodified lines
14
15
16
17
18
19
20
50 unmodified lines
71
72
73
73
74
75
76
77
8 unmodified lines
86
87
88
88
89
90
91
92
89
90
91
92
93
94
95
96
97
98
99
99
100
101
102
103
5 unmodified lines
109
110
111
111
112
113
114
112
113
114
115
13 unmodified lines
"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/uiform"
)
// OPFDecision is the resolved gate for a single pre-push OPF run.
50 unmodified lines
os.Getenv(envOPF),
promptDefault,
hasTTY,
func() (OPFDecision, error) { return askOPFPrompt(ctx, isAccessibleMode()) },
func() (OPFDecision, error) { return askOPFPrompt(ctx) },
)
if err != nil {
return OPFAbort, err
8 unmodified lines
// OPFAbort. Selecting "Always" persists prompt_default=always to
// .entire/settings.local.json so future pushes don't ask.
//
// Style matches other entire CLI prompts: Dracula theme via
// huh.ThemeDracula (the same theme cli.NewAccessibleForm applies for
// callers in the cli package). Strategy can't import cli (cycle), so
// we apply the theme inline.
func askOPFPrompt(ctx context.Context, accessible bool) (OPFDecision, error) {
// Style matches other entire CLI prompts via uiform.New, which applies the
// shared base16 palette theme and accessibility handling (the same wiring
// cli.NewAccessibleForm uses). uiform is a leaf package, so strategy can
// import it without the cycle that importing cli would create.
func askOPFPrompt(ctx context.Context) (OPFDecision, error) {
const (
choiceYes = "yes"
choiceNo = "no"
choiceAlways = "always"
)
choice := choiceYes
form := huh.NewForm(
form := uiform.New(
huh.NewGroup(
huh.NewSelect[string]().
Title("Run OpenAI Privacy Filter on these checkpoints?").
5 unmodified lines
).
Value(&choice),
),
).WithTheme(huh.ThemeFunc(huh.ThemeDracula))
if accessible {
form = form.WithAccessible(true)
}
)
if err := form.RunWithContext(ctx); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return OPFAbort, nil
Mcmd/entire/cli/strategy/manual_commit_opf_prompt.go+9/-11
3 unmodified lines
4
5
6
7
7
8
9
10
11
12
14
15
16
17
18
19
13
14
15
3 unmodified lines
"context"
"fmt"
"io"
"os"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/go-git/go-git/v6/plumbing"
)
// isAccessibleMode returns true if accessibility mode should be enabled.
// This checks the ACCESSIBLE environment variable.
func isAccessibleMode() bool {
return os.Getenv("ACCESSIBLE") != ""
}
// Reset deletes the shadow branch and session state for the current HEAD.
// This allows starting fresh without existing checkpoints.
func (s *ManualCommitStrategy) Reset(ctx context.Context, w, errW io.Writer) error {
Mcmd/entire/cli/strategy/manual_commit_reset.go-7
19 unmodified lines
20
21
22
23
24
25
26
1036 unmodified lines
1063
1064
1065
1065
1066
1067
1068
1069
1070
1071
1072
1072
1073
1074
1073
1074
1075
19 unmodified lines
"github.com/entireio/cli/cmd/entire/cli/osroot"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/trailers"
"github.com/entireio/cli/cmd/entire/cli/uiform"
"github.com/entireio/cli/cmd/entire/cli/validation"
"charm.land/huh/v2"
1036 unmodified lines
fmt.Fprintf(errW, "\nOverwriting will lose the newer local entries.\n\n")
var confirmed bool
form := huh.NewForm(
form := uiform.New(
huh.NewGroup(
huh.NewConfirm().
Title("Overwrite local session logs with checkpoint versions?").
Value(&confirmed),
),
)
if isAccessibleMode() {
form = form.WithAccessible(true)
}
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
Mcmd/entire/cli/strategy/manual_commit_rewind.go+2/-4
12 unmodified lines
13
14
15
16
17
18
19
95 unmodified lines
115
116
117
117
118
119
120
118
119
120
121
12 unmodified lines
"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/uiform"
)
// envKillSwitch disables the interactive update prompt regardless of TTY.
95 unmodified lines
huh.NewOption("Skip until next version", autoUpdateActionSkipUntilNextVersion),
).
Value(&action)
form := huh.NewForm(huh.NewGroup(sel)).WithTheme(huh.ThemeFunc(huh.ThemeDracula))
if os.Getenv("ACCESSIBLE") != "" {
form = form.WithAccessible(true)
}
form := uiform.New(huh.NewGroup(sel))
if err := form.RunWithContext(ctx); err != nil {
if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) {
return autoUpdateActionSkip, nil
Mcmd/entire/cli/versioncheck/autoupdate.go+2/-4
33 unmodified lines
34
35
36
37
38
39
40
49 unmodified lines
90
91
92
92
93
93
94
95
96
97
98
99
113 unmodified lines
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
33 unmodified lines
"time"
"github.com/entireio/cli/cmd/entire/cli/auth"
"github.com/entireio/cli/cmd/entire/cli/gitremote"
"github.com/entireio/cli/cmd/entire/cli/versioninfo"
"github.com/entireio/cli/internal/entireclient/clusterdiscovery"
"github.com/entireio/cli/internal/entireclient/httpclient"
49 unmodified lines
case parsedURL.Scheme != "entire":
fmt.Fprintf(os.Stderr, "fatal: unsupported URL scheme %q (expected 'entire')\n", parsedURL.Scheme)
return 128
case parsedURL.Host == "":
fmt.Fprintf(os.Stderr, "fatal: missing host in URL %q\n", rawURL)
case parsedURL.Host == "" || gitremote.IsSupportedForge(parsedURL.Host):
// Cluster host absent (empty, or a forge id in its slot);
// missingClusterHostMessage renders the actionable hint.
fmt.Fprint(os.Stderr, missingClusterHostMessage(parsedURL, rawURL))
return 128
}
113 unmodified lines
return fmt.Sprintf("fatal: %v\n", err)
}
// missingClusterHostMessage renders the stderr "fatal: …" line for an entire://
// URL that omits its cluster host. Two shapes reach here: a forge id typed
// where the host belongs (entire://gh/owner/repo, Host="gh") and an empty host
// (entire:///gh/owner/repo, Host=""). When the reconstructed shorthand is a
// complete forge/owner/repo triple that `entire repo clone` can resolve, it
// points at the interactive picker; a partial path (entire://gh,
// entire://gh/owner) or a non-forge segment falls back to the plain
// missing-host error rather than suggesting a clone command that would reject
// the ref. Kept pure so it's unit-testable.
func missingClusterHostMessage(parsedURL *url.URL, rawURL string) string {
// Reconstruct the forge/owner/repo shorthand the user likely intended: a
// forge id in the host slot sits in front of the path; an empty host
// already has it there.
shorthand := strings.TrimPrefix(parsedURL.Path, "/")
if parsedURL.Host != "" {
shorthand = parsedURL.Host + "/" + shorthand
}
// Only point at `entire repo clone` for a complete forge/owner/repo triple
// (the shape parseMirrorCloneRef accepts); anything shorter would relocate
// the failure into a clone command that rejects the ref.
seg := strings.Split(strings.Trim(shorthand, "/"), "/")
if len(seg) != 3 || seg[0] == "" || seg[1] == "" || seg[2] == "" || !gitremote.IsSupportedForge(seg[0]) {
return fmt.Sprintf("fatal: missing host in URL %q\n", rawURL)
}
return fmt.Sprintf(
"fatal: entire:// URL is missing its cluster host (%q is a forge id, not a host).\n"+
"The full form is entire://<cluster-host>/%s/<owner>/<repo>.\n"+
"To pick a mirror interactively, run:\n\n entire repo clone /%s\n",
seg[0], seg[0], strings.Join(seg, "/"))
}
// loadedVersion populates the build info and returns the resolved version.
func loadedVersion() string {
versioninfo.Load()
Mcmd/git-remote-entire/main.go+36/-2
172 unmodified lines
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
172 unmodified lines
}
}
func TestMissingClusterHostMessage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURL string
contains []string
notContains []string
}{
{
// The motivating case: forge id typed where the cluster host belongs.
name: "forge id in host slot points at repo clone",
rawURL: "entire://gh/entire.io/cli",
contains: []string{"missing its cluster host", `"gh" is a forge id`, "entire repo clone /gh/entire.io/cli"},
},
{
// Empty host but the path already reads as a forge shorthand.
name: "empty host with forge path points at repo clone",
rawURL: "entire:///gh/entire.io/cli",
contains: []string{"missing its cluster host", "entire repo clone /gh/entire.io/cli"},
},
{
// Empty host, leading segment is not a known forge → generic error.
name: "empty host with non-forge path falls back",
rawURL: "entire:///not-a-forge/owner/repo",
contains: []string{`fatal: missing host in URL "entire:///not-a-forge/owner/repo"`},
notContains: []string{"entire repo clone"},
},
{
name: "bare scheme falls back",
rawURL: "entire://",
contains: []string{`fatal: missing host in URL "entire://"`},
notContains: []string{"entire repo clone"},
},
{
// Not enough path to form owner/repo → not worth pointing at clone.
name: "empty host single-segment path falls back",
rawURL: "entire:///gh",
contains: []string{`fatal: missing host in URL "entire:///gh"`},
notContains: []string{"entire repo clone"},
},
{
// Forge in host slot but no owner/repo — the shorthand `/gh` would be
// rejected by `entire repo clone`, so fall back rather than suggest it.
name: "forge in host slot without path falls back",
rawURL: "entire://gh",
contains: []string{`fatal: missing host in URL "entire://gh"`},
notContains: []string{"entire repo clone"},
},
{
// Forge in host slot with owner but no repo — incomplete triple.
name: "forge in host slot with owner only falls back",
rawURL: "entire://gh/owner",
contains: []string{`fatal: missing host in URL "entire://gh/owner"`},
notContains: []string{"entire repo clone"},
},
{
// Empty host, forge + owner but no repo — incomplete triple.
name: "empty host forge and owner only falls back",
rawURL: "entire:///gh/owner",
contains: []string{`fatal: missing host in URL "entire:///gh/owner"`},
notContains: []string{"entire repo clone"},
},
{
// Too many segments — not the gh/<owner>/<repo> shape either.
name: "forge in host slot with extra path segment falls back",
rawURL: "entire://gh/owner/repo/extra",
contains: []string{`fatal: missing host in URL "entire://gh/owner/repo/extra"`},
notContains: []string{"entire repo clone"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
parsed, err := url.Parse(tc.rawURL)
if err != nil {
t.Fatalf("parse %q: %v", tc.rawURL, err)
}
got := missingClusterHostMessage(parsed, tc.rawURL)
for _, sub := range tc.contains {
if !strings.Contains(got, sub) {
t.Errorf("missingClusterHostMessage(%q) = %q, missing %q", tc.rawURL, got, sub)
}
}
for _, sub := range tc.notContains {
if strings.Contains(got, sub) {
t.Errorf("missingClusterHostMessage(%q) = %q, should not contain %q", tc.rawURL, got, sub)
}
}
})
}
}
func TestCoreTrusted(t *testing.T) {
t.Parallel()
trusted := []string{"https://core.us.entire.io", "https://core.eu.entire.io/"}
Mcmd/git-remote-entire/main_test.go+92
2 unmodified lines
3
4
5
6
7
6
7
8
9
10
10
11
12
13
14
15
15
16
17
18
7 unmodified lines
26
27
28
29
29
30
31
32
27 unmodified lines
60
61
62
63
63
64
65
66
2 unmodified lines
go 1.26.4
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/glamour/v2 v2.0.1
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.4
charm.land/lipgloss/v2 v2.0.5
github.com/betterleaks/betterleaks v1.5.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/creack/pty v1.1.24
github.com/denisbrodbeck/machineid v1.0.1
github.com/entireio/auth-go v0.5.0
github.com/entireio/auth-go v0.5.2
github.com/go-faster/errors v0.7.1
github.com/go-faster/jx v1.2.0
github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6
7 unmodified lines
github.com/muesli/termenv v0.16.0
github.com/ogen-go/ogen v1.22.0
github.com/oklog/ulid/v2 v2.1.1
github.com/posthog/posthog-go v1.16.2
github.com/posthog/posthog-go v1.17.5
github.com/sergi/go-diff v1.4.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
27 unmodified lines
github.com/bodgit/windows v1.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
Mgo.mod+6/-6
1
2
3
4
5
6
3
4
5
6
7
8
9
10
11
12
11
12
13
14
15
42 unmodified lines
58
59
60
61
62
61
62
63
64
65
43 unmodified lines
109
110
111
112
113
112
113
114
115
116
128 unmodified lines
245
246
247
248
249
248
249
250
251
252
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8a+4nPE9g=
42 unmodified lines
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
43 unmodified lines
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/entireio/auth-go v0.5.0 h1:omda1kFqyxrxxzDHtb9HH2ObgCaDUz8P2HQK+3g1QLg=
github.com/entireio/auth-go v0.5.0/go.mod h1:eqFYgiNSBw6HXYR3j8DRW0/WTV1dX3SWxr2D6YCYNQ0=
github.com/entireio/auth-go v0.5.2 h1:z0deFLJiBQH3ROMo/Z/YE2HcJ6W2SxZO5RVn2feQIPM=
github.com/entireio/auth-go v0.5.2/go.mod h1:eqFYgiNSBw6HXYR3j8DRW0/WTV1dX3SWxr2D6YCYNQ0=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/semgroup v1.2.0 h1:h/OLXwEM+3NNyAdZEpMiH1OzfplU09i2qXPVThGZvyg=
128 unmodified lines
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posthog/posthog-go v1.16.2 h1:zjbAZZELbt8/GNTWyFmZuVNURf8prGdlo1BcJOX9RK4=
github.com/posthog/posthog-go v1.16.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
github.com/posthog/posthog-go v1.17.5 h1:mrLiAdyiQpl8Yeyg23iShyAztJMaUrYzsBjKeH52Aak=
github.com/posthog/posthog-go v1.17.5/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
Mgo.sum+12/-12
198 unmodified lines
199
200
201
202
202
203
204
205
206
207
198 unmodified lines
if serverMsg != "" {
return fmt.Errorf("authentication failed: %s", serverMsg)
}
return errors.New("authentication failed - please run 'entire login'")
// Reaching here means the transport already re-minted and retried once
// (see retryOn401) and the fresh credential was still rejected.
return errors.New("authentication failed even after refreshing credentials - run 'entire login' if this persists")
case http.StatusNotFound:
if serverMsg != "" {
return errors.New(serverMsg)
Minternal/remotehelper/transport/inforefs.go+3/-1
296 unmodified lines
297
298
299
300
301
302
301
302
303
303
304
305
306
307
308
309
310
311
312
313
314
315
305
316
317
318
319
308
309
310
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
11 unmodified lines
380
381
382
383
384
385
386
387
388
389
390
391
340
341
342
343
392
393
394
395
396
397
398
399
400
401
402
403
345
404
405
406
407
408
409
410
411
412
413
414
415
416
348
417
418
350
351
352
419
420
355
356
357
421
422
423
3 unmodified lines
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
296 unmodified lines
// request body — the cold-path probe (no-redirect client), the
// missing-replicas fallback, and the Location salvage all share this
// shape.
//
// On a 401 it retries once with a freshly minted token: see retryOn401.
func (p *Proxy) doGet(ctx context.Context, urlStr string, client *http.Client, setHeaders func(*http.Request)) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
build := func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
if err := p.setAuthOrError(req); err != nil {
return nil, err
}
if setHeaders != nil {
setHeaders(req)
}
return req, nil
}
if err := p.setAuthOrError(req); err != nil {
req, err := build()
if err != nil {
return nil, err
}
if setHeaders != nil {
setHeaders(req)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("doing request: %w", err)
}
resp, err = p.retryOn401(client, resp, build)
if err != nil {
return nil, fmt.Errorf("doing request: %w", err)
}
return resp, nil
}
// retryOn401 resends the request once, with a freshly minted credential,
// when resp is an HTTP 401; otherwise it returns resp untouched.
//
// A 401 means the data plane rejected the credential itself (a signing-key
// rotation or clock skew invalidating a still-mid-TTL persisted token). The
// unauthorizedObserver has already fired creds.Invalidate by the time this
// runs — synchronously, inside the RoundTrip that produced this response — so
// build's setAuthOrError re-resolves the Authorization header from the token
// source, which now re-mints rather than replaying the dead credential. build
// also rewinds any request body, so this is safe for POSTs. We deliberately
// re-run build rather than replaying the original *http.Request: that yields a
// fresh header (not a captured stale one) and a rewound body.
//
// Exactly one retry. If the resend also 401s, the caller renders the error;
// there is no loop. A build error on the resend (e.g. the re-mint failing) is
// surfaced verbatim, so the mint error reaches the user rather than a
// nil-token retry.
func (p *Proxy) retryOn401(client *http.Client, resp *http.Response, build func() (*http.Request, error)) (*http.Response, error) {
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
// Drain (bounded, like httpError and the 5xx failover path) before Close
// so net/http can reuse the connection for the immediate retry instead of
// paying a fresh TCP+TLS handshake.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024)) //nolint:errcheck // best-effort drain for connection reuse
_ = resp.Body.Close()
debuglog.Printf("data plane returned 401; credential invalidated, retrying once with a freshly minted token")
req, err := build()
if err != nil {
return nil, err
}
//nolint:wrapcheck // caller adds context; keeping the raw error preserves failover classification
return client.Do(req)
}
// doWithFailover tries an HTTP request against each node, starting
// from a random offset (or the stickyNode if one is set and still in
// the replica list). Connection errors and 5xx responses trigger
11 unmodified lines
}
}
var lastErr error
// One auth retry per logical operation, not per replica: a 401 doesn't
// trigger failover, so the loop returns the first 401 to the retry — this
// flag stops a second node (or a re-entry) from re-minting again.
authRetried := false
for i := range nodes {
node := nodes[(start+i)%len(nodes)]
reqURL := p.nodeURL(node, makeSuffix)
var bodyReader io.Reader
if body != nil {
if _, err := body.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("resetting request body: %w", err)
// build (re)constructs the request: rewind the body, mint/attach the
// Authorization header, and stamp the caller's headers. Reused by the
// 401 retry so the resend carries a rewound body and a fresh token
// rather than a replayed stale header.
build := func() (*http.Request, error) {
var bodyReader io.Reader
if body != nil {
if _, err := body.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("resetting request body: %w", err)
}
bodyReader = body
}
bodyReader = body
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
if err := p.setAuthOrError(req); err != nil {
return nil, err
}
if setHeaders != nil {
setHeaders(req)
}
return req, nil
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
req, err := build()
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
if err := p.setAuthOrError(req); err != nil {
return nil, err
}
if setHeaders != nil {
setHeaders(req)
}
resp, err := p.client.Do(req)
if err != nil {
3 unmodified lines
continue
}
if resp.StatusCode == http.StatusUnauthorized && !authRetried {
authRetried = true
debuglog.Printf("node %s returned HTTP 401; retrying once with a freshly minted token", node)
// The retry hits the same node it 401'd on. Surface any error
// directly (a re-mint failure or a transient connect on the
// resend) rather than failing over across healthy nodes — a
// blanket failover would churn the replica cache on a transient
// auth-provider outage, and the 401 already told us the node is up.
resp, err = p.retryOn401(p.client, resp, build)
if err != nil {
return nil, err
}
}
if shouldFailover(resp.StatusCode) {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) //nolint:errcheck // best-effort body read for error message
_ = resp.Body.Close()
Minternal/remotehelper/transport/proxy.go+96/-19
9 unmodified lines
10
11
12
13
14
15
16
1442 unmodified lines
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
9 unmodified lines
"net/url"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
1442 unmodified lines
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
// fakeTokenSource models the jurisdiction token source the remote helper
// wires into the proxy: Token memoizes a minted token, Invalidate drops the
// memo so the next Token re-mints a distinct one. mintErrs[n], when non-nil,
// makes the (n+1)th mint fail — used to exercise re-mint failure on a retry.
type fakeTokenSource struct {
mu sync.Mutex
token string
mints int
mintErrs []error
}
func (s *fakeTokenSource) Token() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.token != "" {
return s.token, nil
}
if s.mints < len(s.mintErrs) && s.mintErrs[s.mints] != nil {
err := s.mintErrs[s.mints]
s.mints++
return "", err
}
s.mints++
s.token = fmt.Sprintf("token-%d", s.mints)
return s.token, nil
}
func (s *fakeTokenSource) Invalidate() {
s.mu.Lock()
defer s.mu.Unlock()
s.token = ""
}
// authRetryProxy builds a single-node Proxy wired the way git-remote-entire's
// main wires production: SetAuth stamps a Bearer from src, and OnUnauthorized
// invalidates src so the next mint is fresh.
func authRetryProxy(t *testing.T, serverURL string, src *fakeTokenSource) *Proxy {
t.Helper()
return New(Config{
Nodes: replicas.NodeConfig{
InitialNodes: []string{serverURL},
ClusterHost: mustHost(t, serverURL),
},
Path: "/et/alice/repo",
SetAuth: func(req *http.Request) error {
tok, err := src.Token()
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
return nil
},
OnUnauthorized: src.Invalidate,
})
}
// TestServiceRPCRetriesOnceOn401: a POST that 401s once then succeeds must be
// retried in-process — exactly two attempts, the body replayed verbatim on the
// retry, and the retry carrying a freshly minted Authorization header.
func TestServiceRPCRetriesOnceOn401(t *testing.T) {
t.Parallel()
const reqBody = "0032want 1234567890123456789012345678901234567890\n0000"
type attempt struct{ auth, body string }
var mu sync.Mutex
var attempts []attempt
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body) //nolint:errcheck // test
mu.Lock()
n := len(attempts)
attempts = append(attempts, attempt{auth: r.Header.Get("Authorization"), body: string(b)})
mu.Unlock()
if n == 0 {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "pack-data")
}))
defer server.Close()
src := &fakeTokenSource{}
p := authRetryProxy(t, server.URL, src)
body, err := p.ServiceRPC(context.Background(), "git-upload-pack", strings.NewReader(reqBody))
require.NoError(t, err)
defer body.Close()
got, _ := io.ReadAll(body) //nolint:errcheck // test
assert.Equal(t, "pack-data", string(got))
mu.Lock()
defer mu.Unlock()
require.Len(t, attempts, 2, "expected exactly one in-process retry (two attempts total)")
assert.Equal(t, reqBody, attempts[0].body, "first attempt body")
assert.Equal(t, reqBody, attempts[1].body, "POST body must be fully replayed on the retry")
assert.Equal(t, "Bearer token-1", attempts[0].auth)
assert.Equal(t, "Bearer token-2", attempts[1].auth, "retry must carry a freshly minted token, not the replayed stale header")
assert.NotEqual(t, attempts[0].auth, attempts[1].auth)
}
// TestServiceRPCRetriesOnceThenFailsOnPersistent401: when the fresh token is
// also rejected, we stop after exactly one retry and surface the refreshed
// message — no retry loop.
func TestServiceRPCRetriesOnceThenFailsOnPersistent401(t *testing.T) {
t.Parallel()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
src := &fakeTokenSource{}
p := authRetryProxy(t, server.URL, src)
_, err := p.ServiceRPC(context.Background(), "git-upload-pack", strings.NewReader("body"))
require.Error(t, err)
assert.Contains(t, err.Error(), "authentication failed even after refreshing credentials")
assert.Equal(t, int32(2), calls.Load(), "exactly two attempts: original + one retry")
}
// TestServiceRPCRetryReMintFailureSurfaces: if re-minting the token for the
// retry fails, the mint error must reach the caller — not a nil-token retry —
// and we must not have dialled the server a second time.
func TestServiceRPCRetryReMintFailureSurfaces(t *testing.T) {
t.Parallel()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
// First mint succeeds (token-1, gets the 401); the retry's re-mint fails.
src := &fakeTokenSource{mintErrs: []error{nil, errors.New("core unreachable: exchange failed")}}
p := authRetryProxy(t, server.URL, src)
_, err := p.ServiceRPC(context.Background(), "git-upload-pack", strings.NewReader("body"))
require.Error(t, err)
assert.Contains(t, err.Error(), "core unreachable: exchange failed")
assert.Equal(t, int32(1), calls.Load(), "retry must abort at re-mint, before a second dial")
}
// TestInfoRefsRetriesOnceOn401: the GET info/refs path self-heals on a 401 the
// same way, re-minting and retrying once.
func TestInfoRefsRetriesOnceOn401(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var auths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
n := len(auths)
auths = append(auths, r.Header.Get("Authorization"))
mu.Unlock()
if n == 0 {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "refs")
}))
defer server.Close()
src := &fakeTokenSource{}
p := authRetryProxy(t, server.URL, src)
body, err := p.InfoRefs(context.Background(), "git-upload-pack")
require.NoError(t, err)
defer body.Close()
got, _ := io.ReadAll(body) //nolint:errcheck // test
assert.Equal(t, "refs", string(got))
mu.Lock()
defer mu.Unlock()
require.Len(t, auths, 2, "expected exactly one in-process retry")
assert.Equal(t, "Bearer token-1", auths[0])
assert.Equal(t, "Bearer token-2", auths[1], "retry must carry a freshly minted token")
}
Minternal/remotehelper/transport/proxy_test.go+181