Merge remote-tracking branch 'origin/main' into move-json-flag-to-specific-commands · Entire
Log in
Merge remote-tracking branch 'origin/main' into move-json-flag-to-specific-commands
2e7f720→main·
gtrrz-victor·1w ago·15 files·+353 added/-241 removed
# Conflicts: # cmd/entire/cli/repo_mirror_test.go
Changes
15
MCHANGELOG.md+21
cmd/entire/cli
Mrepo_mirror.go+72/-48
Mrepo_mirror_test.go+40/-30
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
Msetup.go+55/-41
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
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
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
MCHANGELOG.md+21
17 unmodified lines
18
19
20
21
22
23
24
25
26
27
28
29
30
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
37
38
39
40
41
56
57
58
59
60
61
62
63
64
10 unmodified lines
75
76
77
58
59
60
61
62
63
64
65
78
79
80
81
82
83
84
85
86
87
88
89
90
91
72
73
74
92
93
94
95
96
77
97
98
99
100
101
102
103
104
105
106
107
84
108
109
110
111
6 unmodified lines
118
119
120
97
121
122
123
124
29 unmodified lines
154
155
156
133
157
158
159
160
10 unmodified lines
171
172
173
150
174
175
152
153
154
155
156
157
176
177
178
179
180
181
182
183
184
1 unmodified line
186
187
188
165
189
190
191
192
7 unmodified lines
200
201
202
179
203
204
205
206
298 unmodified lines
505
506
507
484
508
509
510
511
13 unmodified lines
525
526
527
504
528
529
530
531
9 unmodified lines
541
542
543
520
544
545
546
547
548
549
550
527
551
552
553
554
30 unmodified lines
585
586
587
564
588
589
590
591
4 unmodified lines
596
597
598
575
576
599
600
601
602
603
30 unmodified lines
634
635
636
613
637
638
639
640
641
642
643
620
644
645
646
647
17 unmodified lines
"github.com/entireio/cli/internal/coreapi"
)
// Column header names, the single source of truth for both the table headers
// (mirrorColumns/availableMirrorColumns) and the --sort key switches. parseSort-
// Column returns the canonical header it matched, so the sort switches compare
// against these constants directly.
const (
colRepo = "REPO"
colCloneURL = "CLONE URL"
colPrivate = "PRIVATE"
colAccess = "ACCESS"
colStatus = "STATUS"
// 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
// owner/repo name, the clone URL you'd copy, and whether the upstream is
// private. Owner, provider, and cluster aren't columns of their own — they're
// inferable from the owner/repo pair and the clone URL
// (entire://<cluster>/gh/<owner>/<repo>). `--repo` filters on the repo name
// only; owner/provider/cluster stay server-side filters, and the wire model's
// internal ids are dropped. The clone URL is synthesised from the mirror's
// coords (the form `git clone` accepts), since the list API doesn't return it.
var mirrorColumns = []string{colRepo, colCloneURL, colPrivate}
// (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.
10 unmodified lines
return []string{repo, cloneURL, mirrorPrivate(m)}
}
// parseSortColumn resolves a --sort spec to the canonical column header it
// names (one of the columns entries) and a direction. It trims first, then
// reads the '-' prefix, so leading/trailing whitespace is handled identically
// on every path (the direction and the column name never disagree). An empty
// spec selects the first column. An unknown name errors naming the valid
// columns. Returning the matched header lets callers switch on the col*
// constants directly.
func parseSortColumn(spec string, columns []string) (col string, desc bool, err error) {
// 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
}
for _, h := range columns {
if strings.EqualFold(h, name) {
return h, desc, nil
for _, c := range columns {
if strings.EqualFold(c.key, name) {
return c, desc, nil
}
}
return "", false, fmt.Errorf("unknown sort column %q; valid columns: %s", name, strings.ToLower(strings.Join(columns, ", ")))
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. `repo`/default sorts by the
// 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)
6 unmodified lines
return strings.ToLower(mirrorCloneURL(m.ClusterHost, m.Owner, m.Repo))
case colPrivate:
return mirrorPrivate(m)
default: // repo -> tiebreak alone
default: // name -> tiebreak alone
return ""
}
}
29 unmodified lines
return strings.ToLower(string(m.Access))
case colStatus:
return strings.ToLower(string(m.Status))
default: // repo -> tiebreak alone
default: // name -> tiebreak alone
return ""
}
}
10 unmodified lines
return nil
}
// filterByRepo keeps items whose repo identifier contains substr (case-
// 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 repo name, so `repo mirror list --repo` narrows that
// last dimension client-side. repoOf returns the item's displayed identifier —
// the callers pass the owner/repo form shown in the REPO column, so a value
// copied from the table (e.g. acme/web) matches the row it came from. An empty
// substr returns items unchanged.
func filterByRepo[T any](items []T, repoOf func(T) string, substr string) []T {
// 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
1 unmodified line
substr = strings.ToLower(substr)
out := make([]T, 0, len(items))
for _, it := range items {
if strings.Contains(strings.ToLower(repoOf(it)), substr) {
if strings.Contains(strings.ToLower(nameOf(it)), substr) {
out = append(out, it)
}
}
7 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{colRepo, colAccess, colStatus}
var availableMirrorColumns = []column{colName, colAccess, colStatus}
func availableMirrorRow(m coreapi.AvailableMirror) []string {
return []string{m.Owner + "/" + m.Repo, string(m.Access), string(m.Status)}
298 unmodified lines
}
func newRepoMirrorListCmd() *cobra.Command {
var cluster, provider, owner, repo string
var cluster, provider, owner, name string
var sortSpec string
var showAvailable bool
cmd := &cobra.Command{
13 unmodified lines
},
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
}
avail := filterByRepo(out.Available, func(m coreapi.AvailableMirror) string { return m.Owner + "/" + m.Repo }, repo)
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
30 unmodified lines
if err != nil {
return nil, err
}
mirrors = filterByRepo(mirrors, func(m coreapi.Mirror) string { return m.Owner + "/" + m.Repo }, repo)
mirrors = filterByName(mirrors, func(m coreapi.Mirror) string { return m.Owner + "/" + m.Repo }, name)
if err := sortMirrors(mirrors, sortSpec); err != nil {
return nil, err
}
4 unmodified lines
cmd.Flags().StringVar(&cluster, "cluster", "", "Filter by cluster public host")
cmd.Flags().StringVar(&provider, "provider", "", "Filter by upstream provider (e.g. github)")
cmd.Flags().StringVar(&owner, "owner", "", "Filter by upstream owner login")
cmd.Flags().StringVar(&repo, "repo", "", "Filter by owner/repo substring, matching the REPO column (case-insensitive)")
cmd.Flags().StringVar(&sortSpec, "sort", "", "Sort by column (header name; prefix '-' for descending). Default: repo name ascending")
cmd.Flags().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
30 unmodified lines
// federation other than the active login instead of failing with
// "no mirror matching".
if looksLikeULID(ref) {
return runCoreObject(cmd, mirrorColumns, mirrorRow, show)
return runCoreObject(cmd, columnHeaders(mirrorColumns), mirrorRow, show)
}
clusterHost, _, _, _, err := parseMirrorCloneURL(ref)
if err != nil {
cmd.SilenceUsage = true
return badMirrorRefErr(err)
}
return runCoreObjectForCluster(cmd, clusterHost, mirrorColumns, mirrorRow, show)
return runCoreObjectForCluster(cmd, clusterHost, columnHeaders(mirrorColumns), mirrorRow, show)
},
}
addJSONFlag(cmd)
Mcmd/entire/cli/repo_mirror.go+72/-48
379 unmodified lines
380
381
382
383
383
384
385
386
126 unmodified lines
513
514
515
516
516
517
518
519
6 unmodified lines
526
527
528
529
529
530
531
531
532
533
534
535
536
537
538
537
538
539
540
541
541
542
543
544
6 unmodified lines
551
552
553
554
554
555
556
556
557
558
559
560
560
561
562
563
562
564
565
566
567
568
569
570
571
572
573
574
575
576
567
577
578
579
580
20 unmodified lines
601
602
603
594
604
605
596
606
607
598
608
609
610
611
1 unmodified line
613
614
615
606
616
617
618
619
1 unmodified line
621
622
623
614
624
625
616
626
627
628
629
630
631
632
623
624
633
634
635
636
637
1 unmodified line
639
640
641
632
633
642
643
644
645
646
647
638
648
649
650
651
14 unmodified lines
666
667
668
659
669
670
671
672
581 unmodified lines
1254
1255
1256
1247
1257
1258
1259
1250
1260
1261
1262
1263
1 unmodified line
1265
1266
1267
1258
1268
1269
1270
1271
12 unmodified lines
1284
1285
1286
1277
1287
1288
1289
1290
6 unmodified lines
1297
1298
1299
1290
1300
1301
1302
1303
26 unmodified lines
1330
1331
1332
1323
1333
1334
1335
1336
379 unmodified lines
// 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 --repo/--sort
// 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()
126 unmodified lines
}
}
// TestRepoMirrorList_FilterSort pins the client-side --repo filter and --sort
// 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.
6 unmodified lines
{Owner: "other", Repo: "api", ClusterHost: "eu-west-1.entire.io"},
}
t.Run("--repo narrows the table by repo-name substring", func(t *testing.T) {
t.Run("--name narrows the table by owner/repo substring", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--repo", "cli")
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("--repo matches the owner/repo form shown in the REPO column", func(t *testing.T) {
// A value copied straight from the displayed REPO column must match the
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, "--repo", "acme/web")
stdout, _ := runMirrorList(t, "--name", "acme/web")
require.Contains(t, stdout, "acme/web")
require.NotContains(t, stdout, "acme/cli")
require.NotContains(t, stdout, "other/api")
6 unmodified lines
requireOrder(t, stdout, "acme/cli", "acme/web", "other/api")
})
t.Run("--sort -repo reverses the order", func(t *testing.T) {
t.Run("--sort -name reverses the order", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--sort", "-repo")
stdout, _ := runMirrorList(t, "--sort", "-name")
requireOrder(t, stdout, "other/api", "acme/web", "acme/cli")
})
t.Run("--repo applies to --json and keeps [] not null", func(t *testing.T) {
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, "--repo", "cli", "--json")
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, "--repo", "zzz", "--json")
stdout, _ = runMirrorList(t, "--name", "zzz", "--json")
require.Contains(t, stdout, "[]")
require.NotContains(t, stdout, "null")
})
20 unmodified lines
)
})
t.Run("explicit --sort repo keeps the cluster tiebreak (matches default)", func(t *testing.T) {
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 repo` must order like the default: owner/repo ascending, and
// `--sort name` must order like the default: owner/repo ascending, and
// within the duplicate tie, cluster ascending (aws before eu). Guards
// against `--sort repo` regressing to a plain single-key sort that would
// 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"},
1 unmodified line
{Owner: "acme", Repo: "api", ClusterHost: "aws-us-east-2.entire.io"},
}
serveMirrorList(t, dupes, nil)
stdout, _ := runMirrorList(t, "--sort", "repo")
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",
1 unmodified line
"entire://eu-west-1.entire.io/gh/acme/web",
)
// -repo reverses the whole ordering, tiebreak included.
// -name reverses the whole ordering, tiebreak included.
serveMirrorList(t, dupes, nil)
stdout, _ = runMirrorList(t, "--sort", "-repo")
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("--repo/--sort apply under --show-available", func(t *testing.T) {
// --repo cli keeps two rows (so --sort is observable) and drops the
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{
1 unmodified line
{Owner: "acme", Repo: "cli-web", Access: "read", Status: "available"},
{Owner: "other", Repo: "srv", Access: "read", Status: "available"},
})
stdout, _ := runMirrorList(t, "--show-available", "--repo", "cli", "--sort", "access")
require.NotContains(t, stdout, "other/srv", "--repo cli must drop the non-matching row")
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-repo column sort: all rows share the same private value, so the
// 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{
14 unmodified lines
t.Run("--sort with leading whitespace parses direction like the trimmed spec", func(t *testing.T) {
serveMirrorList(t, mirrors, nil)
stdout, _ := runMirrorList(t, "--sort", " -repo")
stdout, _ := runMirrorList(t, "--sort", " -name")
requireOrder(t, stdout, "other/api", "acme/web", "acme/cli")
})
}
581 unmodified lines
}, mirrorRepoHosts(m))
})
t.Run("-repo reverses the whole ordering, tiebreak included", func(t *testing.T) {
t.Run("-name reverses the whole ordering, tiebreak included", func(t *testing.T) {
t.Parallel()
m := base()
require.NoError(t, sortMirrors(m, "-repo"))
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",
1 unmodified line
}, mirrorRepoHosts(m))
})
t.Run("non-repo column sorts keep the owner/repo+cluster tiebreak", func(t *testing.T) {
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
12 unmodified lines
t.Run("whitespace spec parses direction from the trimmed spec", func(t *testing.T) {
t.Parallel()
m := base()
require.NoError(t, sortMirrors(m, " -repo"))
require.NoError(t, sortMirrors(m, " -name"))
require.Equal(t, []string{
"acme/web@eu-west-1.entire.io",
"acme/web@aws-us-east-2.entire.io",
6 unmodified lines
err := sortMirrors(base(), "nope")
require.Error(t, err)
require.Contains(t, err.Error(), "unknown sort column")
require.Contains(t, err.Error(), "repo")
require.Contains(t, err.Error(), "name")
})
}
26 unmodified lines
t.Run("whitespace spec parses direction from the trimmed spec", func(t *testing.T) {
t.Parallel()
a := base()
require.NoError(t, sortAvailable(a, " -repo"))
require.NoError(t, sortAvailable(a, " -name"))
require.Equal(t, []string{"acme/web", "acme/cli", "acme/api"}, repos(a))
})
Mcmd/entire/cli/repo_mirror_test.go+40/-30
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
1457 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
1465
1466
1467
1491
1492
1493
1494
1495
1496
1470
1497
1498
1499
1500
1473
1474
1501
1502
1503
1504
1505
1506
6 unmodified lines
1513
1514
1515
1487
1488
1489
1490
1491
1492
1493
1516
1517
1518
1519
1520
1521
1522
1523
1524
59 unmodified lines
1584
1585
1586
1559
1560
1561
1562
1563
1564
1587
1588
1589
1590
1591
1592
1593
1594
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1457 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 {
Mcmd/entire/cli/setup.go+55/-41
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