Merge branch 'main' into fix/1716-redact-supabase-secrets · Entire
Log in
Merge branch 'main' into fix/1716-redact-supabase-secrets
c010830→main·
suhaanthayyil·4d ago·48 files·+3,003 added/-576 removed
Changes
48
.github/workflows
Me2e.yml+1/-1
Mrelease.yml+1/-1
M.goreleaser.yaml+8/-1
cmd/entire/cli
agent
claudecode
Mdiscovery.go+24/-260
Mreviewer.go+51/-4
Mreviewer_test.go+100/-3
testdata
Astream_with_deltas.jsonl+7
codex
MAGENT.md+9/-1
Mdiscovery.go+43/-6
Mdiscovery_test.go+116/-7
Areview_tokens.go+195
Areview_tokens_test.go+423
Mreviewer.go+119/-27
Mreviewer_test.go+106/-3
cursor
Mhooks.go+43/-36
Mhooks_test.go+90
skilldiscovery
Mregistry.go+19/-6
Mregistry_test.go+17/-2
Ascan.go+257
auth
Mcell_data_api.go+71/-22
Mcell_data_api_test.go+79/-37
checkpoint
Mpersistent.go+1/-1
Mpersistent_signing_test.go+1/-1
remote
Mgit.go+68/-1
Mgit_test.go+133
Mutil.go+40/-17
Mutil_test.go+59/-17
integration_test
Mtestenv.go+12/-5
Mplugin.go+15/-1
Mplugin_test.go+60
review
Mcmd.go+17/-3
Mcmd_test.go+128/-5
Msetup.go+4/-1
Msetup_github.go+122/-43
Msetup_github_test.go+222/-34
strategy
Mcheckpoint_remote_test.go+82
Mhooks.go+24/-2
Mhooks_test.go+53
docs
architecture
Mattribution.md+33
testing
Mgit-remote-test-plan.md+2/-2
Mgo.mod+6/-6
Mgo.sum+12/-12
internal/remotehelper/githelper
Minvariants_test.go+61/-1
Mlist.go+2/-2
Mlist_test.go+1/-1
Mpush.go+6/-2
Arefadv_cache.go+55
Mrun.go+5/-2
207 unmodified lines
208
209
210
211
211
212
213
214
207 unmodified lines
} >> "$GITHUB_OUTPUT"
- name: Notify Slack of E2E failure
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.E2E_SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
M.github/workflows/e2e.yml+1/-1
194 unmodified lines
195
196
197
198
198
199
200
201
194 unmodified lines
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
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.E2E_SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
M.github/workflows/release.yml+1/-1
99 unmodified lines
100
101
102
103
103
104
105
106
107
108
109
110
111
112
113
99 unmodified lines
prerelease: auto
scoops:
- repository:
# Name the manifest (and therefore the Scoop app directory) "entire". Without
# this, goreleaser defaults the manifest name to the project name, which
# resolves to the repo name ("cli"), so `scoop install` lands the binary in
# …\scoop\apps\cli\current\entire.exe. That mismatched app-dir name is
# surprising ("scoop install cli"?) and fed the Windows hook-path bug in
# https://github.com/entireio/cli/issues/1424.
- name: entire
repository:
owner: entireio
name: scoop-bucket
token: "{{ .Env.TAP_GITHUB_TOKEN }}"
M.goreleaser.yaml+8/-1
1 unmodified line
2
3
4
5
5
6
7
9
10
11
12
8
9
10
5 unmodified lines
16
17
18
24
25
26
27
19
20
21
22
23
24
29
30
31
32
33
25
26
27
28
29
30
31
32
33
3 unmodified lines
37
38
39
40
41
44
45
46
47
48
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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
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
1 unmodified line
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"golang.org/x/mod/semver"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/skilldiscovery"
5 unmodified lines
// (nil, nil) when HOME is unreadable or directories are missing — discovery
// is best-effort.
//
// Claude Code exposes three kinds of invocable content per plugin:
// - skills: <plugin>/skills/<name>/SKILL.md (YAML frontmatter with name + description)
// - commands: <plugin>/commands/<name>.md (YAML frontmatter with description; name = filename)
// - agents: <plugin>/agents/<name>.md (YAML frontmatter with description; name = filename)
// Claude Code exposes three kinds of invocable content per plugin, all invoked
// via the same slash-prefix syntax (`/name`, `/plugin:name`):
// - skills: <plugin>/skills/<name>/SKILL.md (frontmatter: name + description)
// - commands: <plugin>/commands/<name>.md (frontmatter: description; name = filename)
// - agents: <plugin>/agents/<name>.md (frontmatter: description; name = filename)
//
// All three are walked because users invoke them via the same slash-prefix
// syntax (`/plugin:name`) and any of them can be a review tool. The
// pr-review-toolkit plugin, for example, ships its review skills as
// commands/agents (not skills/), and was silently missed by a skills-only
// walker.
// All three are walked because any can be a review tool — the pr-review-toolkit
// plugin, for example, ships its review skills as commands/agents (not skills/).
//
// The generic SKILL.md / markdown scanning, version dedupe, and frontmatter
// parsing live in the shared skilldiscovery package; this method supplies the
// Claude-specific roots and slash invocation form.
//
//nolint:unparam // error return is part of SkillDiscoverer contract; future implementations may report hard failures
func (c *ClaudeCodeAgent) DiscoverReviewSkills(ctx context.Context) ([]agent.DiscoveredSkill, error) {
3 unmodified lines
return nil, nil
}
form := skilldiscovery.SlashForm
var found []agent.DiscoveredSkill
found = append(found, scanPluginCache(ctx, filepath.Join(home, ".claude", "plugins", "cache"))...)
found = append(found, scanUserSkills(ctx, filepath.Join(home, ".claude", "skills"))...)
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(home, ".claude", "commands"), "")...)
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(home, ".claude", "agents"), "")...)
found = dedupeByInvocation(found)
found = append(found, skilldiscovery.ScanPluginCache(ctx, filepath.Join(home, ".claude", "plugins", "cache"),
func(versionRoot, pluginName string) []agent.DiscoveredSkill {
var out []agent.DiscoveredSkill
out = append(out, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(versionRoot, "skills"), pluginName, form)...)
out = append(out, skilldiscovery.ScanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "commands"), pluginName, form)...)
out = append(out, skilldiscovery.ScanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "agents"), pluginName, form)...)
return out
})...)
found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(home, ".claude", "skills"), "", form)...)
found = append(found, skilldiscovery.ScanFlatMarkdownDir(ctx, filepath.Join(home, ".claude", "commands"), "", form)...)
found = append(found, skilldiscovery.ScanFlatMarkdownDir(ctx, filepath.Join(home, ".claude", "agents"), "", form)...)
found = skilldiscovery.DedupeByInvocation(found)
if len(found) == 0 {
return nil, nil
}
return found, nil
}
// dedupeByInvocation collapses entries sharing an invocation name. Plugins
// can ship a skill and a same-named command wrapper that forwards to it;
// scan order keeps the skill over its wrapper.
func dedupeByInvocation(in []agent.DiscoveredSkill) []agent.DiscoveredSkill {
if len(in) < 2 {
return in
}
seen := make(map[string]struct{}, len(in))
out := make([]agent.DiscoveredSkill, 0, len(in))
for _, s := range in {
if _, dup := seen[s.Name]; dup {
continue
}
seen[s.Name] = struct{}{}
out = append(out, s)
}
return out
}
// scanPluginCache walks <root>/<marketplace>/<plugin>/<version>/{skills,commands,agents}/
// One plugin can contribute through any or all three directories.
//
// Multiple version directories per plugin are common after upgrades. Walking
// every version produces duplicate skills (same invocation name, same
// description) — confusing in the picker and wasteful in the prompt. We pick
// a single version per plugin via pickLatestVersion: prefer valid semver
// (highest), fall back to lexicographic max.
func scanPluginCache(ctx context.Context, root string) []agent.DiscoveredSkill {
entries, err := os.ReadDir(root)
if err != nil {
logging.Debug(ctx, "claude-code discovery: plugin cache unreadable",
slog.String("root", root), slog.String("error", err.Error()))
return nil
}
var found []agent.DiscoveredSkill
for _, marketEntry := range entries {
if !marketEntry.IsDir() {
continue
}
marketRoot := filepath.Join(root, marketEntry.Name())
pluginEntries, err := os.ReadDir(marketRoot)
if err != nil {
continue
}
for _, pluginEntry := range pluginEntries {
if !pluginEntry.IsDir() {
continue
}
pluginName := pluginEntry.Name()
pluginRoot := filepath.Join(marketRoot, pluginName)
versionEntries, err := os.ReadDir(pluginRoot)
if err != nil {
continue
}
versionDir, ok := pickLatestVersion(versionEntries)
if !ok {
continue
}
versionRoot := filepath.Join(pluginRoot, versionDir)
found = append(found, readSkillsDir(ctx, filepath.Join(versionRoot, "skills"), pluginName)...)
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "commands"), pluginName)...)
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "agents"), pluginName)...)
}
}
return found
}
// pickLatestVersion returns the name of the "newest" version directory among
// entries. Strategy:
//
// - If any entry name parses as semver (with or without a leading "v"), pick
// the highest semver among those that parse. Non-semver entries are
// ignored when at least one semver entry exists.
// - Otherwise, fall back to the lexicographic max of all directory names.
// This handles the "unknown" sentinel some plugins ship and one-off names.
//
// Returns ("", false) if no usable directory entry exists.
func pickLatestVersion(entries []os.DirEntry) (string, bool) {
var dirs []string
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, e.Name())
}
}
if len(dirs) == 0 {
return "", false
}
var semverDirs []string
for _, d := range dirs {
if semver.IsValid(semverWithV(d)) {
semverDirs = append(semverDirs, d)
}
}
if len(semverDirs) > 0 {
sort.Slice(semverDirs, func(i, j int) bool {
return semver.Compare(semverWithV(semverDirs[i]), semverWithV(semverDirs[j])) > 0
})
return semverDirs[0], true
}
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
return dirs[0], true
}
// semverWithV ensures a version string has the "v" prefix that
// golang.org/x/mod/semver requires. Plugin version dirs are usually bare
// (e.g. "0.1.0"), but we tolerate either form.
func semverWithV(s string) string {
if strings.HasPrefix(s, "v") {
return s
}
return "v" + s
}
// scanUserSkills walks ~/.claude/skills/<skill>/SKILL.md.
func scanUserSkills(ctx context.Context, root string) []agent.DiscoveredSkill {
return readSkillsDir(ctx, root, "" /* no plugin prefix */)
}
// readSkillsDir reads each skill subdirectory's SKILL.md, parses frontmatter,
// and emits a DiscoveredSkill if Matches() returns true.
func readSkillsDir(ctx context.Context, dir, pluginName string) []agent.DiscoveredSkill {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var found []agent.DiscoveredSkill
for _, skillEntry := range entries {
if !skillEntry.IsDir() {
continue
}
skillDir := filepath.Join(dir, skillEntry.Name())
skillFile := filepath.Join(skillDir, "SKILL.md")
data, err := os.ReadFile(skillFile) //nolint:gosec // G304: skillFile is constructed from a ReadDir walk under HOME, not user input
if err != nil {
continue
}
name, description, parseErr := parseSkillFrontmatter(data)
if parseErr != nil {
logging.Debug(ctx, "claude-code discovery: skipping malformed SKILL.md",
slog.String("path", skillFile), slog.String("error", parseErr.Error()))
continue
}
if name == "" {
name = skillEntry.Name()
}
invocation := invocationName(name, pluginName)
if !skilldiscovery.Matches(invocation, description) {
continue
}
found = append(found, agent.DiscoveredSkill{
Name: invocation,
Description: description,
SourcePath: skillFile,
})
}
return found
}
// scanFlatMarkdownDir reads *.md files directly under dir (no nesting), parses
// their YAML frontmatter for `description:`, and derives the invocation name
// from the filename (stripping the .md suffix). Used for both plugin
// commands/agents and user-level ~/.claude/commands and ~/.claude/agents.
//
// Frontmatter shape differs from SKILL.md — no `name:` field, so the
// filename is the source of truth for the invocation name.
func scanFlatMarkdownDir(ctx context.Context, dir, pluginName string) []agent.DiscoveredSkill {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var found []agent.DiscoveredSkill
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
baseName := strings.TrimSuffix(entry.Name(), ".md")
if strings.EqualFold(baseName, "README") {
continue
}
filePath := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(filePath) //nolint:gosec // G304: filePath is constructed from a ReadDir walk under HOME, not user input
if err != nil {
continue
}
_, description, parseErr := parseSkillFrontmatter(data)
if parseErr != nil {
logging.Debug(ctx, "claude-code discovery: skipping malformed command/agent",
slog.String("path", filePath), slog.String("error", parseErr.Error()))
continue
}
invocation := invocationName(baseName, pluginName)
if !skilldiscovery.Matches(invocation, description) {
continue
}
found = append(found, agent.DiscoveredSkill{
Name: invocation,
Description: description,
SourcePath: filePath,
})
}
return found
}
// invocationName builds the slash-prefixed invocation form. Plugin-prefixed
// names use "/plugin:name"; bare names use "/name".
func invocationName(name, pluginName string) string {
if pluginName == "" {
return "/" + name
}
return "/" + pluginName + ":" + name
}
// parseSkillFrontmatter extracts `name:` and `description:` from a minimal
// YAML frontmatter block. Purpose-built for the tiny subset of YAML these
// SKILL.md / command / agent files actually use.
//
// Trims surrounding double-quotes from values so `description: "foo bar"`
// is returned as `foo bar` — the command/agent frontmatter quotes values;
// SKILL.md files usually don't.
func parseSkillFrontmatter(data []byte) (name, description string, err error) {
s := string(data)
if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") {
return "", "", errors.New("no frontmatter delimiter")
}
body := strings.TrimPrefix(strings.TrimPrefix(s, "---\r\n"), "---\n")
end := strings.Index(body, "\n---")
if end < 0 {
return "", "", errors.New("no closing frontmatter delimiter")
}
for _, line := range strings.Split(body[:end], "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "name:"):
name = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "name:")), `"`)
case strings.HasPrefix(line, "description:"):
description = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "description:")), `"`)
}
}
return name, description, nil
}
Mcmd/entire/cli/agent/claudecode/discovery.go+24/-260
54 unmodified lines
55
56
57
58
59
60
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
22 unmodified lines
99
100
101
102
103
104
105
106
20 unmodified lines
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
6 unmodified lines
155
156
157
158
159
160
161
162
128
163
164
165
166
167
168
14 unmodified lines
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
54 unmodified lines
// Emits Started first, Finished{Success:...} last (success follows result.is_error).
// On a scanner error (torn stream), emits RunError then Finished{Success:false}.
//
// Tokens are emitted only at the terminal `result` envelope, not
// incrementally — claude's per-assistant `usage` fields aren't cumulative
// and summing them across messages would double-count.
// Live-token semantics: Claude's assistant envelopes carry a usage snapshot
// taken at the START of each API call — input_tokens/cache_* are populated
// but output_tokens is essentially zero (a 1–8 token "initial decision"
// count that does not update as text streams). The true output is only
// surfaced on `result` (aggregate across all calls in the run) or on the
// late `message_delta` event of --include-partial-messages mode.
//
// The Tokens contract (types/reviewer.go) is cumulative running totals, so
// the parser accumulates the input sum across unique message ids (the same
// usage block repeats verbatim on every content-block envelope of one API
// call — summing per envelope would multi-count) and emits
// `Tokens{In: <running sum>, Out: 0}` once per new message id. The running
// sum converges to the `result` aggregate, which is emitted last with the
// true {In, Out}. Out stays 0 mid-run because consumers render every Tokens
// event the same way — surfacing the 1–8 token stub would display a
// misleading real-looking output count.
//
// Package-private; called directly from this package's tests so they can
// drive raw stdout fixtures through the parser without going through the
22 unmodified lines
var sawResult bool
var resultErr bool
var resultUsage messageUsage
seenMsgIDs := map[string]struct{}{}
var cumInputTokens int
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
20 unmodified lines
out <- reviewtypes.ToolCall{Name: block.Name, Args: string(block.Input)}
}
}
// Accumulate input once per unique message id: every
// content-block envelope of one API call repeats the same
// usage snapshot, and its output_tokens is a 1–8 token stub
// (see the parser doc). Emitting the running sum keeps
// mid-run values on the cumulative Tokens contract; the
// true {In, Out} tally comes from `result` below.
in := env.Message.Usage.InputTokens +
env.Message.Usage.CacheReadInputTokens +
env.Message.Usage.CacheCreationInputTokens
if in > 0 && env.Message.ID != "" {
if _, seen := seenMsgIDs[env.Message.ID]; !seen {
seenMsgIDs[env.Message.ID] = struct{}{}
cumInputTokens += in
out <- reviewtypes.Tokens{In: cumInputTokens, Out: 0}
}
}
case "result":
sawResult = true
resultErr = env.IsError
6 unmodified lines
return
}
if sawResult {
// Gate on non-zero usage: a result envelope without a usage
// block would emit Tokens{0,0}, which only ever ERASES the
// mid-run cumulative total under the consumers'
// overwrite-not-sum semantics (mirrors the codex guard).
in := resultUsage.InputTokens + resultUsage.CacheReadInputTokens + resultUsage.CacheCreationInputTokens
out <- reviewtypes.Tokens{In: in, Out: resultUsage.OutputTokens}
if in > 0 || resultUsage.OutputTokens > 0 {
out <- reviewtypes.Tokens{In: in, Out: resultUsage.OutputTokens}
}
out <- reviewtypes.Finished{Success: !resultErr}
return
}
14 unmodified lines
}
type claudeMessage struct {
// ID is the API message id — identical across the multiple
// content-block envelopes of one API call; the parser dedupes usage
// accumulation on it.
ID string `json:"id"`
Content []claudeBlock `json:"content"`
// Usage on assistant envelopes is the per-call-START snapshot — input
// counts are populated but output_tokens reflects only the model's
// initial decision, not the streamed text. Final aggregate usage
// arrives on the `result` envelope. Reuses messageUsage (declared in
// types.go) to stay aligned with the transcript-parser usage shape.
Usage messageUsage `json:"usage"`
}
type claudeBlock struct {
Mcmd/entire/cli/agent/claudecode/reviewer.go+51/-4
238 unmodified lines
239
240
241
242
243
244
245
246
247
248
249
2 unmodified lines
252
253
254
250
251
255
256
257
258
254
259
260
261
262
127 unmodified lines
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
238 unmodified lines
t.Error("expected AssistantText carrying fixture prose 'Cats are…'")
}
// The parser emits Tokens on every assistant envelope that carries
// non-zero usage plus a terminal Tokens on the result envelope. The
// fixture's two assistant envelopes both carry usage, so expect >=2
// here (the exact count is fixture-defined and not asserted to keep
// the fixture editable).
var tokensSeen int
var tokensOut int
for _, ev := range events {
2 unmodified lines
tokensOut = tk.Out
}
}
if tokensSeen != 1 {
t.Errorf("Tokens count = %d, want 1", tokensSeen)
if tokensSeen < 2 {
t.Errorf("Tokens count = %d, want >=2 (per-assistant snapshots + result)", tokensSeen)
}
if tokensOut == 0 {
t.Error("Tokens.Out = 0, want > 0")
t.Error("final Tokens.Out = 0, want > 0")
}
}
127 unmodified lines
}
}
// TestParseClaudeOutput_EmitsCumulativeInputDuringRun captures the live-token
// contract for Claude. The `Tokens` type is documented as cumulative running
// totals (each emission replaces the previous), so mid-run emissions must be
// running sums, not per-call snapshots. Claude's assistant envelopes carry a
// usage block per API call (repeated verbatim on every content-block envelope
// of the same message id), where output_tokens is a 1–8 token "initial
// decision" stub — so the parser accumulates input across unique message ids,
// emits `Tokens{In: <running sum>, Out: 0}`, and lets the terminal `result`
// envelope deliver the true {In, Out} aggregate.
//
// Fixture is derived from real `claude -p --output-format stream-json
// --verbose` output captured against claude-haiku-4-5: six assistant
// envelopes across three API calls (message ids msg_turn1..3, with turn 1
// repeated on three envelopes), then a final result. The per-call input sums
// are 56277, 56626, and 56734 — running totals 56277, 112903, 169637 — and
// the result aggregate is exactly {In: 169637, Out: 2511}, which pins that
// accumulation converges to the final figure.
func TestParseClaudeOutput_EmitsCumulativeInputDuringRun(t *testing.T) {
t.Parallel()
f, err := os.Open("testdata/stream_with_deltas.jsonl")
if err != nil {
t.Fatal(err)
}
defer f.Close()
var events []reviewtypes.Event
for ev := range parseClaudeOutput(f) {
events = append(events, ev)
}
var tokens []reviewtypes.Tokens
sawFinished := false
for _, e := range events {
switch ev := e.(type) {
case reviewtypes.Tokens:
if sawFinished {
t.Errorf("Tokens event arrived AFTER Finished — wrong ordering")
}
tokens = append(tokens, ev)
case reviewtypes.Finished:
sawFinished = true
}
}
// One emission per unique message id (duplicate envelopes of the same
// API call must not re-emit) plus the terminal result emission.
want := []reviewtypes.Tokens{
{In: 56277, Out: 0},
{In: 112903, Out: 0},
{In: 169637, Out: 0},
{In: 169637, Out: 2511},
}
if len(tokens) != len(want) {
t.Fatalf("Tokens events = %d, want %d (one per unique message id + result): %+v", len(tokens), len(want), tokens)
}
for i, w := range want {
if tokens[i] != w {
t.Errorf("tokens[%d] = %+v, want %+v", i, tokens[i], w)
}
}
}
// TestParseClaudeOutput_UsagelessResultDoesNotClobberCumulative pins the
// terminal emission guard: a result envelope with no/zero usage must not
// emit Tokens{0,0} — under the consumers' overwrite-not-sum semantics that
// would erase the mid-run cumulative input total.
func TestParseClaudeOutput_UsagelessResultDoesNotClobberCumulative(t *testing.T) {
t.Parallel()
input := strings.Join([]string{
`{"type":"assistant","message":{"id":"msg_1","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":10,"cache_read_input_tokens":90,"cache_creation_input_tokens":0,"output_tokens":2}}}`,
`{"type":"result","subtype":"success","is_error":false}`,
"",
}, "\n")
var tokens []reviewtypes.Tokens
for ev := range parseClaudeOutput(strings.NewReader(input)) {
if tk, ok := ev.(reviewtypes.Tokens); ok {
tokens = append(tokens, tk)
}
}
if len(tokens) == 0 {
t.Fatal("expected the mid-run cumulative Tokens emission")
}
last := tokens[len(tokens)-1]
if last.In == 0 && last.Out == 0 {
t.Fatalf("final tokens = %+v — usage-less result clobbered the cumulative total", last)
}
if last.In != 100 {
t.Errorf("final tokens = %+v, want the cumulative {100, 0} to stand", last)
}
}
// collectEvents drains an event channel into a slice.
func collectEvents(ch <-chan reviewtypes.Event) []reviewtypes.Event {
var events []reviewtypes.Event
Mcmd/entire/cli/agent/claudecode/reviewer_test.go+100/-3
1
2
3
4
5
6
7
{"type":"system","subtype":"init","cwd":"/redacted/worktree","session_id":"a905e63f-aaaa-aaaa-aaaa-aaaaaaaaaaaa","model":"claude-haiku-4-5","permissionMode":"plan","output_style":"default","apiKeySource":"none","uuid":"redacted-uuid-1"}
{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_turn1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Analyzing the request..."}],"stop_reason":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":56267,"cache_read_input_tokens":0,"output_tokens":6,"service_tier":"standard"}},"session_id":"a905e63f-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"redacted-uuid-2"}
{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_turn1","type":"message","role":"assistant","content":[{"type":"text","text":"I'll outline a plan first."}],"stop_reason":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":56267,"cache_read_input_tokens":0,"output_tokens":6,"service_tier":"standard"}},"session_id":"a905e63f-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"redacted-uuid-3"}
{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_turn1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Write","input":{"file_path":"plan.md","content":"plan body"}}],"stop_reason":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":56267,"cache_read_input_tokens":0,"output_tokens":6,"service_tier":"standard"}},"session_id":"a905e63f-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"redacted-uuid-4"}
{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_turn2","type":"message","role":"assistant","content":[{"type":"text","text":"Plan created, ready to proceed."}],"stop_reason":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":10066,"cache_read_input_tokens":46555,"output_tokens":1,"service_tier":"standard"}},"session_id":"a905e63f-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"redacted-uuid-5"}
{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_turn3","type":"message","role":"assistant","content":[{"type":"text","text":"Found 3 issues."}],"stop_reason":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":107,"cache_read_input_tokens":56621,"output_tokens":2,"service_tier":"standard"}},"session_id":"a905e63f-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"redacted-uuid-6"}
{"type":"result","subtype":"success","is_error":false,"duration_ms":29272,"num_turns":3,"result":"Found 3 issues.","stop_reason":"end_turn","session_id":"a905e63f-aaaa-aaaa-aaaa-aaaaaaaaaaaa","total_cost_usd":0.105,"usage":{"input_tokens":21,"cache_creation_input_tokens":66440,"cache_read_input_tokens":103176,"output_tokens":2511,"service_tier":"standard"},"uuid":"redacted-uuid-7"}
Acmd/entire/cli/agent/claudecode/testdata/stream_with_deltas.jsonl+7
192 unmodified lines
193
194
195
196
196
197
198
199
4 unmodified lines
204
205
206
207
208
209
210
211
212
213
214
192 unmodified lines
## Gaps & Limitations
- **Hooks require feature flag:** The `hooks` feature is `default_enabled: false` (stage: UnderDevelopment). It must be enabled via `--enable hooks` CLI flag, or `features.hooks = true` in `config.toml`, or `-c features.hooks=true`. Without this, hooks.json is ignored entirely.
- **Hooks require feature flag:** The `codex_hooks` feature is `default_enabled: false` (stage: UnderDevelopment). It must be enabled via `--enable codex_hooks` CLI flag, or `features.codex_hooks = true` in `config.toml`, or `-c features.codex_hooks=true`. Without this, hooks.json is ignored entirely.
- **No SessionEnd hook:** Codex does not fire a hook when a session is completely terminated. The `Stop` hook fires at end-of-turn, not end-of-session. This is similar to some other agents — the framework handles this gracefully.
- **PreToolUse is shell-only:** Currently only fires for `Bash` tool (direct shell execution). MCP tools, stdin streaming, and other tool types are not yet hooked. PostToolUse is in review.
- **Transcript may be null:** In `--ephemeral` mode, `transcript_path` is null. The integration should handle this gracefully.
4 unmodified lines
- JSON schemas at `codex-rs/hooks/schema/generated/` in the Codex repository
- Hook config structure at `codex-rs/hooks/src/engine/config.rs` in the Codex repository
## Review integration (`entire review`)
Codex review runs via `codex exec --skip-git-repo-check --json [-m <model>] [-c model_reasoning_effort=<level>] -` (prompt on stdin). **`codex exec` fires no lifecycle hooks**, which shapes the whole integration (see CLAUDE.md → `entire review` → "Codex specifics"):
- **Skills are passed verbatim, not paraphrased.** Codex injects its installed-skill catalog into every exec session and loads the matching `SKILL.md`; configured skills use codex's `$name` / `$plugin:name` form (`DiscoverReviewSkills` in `discovery.go`). Native `codex exec review` is not used — it rejects a prompt under a scope flag and can't carry Entire's scope/per-run/checkpoint context.
- **Live tokens come from the rollout file, not stdout.** `codex exec --json` carries `usage` only on the terminal `turn.completed`, and a review is a single turn. `review_tokens.go` resolves the rollout transcript by `thread_id` (from the `thread.started` envelope), tails it (the same `~/.codex/.../rollout-*-<thread-id>.jsonl` documented under Transcript above), and emits cumulative `Tokens` per `token_count` event — the source codex's interactive UI reads.
- **No tagged review session.** Because no hook fires, codex's session is never tagged `KindAgentReview`. The fix manifest therefore sources codex from its **live run output** (`run.Buffer`), and `entire review fix` skill verification is advisory for codex (loose description match), not a hard block.
Mcmd/entire/cli/agent/codex/AGENT.md+9/-1
1 unmodified line
2
3
4
5
6
7
8
9
10
11
12
9
10
11
12
13
14
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
1 unmodified line
import (
"context"
"log/slog"
"path/filepath"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/skilldiscovery"
"github.com/entireio/cli/cmd/entire/cli/logging"
)
// DiscoverReviewSkills is a stub until the Codex on-disk plugin layout is
// verified against codex-rs source (see codex-rs/tui/src/slash_command.rs).
// Returns (nil, nil) so the picker treats Codex as "built-ins + install
// hint only" for Phase 1.
func (c *CodexAgent) DiscoverReviewSkills(_ context.Context) ([]agent.DiscoveredSkill, error) {
return nil, nil
// DiscoverReviewSkills walks codex's on-disk skill layout looking for
// review-adjacent skills. Returns (nil, nil) when HOME is unreadable or the
// directories are missing — discovery is best-effort.
//
// Codex exposes skills as <root>/<name>/SKILL.md (same frontmatter shape as
// Claude). Three roots contribute, mirroring codex's own injected skills
// catalog:
// - ~/.codex/skills/<name>/ → user skills ($name)
// - ~/.codex/plugins/cache/<m>/<p>/<v>/skills/<name>/ → plugin skills ($p:name)
// - ~/.codex/superpowers/skills/<name>/ → superpowers ($superpowers:name)
//
// Skills are emitted in codex's dollar invocation form ($name / $plugin:name) —
// the literal token a user types to invoke the skill in the codex CLI — so the
// review prompt names skills exactly the way codex's skill system expects,
// loading the real SKILL.md rather than relying on a loose description match.
//
//nolint:unparam // error return is part of SkillDiscoverer contract; future implementations may report hard failures
func (c *CodexAgent) DiscoverReviewSkills(ctx context.Context) ([]agent.DiscoveredSkill, error) {
// resolveCodexHome is the agent's canonical config-tree resolution
// (honors CODEX_HOME) — discovery must see the same skills codex runs.
codexHome, err := resolveCodexHome()
if err != nil {
logging.Debug(ctx, "codex discovery: resolve codex home failed", slog.String("error", err.Error()))
return nil, nil
}
form := skilldiscovery.DollarForm
var found []agent.DiscoveredSkill
found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(codexHome, "skills"), "", form)...)
found = append(found, skilldiscovery.ScanPluginCache(ctx, filepath.Join(codexHome, "plugins", "cache"),
func(versionRoot, pluginName string) []agent.DiscoveredSkill {
return skilldiscovery.ScanSkillsDir(ctx, filepath.Join(versionRoot, "skills"), pluginName, form)
})...)
found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(codexHome, "superpowers", "skills"), "superpowers", form)...)
found = skilldiscovery.DedupeByInvocation(found)
if len(found) == 0 {
return nil, nil
}
return found, nil
}
Mcmd/entire/cli/agent/codex/discovery.go+43/-6
1 unmodified line
2
3
4
5
6
7
8
9
3 unmodified lines
13
14
15
14
15
16
17
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
19
44
45
21
22
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
1 unmodified line
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/entireio/cli/cmd/entire/cli/agent"
3 unmodified lines
// Compile-time pin: CodexAgent must satisfy SkillDiscoverer.
var _ agent.SkillDiscoverer = (*codex.CodexAgent)(nil)
func TestCodexAgent_DiscoverReviewSkills_Stub(t *testing.T) {
t.Parallel()
a := &codex.CodexAgent{}
skills, err := a.DiscoverReviewSkills(context.Background())
// withFakeHome points HOME at a temp dir so discovery walks an empty,
// controlled ~/.codex tree. Uses t.Setenv, so callers must NOT t.Parallel.
func withFakeHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("CODEX_HOME", "") // hermetic: a dev shell's CODEX_HOME must not leak in
return home
}
// writeSkill creates <root>/<name>/SKILL.md with the given frontmatter name
// and description.
func writeSkill(t *testing.T, root, dir, name, description string) {
t.Helper()
skillDir := filepath.Join(root, dir)
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\nbody\n"
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func discover(t *testing.T) []agent.DiscoveredSkill {
t.Helper()
skills, err := (&codex.CodexAgent{}).DiscoverReviewSkills(context.Background())
if err != nil {
t.Fatalf("stub should not error; got %v", err)
t.Fatalf("unexpected error: %v", err)
}
if skills != nil {
t.Errorf("stub should return nil skills; got %+v", skills)
return skills
}
func nameOf(skills []agent.DiscoveredSkill, want string) bool {
for _, s := range skills {
if s.Name == want {
return true
}
}
return false
}
func TestCodexAgent_DiscoverReviewSkills_NoSkillsReturnsNilNil(t *testing.T) {
// Cannot t.Parallel — uses t.Setenv.
withFakeHome(t)
if skills := discover(t); skills != nil {
t.Errorf("skills = %v, want nil", skills)
}
}
func TestCodexAgent_DiscoverReviewSkills_FindsUserSkillInDollarForm(t *testing.T) {
home := withFakeHome(t)
writeSkill(t, filepath.Join(home, ".codex", "skills"), "code-reviewer", "code-reviewer",
"Review code changes with an emphasis on correctness.")
skills := discover(t)
if len(skills) != 1 {
t.Fatalf("skills count = %d, want 1: %+v", len(skills), skills)
}
if skills[0].Name != "$code-reviewer" {
t.Errorf("Name = %q, want $code-reviewer", skills[0].Name)
}
}
func TestCodexAgent_DiscoverReviewSkills_FindsPluginSkillNamespaced(t *testing.T) {
home := withFakeHome(t)
// Opaque (non-semver) version dir, like codex's content-hash versions.
writeSkill(t,
filepath.Join(home, ".codex", "plugins", "cache", "openai-curated", "github", "fef63ecf", "skills"),
"gh-review", "gh-review", "Review a GitHub pull request.")
skills := discover(t)
if !nameOf(skills, "$github:gh-review") {
t.Errorf("missing $github:gh-review; got %+v", skills)
}
}
func TestCodexAgent_DiscoverReviewSkills_FindsSuperpowersSkill(t *testing.T) {
home := withFakeHome(t)
writeSkill(t, filepath.Join(home, ".codex", "superpowers", "skills"),
"receiving-code-review", "receiving-code-review", "Receive code review feedback.")
skills := discover(t)
if !nameOf(skills, "$superpowers:receiving-code-review") {
t.Errorf("missing $superpowers:receiving-code-review; got %+v", skills)
}
}
func TestCodexAgent_DiscoverReviewSkills_SkipsNonReviewSkill(t *testing.T) {
home := withFakeHome(t)
skillsRoot := filepath.Join(home, ".codex", "skills")
writeSkill(t, skillsRoot, "code-reviewer", "code-reviewer", "Review code changes.")
// "committer" has no review keyword in its name → filtered by Matches.
writeSkill(t, skillsRoot, "committer", "committer", "Prepare clear commit messages.")
skills := discover(t)
if len(skills) != 1 || skills[0].Name != "$code-reviewer" {
t.Errorf("want only $code-reviewer; got %+v", skills)
}
}
// TestCodexAgent_DiscoverReviewSkills_HonorsCodexHome pins discovery to the
// agent's canonical home resolution: the rest of the codex agent resolves its
// config tree through resolveCodexHome (which honors CODEX_HOME), so skills
// installed under a custom codex home must be discoverable too — otherwise
// saved $skills fail spawn-time validation as "not installed" even though
// codex itself finds and runs them.
func TestCodexAgent_DiscoverReviewSkills_HonorsCodexHome(t *testing.T) {
// Cannot t.Parallel — uses t.Setenv.
withFakeHome(t) // HOME points at an empty dir; the skill lives elsewhere
codexHome := t.TempDir()
t.Setenv("CODEX_HOME", codexHome)
writeSkill(t, codexHome, "skills/code-review", "code-review", "Reviews code.")
if !nameOf(discover(t), "$code-review") {
t.Fatal("skill under CODEX_HOME not discovered — discovery must use resolveCodexHome, not ~/.codex")
}
}
Mcmd/entire/cli/agent/codex/discovery_test.go+116/-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
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
package codex
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"sync/atomic"
"time"
"github.com/entireio/cli/cmd/entire/cli/logging"
reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types"
)
// Polling/tailing cadence for the rollout token tailer.
const (
rolloutPollInterval = 300 * time.Millisecond
rolloutPollAttempts = 100 // ~30s for codex to create the rollout file
rolloutTailInterval = 400 * time.Millisecond
rolloutReadChunk = 8192
)
// tailRolloutTokens resolves the codex rollout transcript for threadID and
// tails it, emitting a cumulative reviewtypes.Tokens event for every
// token_count codex writes (~once per model turn). codex's `exec --json`
// stdout only carries usage on turn.completed envelopes, and a review is
// usually a single turn — so without this, consumers see no token movement
// until the run ends. The rollout file is the same source codex's
// interactive UI reads for its live token counter.
//
// token_count.total_token_usage is a running SESSION total (not per-turn
// scale like turn.completed usage), so each emission is an absolute count —
// matching consumers' overwrite-not-sum semantics. Duplicate totals are
// suppressed so we only emit on real movement. emitted is set after the
// first successful send; the parser uses it to suppress its per-turn-scale
// stdout emissions so a single source stays authoritative.
//
// Returns when stop is closed (the stdout stream ended) — after one final
// catch-up drain of the file, so the last token_count codex wrote is not
// lost to tick timing — or when the rollout file never appears. The caller
// must wait for this to return before closing the event channel (see
// parseCodexOutputBuf), and the run contract guarantees the consumer drains
// events until close, so sends here can neither race a close nor deadlock.
func tailRolloutTokens(threadID string, out chan<- reviewtypes.Event, stop <-chan struct{}, emitted *atomic.Bool) {
ctx := context.Background()
sessionDir, err := (&CodexAgent{}).GetSessionDir("")
if err != nil {
logging.Debug(ctx, "codex token tail: session dir unresolved", slog.String("error", err.Error()))
return
}
path := waitForRollout(ctx, sessionDir, threadID, stop)
if path == "" {
return
}
f, err := os.Open(path) //nolint:gosec // path is a glob match under codex's session dir, not user input
if err != nil {
logging.Debug(ctx, "codex token tail: open rollout failed", slog.String("error", err.Error()))
return
}
defer f.Close()
// Tail via os.File.Read rather than bufio.Reader: bufio is sticky on EOF
// and would never observe lines codex appends after we first catch up.
tail := rolloutTail{f: f, out: out, emitted: emitted, lastIn: -1, lastOut: -1}
ticker := time.NewTicker(rolloutTailInterval)
defer ticker.Stop()
for {
if err := tail.drain(); err != nil {
logging.Debug(ctx, "codex token tail: read rollout failed", slog.String("error", err.Error()))
return
}
select {
case <-stop:
// Final catch-up: codex may have flushed the terminal
// token_count between our last drain and stream end.
if err := tail.drain(); err != nil {
logging.Debug(ctx, "codex token tail: final drain failed", slog.String("error", err.Error()))
}
// Re-emit the last totals unconditionally (bypassing dedup):
// a per-turn stdout emission can race past the parser's
// tailerEmitted check in the instant before this tailer's
// first send is observed, and this re-send guarantees the
// session-cumulative value is the final Tokens regardless.
if tail.lastIn >= 0 {
out <- reviewtypes.Tokens{In: tail.lastIn, Out: tail.lastOut}
}
return
case <-ticker.C:
}
}
}
// rolloutTail holds the incremental read state for one rollout file.
type rolloutTail struct {
f *os.File
out chan<- reviewtypes.Event
emitted *atomic.Bool
pending []byte
lastIn int
lastOut int
}
// drain reads the file to EOF, emitting Tokens for every complete
// token_count line with new totals. Returns a non-nil error only for
// non-EOF read failures (deleted file, I/O error) — persistent failures
// must stop the tailer instead of silently re-polling forever.
func (t *rolloutTail) drain() error {
chunk := make([]byte, rolloutReadChunk)
for {
n, readErr := t.f.Read(chunk)
if n > 0 {
t.pending = append(t.pending, chunk[:n]...)
for {
idx := bytes.IndexByte(t.pending, '\n')
if idx < 0 {
break
}
line := t.pending[:idx]
t.pending = t.pending[idx+1:]
in, outTok, ok := parseRolloutTokenCount(line)
if !ok || (in == t.lastIn && outTok == t.lastOut) {
continue
}
t.lastIn, t.lastOut = in, outTok
// Unconditional send is safe: the parser waits for the
// tailer before closing the channel, and the run contract
// guarantees the consumer drains until close.
t.out <- reviewtypes.Tokens{In: in, Out: outTok}
t.emitted.Store(true)
}
}
if readErr != nil {
if errors.Is(readErr, io.EOF) {
return nil // caught up — wait for the file to grow
}
return fmt.Errorf("read rollout: %w", readErr)
}
}
}
// waitForRollout polls for the rollout file matching threadID until it
// appears or stop fires — never giving up while the review is running, since
// a rollout that materialises late (slow codex startup, unusual layout
// timing) should still get live tokens for the rest of the run. After the
// expected-quickly window it debug-logs once (the likely signature of a
// codex release changing the rollout layout, which would otherwise silently
// disable live tokens) and backs off to a slower poll.
func waitForRollout(ctx context.Context, sessionDir, threadID string, stop <-chan struct{}) string {
return pollForRollout(ctx, sessionDir, threadID, stop, rolloutPollAttempts, rolloutPollInterval)
}
func pollForRollout(ctx context.Context, sessionDir, threadID string, stop <-chan struct{}, window int, interval time.Duration) string {
for attempt := 0; ; attempt++ {
if path := findRolloutBySessionID(sessionDir, threadID); path != "" {
return path
}
wait := interval
if attempt >= window {
if attempt == window {
logging.Debug(ctx, "codex token tail: rollout file still missing; continuing to poll",
slog.String("session_dir", sessionDir), slog.String("thread_id", threadID))
}
wait = interval * 8 // ~2.4s at production cadence — cheap for a minutes-long run
}
select {
case <-stop:
return ""
case <-time.After(wait):
}
}
}
// parseRolloutTokenCount extracts cumulative input/output token totals from one
// rollout JSONL line. ok is false for any line that isn't a token_count event
// carrying total_token_usage. Reuses the rolloutLine/eventMsgPayload/
// tokenCountInfo shapes from transcript.go so the two readers can't drift.
func parseRolloutTokenCount(data []byte) (in, out int, ok bool) {
var line rolloutLine
if json.Unmarshal(data, &line) != nil || line.Type != "event_msg" {
return 0, 0, false
}
var evt eventMsgPayload
if json.Unmarshal(line.Payload, &evt) != nil || evt.Type != "token_count" || len(evt.Info) == 0 {
return 0, 0, false
}
var info tokenCountInfo
if json.Unmarshal(evt.Info, &info) != nil || info.TotalTokenUsage == nil {
return 0, 0, false
}
return info.TotalTokenUsage.InputTokens, info.TotalTokenUsage.OutputTokens, true
}
Acmd/entire/cli/agent/codex/review_tokens.go+195
package codex
import ( "context" "io" "os" "path/filepath" "strconv" "sync/atomic" "testing" "time"
reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types" )
const tailTestThreadID = "019e8d8f-9d70-7021-b8fe-2c13802e3443"
func tokenLine(in, out int) string {
return {"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage": +
{"input_tokens": + strconv.Itoa(in) + ,"output_tokens": + strconv.Itoa(out) + }}}} + "\n"
}
func TestParseRolloutTokenCount(t *testing.T) {
t.Parallel()
in, out, ok := parseRolloutTokenCount([]byte(tokenLine(25338, 595)))
if !ok || in != 25338 || out != 595 {
t.Fatalf("token_count line: got in=%d out=%d ok=%v, want 25338/595/true", in, out, ok)
}
// Non-token_count lines are ignored.
for _, line := range []string{
{"type":"response_item","payload":{"type":"reasoning"}},
{"type":"event_msg","payload":{"type":"agent_message"}},
not json,
``,
} {
if _, _, ok := parseRolloutTokenCount([]byte(line)); ok {
t.Errorf("expected ok=false for %q", line)
}
}
}
// TestTailRolloutTokens_TailsAppendedLines is the core behavior: the tailer // must emit Tokens for token_count lines that codex appends after the tailer // has already caught up to EOF (a plain bufio.Reader would miss these). func TestTailRolloutTokens_TailsAppendedLines(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. dir := t.TempDir() t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", dir)
rollout := filepath.Join(dir, "rollout-2026-06-03T08-57-39-"+tailTestThreadID+".jsonl") if err := os.WriteFile(rollout, []byte(tokenLine(25338, 595)), 0o644); err != nil { t.Fatal(err) }
out := make(chan reviewtypes.Event, 16) stop := make(chan struct{}) done := make(chan struct{}) go func() { tailRolloutTokens(tailTestThreadID, out, stop, new(atomic.Bool)) close(done) }() defer func() { close(stop) <-done }()
first := awaitTokens(t, out) if first.In != 25338 || first.Out != 595 { t.Fatalf("first tokens = %+v, want {25338, 595}", first) }
// Append a second token_count after the tailer caught up — it must see it. f, err := os.OpenFile(rollout, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Fatal(err) } if _, err := f.WriteString(tokenLine(52798, 1123)); err != nil { t.Fatal(err) } _ = f.Close()
second := awaitTokens(t, out) if second.In != 52798 || second.Out != 1123 { t.Fatalf("second tokens = %+v, want {52798, 1123} (appended line not tailed)", second) } }
// awaitTokens waits for the next Tokens event or fails on timeout. func awaitTokens(t *testing.T, out <-chan reviewtypes.Event) reviewtypes.Tokens { t.Helper() timeout := time.After(5 * time.Second) for { select { case ev := <-out: if tk, ok := ev.(reviewtypes.Tokens); ok { return tk } case <-timeout: t.Fatal("timed out waiting for a Tokens event") } } }
// startTailerFixture writes a rollout file for tailTestThreadID, starts the // parser on a pipe, sends thread.started, and waits for the tailer's first // Tokens. Returns the pipe writer, the event channel, and the rollout path. func startTailerFixture(t *testing.T, firstLine string, wantIn, wantOut int) (*io.PipeWriter, <-chan reviewtypes.Event, string) { t.Helper() dir := t.TempDir() t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", dir) rollout := filepath.Join(dir, "rollout-2026-06-03T08-57-39-"+tailTestThreadID+".jsonl") if err := os.WriteFile(rollout, []byte(firstLine), 0o644); err != nil { t.Fatal(err) }
pr, pw := io.Pipe()
events := parseCodexOutput(pr)
// Inline write is safe: the parser goroutine is already draining pr.
if _, err := pw.Write([]byte({"type":"thread.started","thread_id":" + tailTestThreadID + "} + "\n")); err != nil {
t.Fatalf("write thread.started: %v", err)
}
// The tailer (not stdout — no turn.completed was written yet) must // deliver Tokens while the stream is still open. tk := awaitTokens(t, events) if tk.In != wantIn || tk.Out != wantOut { t.Fatalf("tailer tokens = %+v, want {%d, %d}", tk, wantIn, wantOut) } return pw, events, rollout }
// collectUntilClose drains events until the channel closes, failing the test // if it doesn't close within 5s. func collectUntilClose(t *testing.T, events <-chan reviewtypes.Event) []reviewtypes.Event { t.Helper() var got []reviewtypes.Event drained := make(chan struct{}) go func() { for ev := range events { got = append(got, ev) } close(drained) }() select { case <-drained: case <-time.After(5 * time.Second): t.Fatal("event channel did not close — tailer not stopped") } return got }
// TestParseCodexOutput_StartsRolloutTailerOnThreadStarted locks the wiring: // the parser launches the rollout tailer when thread.started carries a // thread_id, so Tokens flow from the rollout file between turn boundaries, // and the parser stops the tailer and waits for it before closing the event // channel (no send-on-closed-channel race). func TestParseCodexOutput_StartsRolloutTailerOnThreadStarted(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. pw, events, _ := startTailerFixture(t, tokenLine(11111, 22), 11111, 22) _ = pw.Close() collectUntilClose(t, events) }
// TestParseCodexOutput_FinishedIsLastEvenWithPendingTailerLines pins the // parser contract that Finished is the final event: the tailer must be // stopped and awaited BEFORE the terminal emissions, not in a defer that // runs after them — otherwise a tailer with unread rollout lines keeps // sending Tokens after Finished. func TestParseCodexOutput_FinishedIsLastEvenWithPendingTailerLines(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. pw, events, rollout := startTailerFixture(t, tokenLine(1000, 50), 1000, 50)
// Append a large backlog, then wait until the tailer is actively // draining it (a few backlog Tokens observed) before signalling EOF — // that pins the tailer mid-send exactly when the parser emits its // terminal events. f, err := os.OpenFile(rollout, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Fatal(err) } for i := 1; i <= 2000; i++ { if _, err := f.WriteString(tokenLine(1000+i, 50+i)); err != nil { t.Fatal(err) } } _ = f.Close() for range 3 { awaitTokens(t, events) } _ = pw.Close() // EOF with tailer mid-backlog
got := collectUntilClose(t, events) if len(got) == 0 { t.Fatal("no events after EOF") } last := got[len(got)-1] if _, ok := last.(reviewtypes.Finished); !ok { t.Fatalf("last event = %#v, want Finished (Tokens after Finished violates the parser contract)", last) } }
// TestParseCodexOutput_UsagelessTurnCompletedDoesNotClobberTailerTokens pins // the backstop behavior: a terminal turn.completed WITHOUT a usage block // must not emit Tokens{0,0} — under overwrite-not-sum consumer semantics // that would erase the rollout tailer's genuine totals. func TestParseCodexOutput_UsagelessTurnCompletedDoesNotClobberTailerTokens(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. pw, events, _ := startTailerFixture(t, tokenLine(1000, 50), 1000, 50)
if _, err := pw.Write([]byte({"type":"turn.completed"} + "\n")); err != nil {
t.Fatalf("write turn.completed: %v", err)
}
_ = pw.Close()
got := collectUntilClose(t, events) // The tailer's {1000, 50} was already consumed by startTailerFixture; // it must remain the final observed value — any later Tokens (in // particular {0,0} from the old backstop) would clobber it under the // consumers' overwrite semantics. lastTokens := reviewtypes.Tokens{In: 1000, Out: 50} finishedOK := false for _, ev := range got { switch e := ev.(type) { case reviewtypes.Tokens: if e.In == 0 && e.Out == 0 { t.Fatalf("observed Tokens{0,0} — clobbers the tailer's totals") } lastTokens = e case reviewtypes.Finished: finishedOK = e.Success } } if !finishedOK { t.Error("turn.completed present: want Finished{Success:true}") } if lastTokens.In != 1000 || lastTokens.Out != 50 { t.Errorf("final tokens = %+v, want tailer's {1000, 50} to stand", lastTokens) } }
// TestParseCodexOutput_TailerSuppressesPerTurnStdoutTokens pins single-source // authority: rollout token_count totals are session-cumulative while // turn.completed usage is per-turn scale, so once the tailer has emitted, // per-turn stdout values must be suppressed — mixing the two makes the live // counter flap between scales and the final value nondeterministic. func TestParseCodexOutput_TailerSuppressesPerTurnStdoutTokens(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. pw, events, rollout := startTailerFixture(t, tokenLine(1000, 50), 1000, 50)
// The session-cumulative rollout advances to 2000/150... f, err := os.OpenFile(rollout, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Fatal(err) } if _, err := f.WriteString(tokenLine(2000, 150)); err != nil { t.Fatal(err) } _ = f.Close()
// ...then a per-turn-scale turn.completed arrives on stdout.
if _, err := pw.Write([]byte({"type":"turn.completed","usage":{"input_tokens":500,"output_tokens":30}} + "\n")); err != nil {
t.Fatalf("write turn.completed: %v", err)
}
_ = pw.Close()
got := collectUntilClose(t, events) var lastTokens reviewtypes.Tokens for _, ev := range got { switch e := ev.(type) { case reviewtypes.Tokens: if e.In == 500 && e.Out == 30 { t.Fatalf("per-turn stdout Tokens{500,30} emitted despite active tailer — scale flap") } lastTokens = e case reviewtypes.Finished: if !e.Success { t.Error("want Finished{Success:true}") } } } if lastTokens.In != 2000 || lastTokens.Out != 150 { t.Errorf("final tokens = %+v, want the tailer's cumulative {2000, 150}", lastTokens) } }
// TestTailRolloutTokens_PartialLineAppend covers the hand-rolled line buffer: // a token_count written in two partial chunks must be parsed exactly once, // when the newline completes it. func TestTailRolloutTokens_PartialLineAppend(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. dir := t.TempDir() t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", dir) rollout := filepath.Join(dir, "rollout-2026-06-03T08-57-39-"+tailTestThreadID+".jsonl") line := tokenLine(31337, 42) half := len(line) / 2 if err := os.WriteFile(rollout, []byte(line[:half]), 0o644); err != nil { t.Fatal(err) }
// Give the tailer a moment on the partial line, then complete it. select { case ev := <-out: t.Fatalf("event %#v emitted from a partial line", ev) case <-time.After(600 * time.Millisecond): } f, err := os.OpenFile(rollout, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Fatal(err) } if _, err := f.WriteString(line[half:]); err != nil { t.Fatal(err) } _ = f.Close()
tk := awaitTokens(t, out) if tk.In != 31337 || tk.Out != 42 { t.Fatalf("tokens = %+v, want {31337, 42}", tk) } }
// TestTailRolloutTokens_ReemitsLastTotalsOnStop pins the TOCTOU hardening: // on stop, after the final catch-up drain, the tailer re-emits its last // known totals. This guarantees the tailer's session-cumulative value is the // final Tokens even if a per-turn stdout emission raced past the parser's // tailerEmitted check in the instant before the tailer's first Store(true). func TestTailRolloutTokens_ReemitsLastTotalsOnStop(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. dir := t.TempDir() t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", dir) rollout := filepath.Join(dir, "rollout-2026-06-03T08-57-39-"+tailTestThreadID+".jsonl") if err := os.WriteFile(rollout, []byte(tokenLine(7000, 300)), 0o644); err != nil { t.Fatal(err) }
out := make(chan reviewtypes.Event, 16) stop := make(chan struct{}) done := make(chan struct{}) go func() { tailRolloutTokens(tailTestThreadID, out, stop, new(atomic.Bool)) close(done) }()
first := awaitTokens(t, out) if first.In != 7000 || first.Out != 300 { t.Fatalf("first tokens = %+v, want {7000, 300}", first) }
close(stop) <-done // The stop path must have re-emitted the last totals (dedup bypassed). select { case ev := <-out: tk, ok := ev.(reviewtypes.Tokens) if !ok || tk.In != 7000 || tk.Out != 300 { t.Fatalf("post-stop event = %#v, want re-emitted Tokens{7000, 300}", ev) } default: t.Fatal("no re-emitted Tokens after stop — TOCTOU window unguarded") } }
func TestTailRolloutTokens_ReturnsOnStopWhenNoRollout(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", t.TempDir()) out := make(chan reviewtypes.Event, 4) stop := make(chan struct{}) done := make(chan struct{}) go func() { tailRolloutTokens(tailTestThreadID, out, stop, new(atomic.Bool)) close(done) }() close(stop) select { case <-done: case <-time.After(5 * time.Second): t.Fatal("tailRolloutTokens did not return promptly after stop with no rollout file") } }
// TestPollForRollout_KeepsLookingPastTheWindow pins that the poll never // gives up while stop is open: a rollout that materialises after the // expected-quickly window must still be found (previously the poll returned // "" after ~30s and live tokens were lost for the rest of the run). func TestPollForRollout_KeepsLookingPastTheWindow(t *testing.T) { // Cannot t.Parallel — uses t.Setenv. dir := t.TempDir() t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", dir) rollout := filepath.Join(dir, "rollout-2026-06-03T08-57-39-"+tailTestThreadID+".jsonl")
stop := make(chan struct{}) defer close(stop) got := make(chan string, 1) go func() { got <- pollForRollout(context.Background(), dir, tailTestThreadID, stop, 3, 10*time.Millisecond) }()
// Create the file well after the 3-attempt window has elapsed. time.Sleep(200 * time.Millisecond) if err := os.WriteFile(rollout, []byte(tokenLine(1, 1)), 0o644); err != nil { t.Fatal(err) }
select { case path := <-got: if path != rollout { t.Fatalf("pollForRollout = %q, want %q (gave up instead of continuing past the window)", path, rollout) } case <-time.After(5 * time.Second): t.Fatal("pollForRollout did not find the late rollout") } }
Acmd/entire/cli/agent/codex/review\_tokens\_test.go+423
9 unmodified lines
10 11 12 13 14 15 16 17 16 unmodified lines
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 37 54 55 56 57 4 unmodified lines
62 63 64 48 49 50 51 52 53 65 66 56 67 68 69 70 71 72 59 60 73 74 75 76 77 17 unmodified lines
95 96 97 84 85 98 99 100 101 102 103 104 105 106 107 108 109 110 111 17 unmodified lines
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 112 151 152 153 154 18 unmodified lines
173 174 175 137 138 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 8 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 233 234 235 236 237 238 239 240 241 242 243 244 245 246 3 unmodified lines
250 251 252 167 168 169 170 171 172 173 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 10 unmodified lines
278 279 280 190 191 192 193 194 281 282 283 284 285 286 287 288 289
9 unmodified lines
"os" "os/exec" "strings" "sync" "sync/atomic"
"github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/review" 16 unmodified lines
// buildCodexReviewCmd builds the exec.Cmd for a codex review run.
// Exposed at package level for test inspection of argv, stdin, and env.
// buildCodexReviewCmd builds the exec.Cmd for a codex review run.
//
// Configured skills are passed through in codex's native $name form — NOT
// paraphrased. Codex's skill system injects a catalog of installed skills
// into every exec session and loads the matching SKILL.md when the prompt
// names one, so the agent runs the real configured workflow. A previous
// version silently REPLACED /review with a generic 28-word instruction: the
// configured skill never ran (the codex sibling of the claude -p
// slash-expansion bug, where the built-in /review hijacked the prompt).
//
// Native codex exec review is intentionally NOT used: it rejects an extra
// prompt when a scope flag is set, and codex hooks don't fire during it —
// leaving no channel for Entire's scope enumeration, per-run prompt, and
// checkpoint context. Plain codex exec - with the composed prompt on stdin
// runs the same skill while carrying our arguments.
func buildCodexReviewCmd(ctx context.Context, cfg reviewtypes.RunConfig) *exec.Cmd {
promptCfg := cfg
promptCfg.Skills = expandCodexBuiltinReview(cfg.Skills)
promptCfg.Skills = codexNativeSkillInvocations(cfg.Skills)
args := []string{codexExecCommand, "--skip-git-repo-check", "--json"}
args = review.AppendModelFlag(args, cfg.Model)
args = append(args, "-")
4 unmodified lines
return cmd }
// Codex's native exec review --base <branch> rejects an additional prompt,
// so expand /review into text and run normal codex exec -. That preserves
// Entire's scoped base clause, per-run instructions, and checkpoint context.
const codexBuiltinReviewPrompt = "Review the current branch changes and report actionable findings. " +
"Prioritize correctness, regressions, security, and missing test coverage. Do not make code changes."
const codexExecCommand = "exec"
func expandCodexBuiltinReview(skills []string) []string { // codexNativeSkillInvocations rewrites slash-form skill invocations (the // agent-portable form profiles are configured with) into codex's native // $name form. Non-slash entries (plain instruction text) pass verbatim. func codexNativeSkillInvocations(skills []string) []string { out := make([]string, 0, len(skills)) for _, skill := range skills { if skill == "/review" { out = append(out, codexBuiltinReviewPrompt) if rest, ok := strings.CutPrefix(skill, "/"); ok && rest != "" { out = append(out, "$"+rest) continue } out = append(out, skill) 17 unmodified lines
// On a scanner error or a missing turn.completed envelope, emits RunError
// (scanner) or Finished{Success: false} (missing turn) accordingly.
//
// Tokens are emitted only at the terminal turn.completed envelope, not
// incrementally — codex's usage fields land once at end-of-turn.
// Live-token semantics: codex's --json output carries usage ONLY on
// turn.completed envelopes. Verified against codex-cli 0.130.0 stdout
// for both short and long prompts — no intermediate envelope
// (item.started, item.completed, etc.) carries a usage block. Codex's
// on-disk session log (the event_msg{type:"token_count"} shape the
// transcript parser consumes) is a separate format, not surfaced
// through exec --json — the rollout tailer (review_tokens.go) reads
// it for live counts between turn boundaries.
//
// Tokens are emitted at every turn.completed envelope so multi-turn
// runs show iterative updates.
//
// Package-private; called directly from this package's tests so they can
// drive raw stdout fixtures through the parser without going through the
17 unmodified lines
out := make(chan reviewtypes.Event, 32) go func() { defer close(out) // The rollout token tailer (started on thread.started) runs concurrently // and also sends on out. It must be stopped and awaited BEFORE the // terminal Tokens/RunError/Finished emissions — Finished is contractually // the last event, and a lagging tailer tick would otherwise overwrite the // final recorded totals after completion. stopTailer is called explicitly // on every exit path ahead of the terminal sends; the deferred call is a // safety net (sync.Once) that also guarantees no send can hit the closed // channel. stop := make(chan struct{}) var tailWG sync.WaitGroup var tailerEmitted atomic.Bool stopTailer := sync.OnceFunc(func() { close(stop) tailWG.Wait() }) defer stopTailer() out <- reviewtypes.Started{} scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, min(1024*1024, maxBuf)), maxBuf) var seenTurnComplete bool var seenTurnComplete, emittedTokens, tailerStarted bool var turnUsage codexUsage var failureMsg string for scanner.Scan() { 18 unmodified lines
// default arm logs unknown types at Debug so drift can be // triaged via ENTIRE_LOG_LEVEL=debug. switch env.Type { case "thread.started", "turn.started": // Session/turn markers — no event emitted. case "thread.started": // Launch the rollout token tailer once. codex's exec --json stdout // only carries usage on turn.completed, so we tail the rollout // file (located by thread_id) for live per-turn token totals — // the same source codex's interactive UI reads. if !tailerStarted && env.ThreadID != "" { tailerStarted = true tailWG.Add(1) go func(id string) { defer tailWG.Done() tailRolloutTokens(id, out, stop, &tailerEmitted) }(env.ThreadID) } case "turn.started": // Turn marker — no event emitted. case "item.started": if env.Item.Type == "command_execution" { out <- reviewtypes.ToolCall{Name: "exec", Args: env.Item.Command} 8 unmodified lines
case "turn.completed": seenTurnComplete = true turnUsage = env.Usage // Emit Tokens at every turn boundary so multi-turn reviews // show iterative updates — but only while the rollout tailer // hasn't produced values: turn.completed usage is treated as // per-turn scale, the tailer's token_count totals are // session-cumulative, and mixing the two in one // overwrite-not-sum slot makes the counter flap between // scales. Once the tailer has emitted, it is the single // authoritative source. // // Scale caveat: whether turn.completed usage is per-turn or // session-cumulative is unverified against real MULTI-turn // codex output — exec-mode reviews are single-turn, where // the two are identical and this code is exact. In the rare // multi-turn no-rollout fallback, the recorded total is the // last turn's usage (an under-count if per-turn); when the // rollout tailer runs — the normal case — its cumulative // totals win regardless. // // codex reports cached_input_tokens as a subset of // input_tokens and reasoning_output_tokens as a subset of // output_tokens (matching OpenAI's chat-completions usage // shape), so do NOT sum the subset fields — that would // double-count. if !tailerEmitted.Load() && (env.Usage.InputTokens > 0 || env.Usage.OutputTokens > 0) { out <- reviewtypes.Tokens{ In: env.Usage.InputTokens, Out: env.Usage.OutputTokens, } emittedTokens = true } default: logging.Debug(context.Background(), "codex parser: unknown envelope type", slog.String("type", env.Type)) } } // Stream over — stop the tailer BEFORE any terminal emission so // Finished stays the last event and no lagging tailer tick can // overwrite the final recorded totals. stopTailer() if err := scanner.Err(); err != nil { out <- reviewtypes.RunError{Err: fmt.Errorf("read stdout: %w", err)} out <- reviewtypes.Finished{Success: false} 3 unmodified lines
out <- reviewtypes.RunError{Err: fmt.Errorf("codex: %s", failureMsg)}
}
if seenTurnComplete {
// codex reports cached_input_tokens as a subset of input_tokens
// and reasoning_output_tokens as a subset of output_tokens
// (matching OpenAI's chat-completions usage shape), so do NOT
// sum the subset fields — that would double-count.
out <- reviewtypes.Tokens{
In: turnUsage.InputTokens,
Out: turnUsage.OutputTokens,
// Defensive backstop for a stream whose turn.completed carried
// usage that never got emitted (can't happen today — the
// per-turn arm emits whenever usage is non-zero and no tailer
// value exists). Gated on non-zero usage: emitting {0,0} here
// would only ever ERASE the tailer's genuine totals under the
// consumers' overwrite-not-sum semantics.
if !emittedTokens && !tailerEmitted.Load() &&
(turnUsage.InputTokens > 0 || turnUsage.OutputTokens > 0) {
out <- reviewtypes.Tokens{
In: turnUsage.InputTokens,
Out: turnUsage.OutputTokens,
}
}
// Success is hard-coded true here because codex's turn.completed
// envelope has no turn-level error field in 0.130.0. If a future
10 unmodified lines
}
type codexEnvelope struct {
Type string json:"type"
Item codexItem json:"item"
Usage codexUsage json:"usage"
Message string json:"message"
Error codexErrorField json:"error"
Type string json:"type"
ThreadID string json:"thread_id" // present on thread.started; locates the rollout file
Item codexItem json:"item"
Usage codexUsage json:"usage"
Message string json:"message"
Error codexErrorField json:"error"
}
// codexErrorField captures the nested error message shape some codex envelopes
Mcmd/entire/cli/agent/codex/reviewer.go+119/-27
121 unmodified lines
122 123 124 125 125 126 127 128 128 129 130 131 214 unmodified lines
346 347 348 349 349 350 351 352 353 354 355 75 unmodified lines
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 29 unmodified lines
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
121 unmodified lines
prompt := readCodexCmdStdin(t, cmd) if strings.Contains(prompt, "/review") { t.Fatalf("builtin review prompt should not include raw /review:\n%s", prompt) t.Fatalf("slash-form skill must be rewritten to codex's $ form:\n%s", prompt) } for _, wantText := range []string{ "Review the current branch changes and report actionable findings.", "$review", "Focus on auth regressions.", "Scope: review the commits unique to this branch vs main, plus any uncommitted changes in the working tree. Ignore code outside this scope.", "Commits in scope (newest first):", 214 unmodified lines
}
func TestParseCodexOutput_NoTurnCompletedMeansFailed(t *testing.T) {
t.Parallel()
// Cannot t.Parallel — uses t.Setenv. The thread.started envelope
// launches the rollout tailer; without the session-dir override it
// would glob the real ~/.codex/sessions.
t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", t.TempDir())
// A truncated session: thread starts and an item completes, but no
// turn.completed envelope ever arrives. The parser must surface
// this as Finished{Success: false}.
75 unmodified lines
} }
// TestParseCodexOutput_EmitsTokensAtEveryTurnCompleted locks the live-token
// contract for codex: its --json output carries usage on every
// turn.completed envelope, and the parser emits Tokens at each turn
// boundary so multi-turn reviews show iterative updates. Captured by
// running real codex-cli 0.130.0 — no item.* envelope ever carried a
// usage field, so emission stays anchored to turn.completed.
func TestParseCodexOutput_EmitsTokensAtEveryTurnCompleted(t *testing.T) {
// Cannot t.Parallel — uses t.Setenv. The thread.started envelope
// launches the rollout tailer; without the session-dir override it
// would glob the real ~/.codex/sessions, and a matching rollout could
// inject Tokens into the exact-count assertions below.
t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", t.TempDir())
// Synthetic multi-turn stream (real envelope shapes, invented usage
// numbers) with a turn.completed at every turn boundary and NO rollout
// file — the no-tailer fallback path. The parser emits each turn's
// usage as it arrives. NOTE: turn.completed usage is treated as
// per-turn scale (see the parser doc); exec-mode reviews are single
// turn in practice, where per-turn and cumulative are identical, so
// multi-turn fallback totals are a documented approximation (the last
// turn's usage), not a verified cumulative sum.
input := strings.Join([]string{
{"type":"thread.started","thread_id":"tid-1"},
{"type":"turn.started"},
{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"ls","aggregated_output":"","exit_code":null,"status":"in_progress"}},
{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"ls","aggregated_output":"a\nb\nc","exit_code":0,"status":"completed"}},
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Found three files."}},
{"type":"turn.completed","usage":{"input_tokens":34317,"cached_input_tokens":19712,"output_tokens":240,"reasoning_output_tokens":114}},
{"type":"turn.started"},
{"type":"item.started","item":{"id":"item_2","type":"command_execution","command":"cat a","aggregated_output":"","exit_code":null,"status":"in_progress"}},
{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"cat a","aggregated_output":"hello","exit_code":0,"status":"completed"}},
{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Done."}},
{"type":"turn.completed","usage":{"input_tokens":35820,"cached_input_tokens":20114,"output_tokens":401,"reasoning_output_tokens":160}},
"",
}, "\n")
var tokens []reviewtypes.Tokens for ev := range parseCodexOutput(strings.NewReader(input)) { if tk, ok := ev.(reviewtypes.Tokens); ok { tokens = append(tokens, tk) } }
if len(tokens) != 2 { t.Fatalf("Tokens count = %d, want exactly 2 (one per turn.completed); got events: %+v", len(tokens), tokens) } if tokens[0].In != 34317 || tokens[0].Out != 240 { t.Errorf("tokens[0] = %+v, want {In:34317, Out:240}", tokens[0]) } if tokens[1].In != 35820 || tokens[1].Out != 401 { t.Errorf("tokens[1] = %+v, want {In:35820, Out:401}", tokens[1]) } }
func collectCodexEvents(ch <-chan reviewtypes.Event) []reviewtypes.Event { var events []reviewtypes.Event for ev := range ch { 29 unmodified lines
} return m }
// TestBuildCodexReviewCmd_SkillsPassNativelyNotParaphrased locks the fix for // codex skill invocation: configured skills reach codex in its native $name // form so codex's skill system loads the real SKILL.md, instead of /review // being silently REPLACED with a generic 28-word paraphrase (which meant the // configured skill never ran — the codex sibling of the claude -p // slash-expansion bug). func TestBuildCodexReviewCmd_SkillsPassNativelyNotParaphrased(t *testing.T) { t.Parallel() cmd := buildCodexReviewCmd(context.Background(), reviewtypes.RunConfig{ Skills: []string{"/review", "/pr-review-toolkit:review-pr", "plain instruction line"}, }) stdin, err := io.ReadAll(cmd.Stdin) if err != nil { t.Fatal(err) } prompt := string(stdin) for _, want := range []string{"$review", "$pr-review-toolkit:review-pr", "plain instruction line"} { if !strings.Contains(prompt, want) { t.Errorf("prompt missing native skill invocation %q:\n%s", want, prompt) } } if strings.Contains(prompt, "Review the current branch changes and report actionable findings") { t.Errorf("prompt still contains the generic paraphrase:\n%s", prompt) } if strings.Contains(prompt, "/review\n") || strings.HasSuffix(prompt, "/review") { t.Errorf("slash-form skill leaked through untransformed:\n%s", prompt) } }
// TestBuildCodexReviewCmd_PromptOverrideVerbatim ensures the $-form transform // never touches a verbatim prompt override. func TestBuildCodexReviewCmd_PromptOverrideVerbatim(t *testing.T) { t.Parallel() cmd := buildCodexReviewCmd(context.Background(), reviewtypes.RunConfig{ Skills: []string{"/review"}, PromptOverride: "/review exactly as written", }) stdin, err := io.ReadAll(cmd.Stdin) if err != nil { t.Fatal(err) } if got := string(stdin); got != "/review exactly as written" { t.Errorf("PromptOverride modified: %q", got) } }
Mcmd/entire/cli/agent/codex/reviewer\_test.go+106/-3
129 unmodified lines
130 131 132 133 134 135 136 137 138 139 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 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 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 175 unmodified lines
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363
129 unmodified lines
subagentStartCmd := cmdPrefix + HookNameSubagentStart
subagentEndCmd := cmdPrefix + HookNameSubagentStop
if !localDev {
sessionStartCmd = agent.WrapProductionSilentHookCommand(sessionStartCmd)
sessionEndCmd = agent.WrapProductionSilentHookCommand(sessionEndCmd)
beforeSubmitPromptCmd = agent.WrapProductionSilentHookCommand(beforeSubmitPromptCmd)
stopCmd = agent.WrapProductionSilentHookCommand(stopCmd)
preCompactCmd = agent.WrapProductionSilentHookCommand(preCompactCmd)
subagentStartCmd = agent.WrapProductionSilentHookCommand(subagentStartCmd)
subagentEndCmd = agent.WrapProductionSilentHookCommand(subagentEndCmd)
// Cursor spawns hook commands through the native OS shell (cmd.exe on
// Windows), so a sh -c '…' wrapper silently fails to launch on a
// Windows host without a working POSIX sh — no hook fires and, because
// this is the silent wrapper, no error surfaces (issue #1424).
// UseWindowsProductionHooks probes for a runnable sh and only swaps in
// the native cmd.exe wrapper when one is absent, so this is a no-op on
// hosts (incl. all non-Windows) where the sh wrapper already works.
useWindowsHooks := agent.UseWindowsProductionHooks(ctx, localDev)
sessionStartCmd = agent.WrapProductionSilentHookCommandForOS(sessionStartCmd, useWindowsHooks)
sessionEndCmd = agent.WrapProductionSilentHookCommandForOS(sessionEndCmd, useWindowsHooks)
beforeSubmitPromptCmd = agent.WrapProductionSilentHookCommandForOS(beforeSubmitPromptCmd, useWindowsHooks)
stopCmd = agent.WrapProductionSilentHookCommandForOS(stopCmd, useWindowsHooks)
preCompactCmd = agent.WrapProductionSilentHookCommandForOS(preCompactCmd, useWindowsHooks)
subagentStartCmd = agent.WrapProductionSilentHookCommandForOS(subagentStartCmd, useWindowsHooks)
subagentEndCmd = agent.WrapProductionSilentHookCommandForOS(subagentEndCmd, useWindowsHooks)
}
count := 0
// Add hooks if they don't exist if !hookCommandExists(sessionStart, sessionStartCmd) { sessionStart = append(sessionStart, CursorHookEntry{Command: sessionStartCmd}) count++ } if !hookCommandExists(sessionEnd, sessionEndCmd) { sessionEnd = append(sessionEnd, CursorHookEntry{Command: sessionEndCmd}) count++ } if !hookCommandExists(beforeSubmitPrompt, beforeSubmitPromptCmd) { beforeSubmitPrompt = append(beforeSubmitPrompt, CursorHookEntry{Command: beforeSubmitPromptCmd}) count++ } if !hookCommandExists(stop, stopCmd) { stop = append(stop, CursorHookEntry{Command: stopCmd}) count++ } if !hookCommandExists(preCompact, preCompactCmd) { preCompact = append(preCompact, CursorHookEntry{Command: preCompactCmd}) count++ } if !hookCommandExists(subagentStart, subagentStartCmd) { subagentStart = append(subagentStart, CursorHookEntry{Command: subagentStartCmd}) count++ } if !hookCommandExists(subagentStop, subagentEndCmd) { subagentStop = append(subagentStop, CursorHookEntry{Command: subagentEndCmd}) count++ } // Sync each hook to its desired command. syncEntireHook replaces any // stale-form Entire hook (e.g. an sh-wrapped entry from a previous install) // with the current command even without --force, so a wrapper-form change — // notably the sh↔cmd.exe migration driven by UseWindowsProductionHooks when // a Windows host gains or loses a working POSIX sh — cleanly replaces rather // than leaving a dead duplicate entry that could double-fire (issue #1424). sessionStart, count = syncEntireHook(sessionStart, sessionStartCmd, count) sessionEnd, count = syncEntireHook(sessionEnd, sessionEndCmd, count) beforeSubmitPrompt, count = syncEntireHook(beforeSubmitPrompt, beforeSubmitPromptCmd, count) stop, count = syncEntireHook(stop, stopCmd, count) preCompact, count = syncEntireHook(preCompact, preCompactCmd, count) subagentStart, count = syncEntireHook(subagentStart, subagentStartCmd, count) subagentStop, count = syncEntireHook(subagentStop, subagentEndCmd, count)
if count == 0 { return 0, nil 175 unmodified lines
// Helper functions for hook management
// syncEntireHook ensures entries contains exactly the given Entire hook command // for this hook type. If command is already present it is a no-op. Otherwise any // existing Entire hook (in any wrapper form) is removed before appending command, // so a changed wrapper form replaces the stale one rather than duplicating it. // Non-Entire entries are preserved. count is incremented when a change is made. func syncEntireHook(entries []CursorHookEntry, command string, count int) ([]CursorHookEntry, int) { if hookCommandExists(entries, command) { return entries, count } if hasEntireHook(entries) { entries = removeEntireHooks(entries) } return append(entries, CursorHookEntry{Command: command}), count + 1 }
func hookCommandExists(entries []CursorHookEntry, command string) bool { for _, entry := range entries { if entry.Command == command {
Mcmd/entire/cli/agent/cursor/hooks.go+43/-36
4 unmodified lines
5 6 7 8 9 10 11 52 unmodified lines
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
4 unmodified lines
"encoding/json" "os" "path/filepath" "strings" "testing"
"github.com/entireio/cli/cmd/entire/cli/agent" 52 unmodified lines
assertEntryCommand(t, hooksFile.Hooks.SubagentStop, agent.WrapProductionSilentHookCommand("entire hooks cursor subagent-stop")) }
// TestInstallHooks_WindowsProbeSuccessKeepsShWrappers verifies that on a // Windows host where a POSIX sh is runnable, Cursor keeps the sh-based wrappers // (parity with non-Windows). Mutates the shared probe, so no t.Parallel(). func TestInstallHooks_WindowsProbeSuccessKeepsShWrappers(t *testing.T) { t.Cleanup(agent.SetWindowsHookProbeForTesting("windows", func(context.Context, string) bool { return true // sh works }))
tempDir := t.TempDir() t.Chdir(tempDir)
ag := &CursorAgent{} if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { t.Fatalf("InstallHooks() error = %v", err) }
hooksFile := readHooksFile(t, tempDir) assertEntryCommand(t, hooksFile.Hooks.SessionStart, agent.WrapProductionSilentHookCommand("entire hooks cursor session-start")) assertEntryCommand(t, hooksFile.Hooks.Stop, agent.WrapProductionSilentHookCommand("entire hooks cursor stop")) }
// TestInstallHooks_WindowsProbeFailureUsesCmdWrappers verifies that on a Windows // host with no runnable POSIX sh, Cursor installs the native cmd.exe wrappers so // hooks actually fire (issue #1424). Mutates the shared probe, so no t.Parallel(). func TestInstallHooks_WindowsProbeFailureUsesCmdWrappers(t *testing.T) { t.Cleanup(agent.SetWindowsHookProbeForTesting("windows", func(context.Context, string) bool { return false // no working sh }))
tempDir := t.TempDir() t.Chdir(tempDir)
ag := &CursorAgent{} if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { t.Fatalf("InstallHooks() error = %v", err) }
hooksFile := readHooksFile(t, tempDir) assertEntryCommand(t, hooksFile.Hooks.SessionStart, agent.WrapWindowsProductionSilentHookCommand("entire hooks cursor session-start")) assertEntryCommand(t, hooksFile.Hooks.Stop, agent.WrapWindowsProductionSilentHookCommand("entire hooks cursor stop")) assertEntryCommand(t, hooksFile.Hooks.SubagentStop, agent.WrapWindowsProductionSilentHookCommand("entire hooks cursor subagent-stop")) }
// TestInstallHooks_WindowsProbeFlipMigratesCleanly verifies that when a host's // sh availability changes between installs, a non-force reinstall REPLACES the // stale sh-wrapped hooks with cmd.exe ones rather than leaving both (which would // double-fire). Mirrors the codex migration test. Mutates the shared probe, so // no t.Parallel(). func TestInstallHooks_WindowsProbeFlipMigratesCleanly(t *testing.T) { shWorks := true t.Cleanup(agent.SetWindowsHookProbeForTesting("windows", func(context.Context, string) bool { return shWorks }))
tempDir := t.TempDir() t.Chdir(tempDir) ag := &CursorAgent{}
// First install with a working sh → sh-based wrappers. if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { t.Fatalf("first InstallHooks() error = %v", err) }
// sh stops working; reinstall WITHOUT force. shWorks = false if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { t.Fatalf("second InstallHooks() error = %v", err) }
hooksFile := readHooksFile(t, tempDir) // Exactly one entry per type — the stale sh entry must be gone, not duplicated. if len(hooksFile.Hooks.Stop) != 1 { t.Errorf("Stop hooks = %d after wrapper migration, want 1 (no duplicate)", len(hooksFile.Hooks.Stop)) } if len(hooksFile.Hooks.SessionStart) != 1 { t.Errorf("SessionStart hooks = %d after wrapper migration, want 1 (no duplicate)", len(hooksFile.Hooks.SessionStart)) } assertEntryCommand(t, hooksFile.Hooks.Stop, agent.WrapWindowsProductionSilentHookCommand("entire hooks cursor stop"))
// No sh-based Entire wrapper may survive the migration. data, err := os.ReadFile(filepath.Join(tempDir, ".cursor", HooksFileName)) if err != nil { t.Fatalf("failed to read hooks file: %v", err) } if strings.Contains(string(data), "sh -c") || strings.Contains(string(data), "command -v entire") { t.Errorf("stale sh-based wrapper survived migration:\n%s", data) } }
func TestInstallHooks_Idempotent(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir)
Mcmd/entire/cli/agent/cursor/hooks\_test.go+90
30 unmodified lines
31 32 33 34 34 35 36 37 38 39 40 41 42 4 unmodified lines
47 48 49 50 51 52 53 54 55 56 48 57 58 59 60 1 unmodified line
62 63 64 56 65 66 67 68 69 70 62 63 71 72 73 74 75 76 77 78 79 80 68 81 82 83 84
30 unmodified lines
{Name: "/security-review", Desc: "Scan git diff for security issues"},
{Name: "/simplify", Desc: "Review recent changes for code quality"},
},
"codex": {{Name: "/review", Desc: "Review current changes and find issues"}},
// Codex has no binary-bundled review command usable from codex exec:
// built-in slash commands like /review only fire in the interactive TUI,
// not when piped through exec. Codex's review skills (code-reviewer,
// review-swarm, …) live on disk and are surfaced by DiscoverReviewSkills in
// $name form, so there are no curated built-ins to hardcode here.
"codex": {},
"gemini": {},
}
4 unmodified lines
// Install commands below are placeholders until marketplace URLs are pinned.
// Tests do not assert on Message text — only on ProvidesAny semantics — so
// prose revisions do not break the suite.
//
// Messages must stay backtick-free: the picker renders them through huh, which
// treats the text as markdown and mangles backtick-wrapped code spans in the
// terminal. Use plain text / colons to set off commands instead.
var installHints = map[string][]InstallHint{
"claude-code": {
{
Message: "Install pr-review-toolkit via claude plugin install entireio/pr-review-toolkit",
Message: "Install pr-review-toolkit: claude plugin install entireio/pr-review-toolkit",
ProvidesAny: []string{
"/pr-review-toolkit:review-pr",
"/pr-review-toolkit:code-reviewer",
1 unmodified line
},
},
{
Message: "Install test-auditor via the superpowers plugin",
Message: "Install test-auditor via the superpowers plugin",
ProvidesAny: []string{"/test-auditor"},
},
},
"codex": {
{
Message: "Install codex-review-pack via codex plugins add <url>",
ProvidesAny: []string{"/codex:adversarial-review"},
Message: "Install codex-review-pack: codex plugins add gemini-code-review via gemini extensions install <url>",
Message: "Install gemini-code-review: gemini extensions install
Mcmd/entire/cli/agent/skilldiscovery/registry.go+19/-6
11 unmodified lines
12 13 14 15 16 17 16 17 18 19 20 21 22 49 unmodified lines
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
11 unmodified lines
if len(claude) != 3 {
t.Fatalf("claude-code built-ins: got %d entries, want 3", len(claude))
}
// Codex has no binary-bundled review command usable from codex exec;
// its review skills are discovered on disk in $name form instead.
codex := skilldiscovery.CuratedBuiltinsFor("codex")
if len(codex) != 1 || codex[0].Name != "/review" {
t.Errorf("codex built-ins: got %+v, want 1x /review", codex)
if len(codex) != 0 {
t.Errorf("codex built-ins: got %+v, want 0 (discovery-driven)", codex)
}
gemini := skilldiscovery.CuratedBuiltinsFor("gemini")
if len(gemini) != 0 {
49 unmodified lines
t.Error("unknown agent should not be eligible") } }
// TestActiveInstallHintsFor_CodexFingerprintMatchesDollarFormDiscovery pins
// the suppression fingerprint to the invocation form codex discovery actually
// produces: DiscoverReviewSkills emits $plugin:name, so a slash-form
// ProvidesAny entry could never intersect the discovered set and the hint
// would show forever even with the plugin installed.
func TestActiveInstallHintsFor_CodexFingerprintMatchesDollarFormDiscovery(t *testing.T) {
t.Parallel()
discovered := map[string]struct{}{"$codex:adversarial-review": {}}
if hints := skilldiscovery.ActiveInstallHintsFor("codex", discovered); len(hints) != 0 {
t.Fatalf("codex hint not suppressed by $-form discovery; got %d hints: %+v", len(hints), hints)
}
}
Mcmd/entire/cli/agent/skilldiscovery/registry\_test.go+17/-2
package skilldiscovery
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"golang.org/x/mod/semver"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/logging"
)
// InvocationForm builds an agent's invocation string for a discovered skill.
// The only thing that differs between agents is the prefix and namespace
// joiner: Claude Code uses slash form (`/name`, `/plugin:name`), codex uses
// dollar form (`$name`, `$plugin:name`) — the literal token a user types to
// invoke the skill in that CLI. Discovery emits Name already in this form so
// downstream prompt composition stays agent-agnostic and joins verbatim.
type InvocationForm func(name, pluginName string) string
// SlashForm is Claude Code's invocation syntax: "/name" or "/plugin:name".
func SlashForm(name, pluginName string) string {
if pluginName == "" {
return "/" + name
}
return "/" + pluginName + ":" + name
}
// DollarForm is codex's invocation syntax: "$name" or "$plugin:name". This is
// the explicit "use this skill" token from codex's own injected skills
// catalog ("name a skill with $SkillName or plain text").
func DollarForm(name, pluginName string) string {
if pluginName == "" {
return "$" + name
}
return "$" + pluginName + ":" + name
}
// DedupeByInvocation collapses entries sharing an invocation name, keeping the
// first occurrence. Plugins can ship a skill and a same-named wrapper that
// forwards to it; scan order decides which wins.
func DedupeByInvocation(in []agent.DiscoveredSkill) []agent.DiscoveredSkill {
if len(in) < 2 {
return in
}
seen := make(map[string]struct{}, len(in))
out := make([]agent.DiscoveredSkill, 0, len(in))
for _, s := range in {
if _, dup := seen[s.Name]; dup {
continue
}
seen[s.Name] = struct{}{}
out = append(out, s)
}
return out
}
// ScanPluginCache walks <root>/<marketplace>/<plugin>/<version>/ and invokes
// scanVersion once per plugin, for the single version directory chosen by
// PickLatestVersion. The callback receives the chosen version root and the
// plugin name (used as the invocation namespace). Both Claude Code and codex
// use this same market/plugin/version cache layout; they differ only in which
// subdirectories under the version root they scan and their invocation form.
func ScanPluginCache(ctx context.Context, root string, scanVersion func(versionRoot, pluginName string) []agent.DiscoveredSkill) []agent.DiscoveredSkill {
entries, err := os.ReadDir(root)
if err != nil {
logging.Debug(ctx, "skill discovery: plugin cache unreadable",
slog.String("root", root), slog.String("error", err.Error()))
return nil
}
var found []agent.DiscoveredSkill
for _, marketEntry := range entries {
if !marketEntry.IsDir() {
continue
}
marketRoot := filepath.Join(root, marketEntry.Name())
pluginEntries, err := os.ReadDir(marketRoot)
if err != nil {
continue
}
for _, pluginEntry := range pluginEntries {
if !pluginEntry.IsDir() {
continue
}
pluginName := pluginEntry.Name()
pluginRoot := filepath.Join(marketRoot, pluginName)
versionEntries, err := os.ReadDir(pluginRoot)
if err != nil {
continue
}
versionDir, ok := PickLatestVersion(versionEntries)
if !ok {
continue
}
found = append(found, scanVersion(filepath.Join(pluginRoot, versionDir), pluginName)...)
}
}
return found
}
// PickLatestVersion returns the "newest" version directory name among entries:
//
// - If any entry parses as semver (with or without a leading "v"), pick the
// highest semver; non-semver entries are ignored when a semver exists.
// - Otherwise fall back to the lexicographic max of all directory names.
// This handles the "unknown" sentinel some plugins ship, and the opaque
// content-hash version dirs codex plugins use (e.g. "fef63ecf").
//
// Returns ("", false) if no usable directory entry exists.
func PickLatestVersion(entries []os.DirEntry) (string, bool) {
var dirs []string
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, e.Name())
}
}
if len(dirs) == 0 {
return "", false
}
var semverDirs []string
for _, d := range dirs {
if semver.IsValid(semverWithV(d)) {
semverDirs = append(semverDirs, d)
}
}
if len(semverDirs) > 0 {
sort.Slice(semverDirs, func(i, j int) bool {
return semver.Compare(semverWithV(semverDirs[i]), semverWithV(semverDirs[j])) > 0
})
return semverDirs[0], true
}
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
return dirs[0], true
}
// semverWithV ensures a version string has the "v" prefix golang.org/x/mod/semver
// requires. Plugin version dirs are usually bare (e.g. "0.1.0").
func semverWithV(s string) string {
if strings.HasPrefix(s, "v") {
return s
}
return "v" + s
}
// ScanSkillsDir reads each <dir>/<name>/SKILL.md, parses its frontmatter, and
// emits a DiscoveredSkill (in invoke's form) when Matches() returns true.
// pluginName is the namespace ("" for un-namespaced user skills). Missing dirs
// yield nil — discovery is best-effort.
func ScanSkillsDir(ctx context.Context, dir, pluginName string, invoke InvocationForm) []agent.DiscoveredSkill {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var found []agent.DiscoveredSkill
for _, skillEntry := range entries {
if !skillEntry.IsDir() {
continue
}
skillFile := filepath.Join(dir, skillEntry.Name(), "SKILL.md")
data, err := os.ReadFile(skillFile) //nolint:gosec // G304: skillFile is built from a ReadDir walk under HOME, not user input
if err != nil {
continue
}
name, description, parseErr := ParseSkillFrontmatter(data)
if parseErr != nil {
logging.Debug(ctx, "skill discovery: skipping malformed SKILL.md",
slog.String("path", skillFile), slog.String("error", parseErr.Error()))
continue
}
if name == "" {
name = skillEntry.Name()
}
invocation := invoke(name, pluginName)
if !Matches(invocation, description) {
continue
}
found = append(found, agent.DiscoveredSkill{
Name: invocation,
Description: description,
SourcePath: skillFile,
})
}
return found
}
// ScanFlatMarkdownDir reads *.md files directly under dir (no nesting), parses
// their frontmatter for `description:`, and derives the invocation name from
// the filename (minus .md). Used by Claude Code for plugin/user commands and
// agents, whose frontmatter has no `name:` field. README.md is skipped.
func ScanFlatMarkdownDir(ctx context.Context, dir, pluginName string, invoke InvocationForm) []agent.DiscoveredSkill {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var found []agent.DiscoveredSkill
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
baseName := strings.TrimSuffix(entry.Name(), ".md")
if strings.EqualFold(baseName, "README") {
continue
}
filePath := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(filePath) //nolint:gosec // G304: filePath is built from a ReadDir walk under HOME, not user input
if err != nil {
continue
}
_, description, parseErr := ParseSkillFrontmatter(data)
if parseErr != nil {
logging.Debug(ctx, "skill discovery: skipping malformed command/agent",
slog.String("path", filePath), slog.String("error", parseErr.Error()))
continue
}
invocation := invoke(baseName, pluginName)
if !Matches(invocation, description) {
continue
}
found = append(found, agent.DiscoveredSkill{
Name: invocation,
Description: description,
SourcePath: filePath,
})
}
return found
}
// ParseSkillFrontmatter extracts `name:` and `description:` from a minimal YAML
// frontmatter block — the tiny subset these SKILL.md / command / agent files
// use. Surrounding double-quotes are trimmed so `description: "foo"` returns
// `foo`.
func ParseSkillFrontmatter(data []byte) (name, description string, err error) {
s := string(data)
if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") {
return "", "", errors.New("no frontmatter delimiter")
}
body := strings.TrimPrefix(strings.TrimPrefix(s, "---\r\n"), "---\n")
end := strings.Index(body, "\n---")
if end < 0 {
return "", "", errors.New("no closing frontmatter delimiter")
}
for _, line := range strings.Split(body[:end], "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "name:"):
name = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "name:")), `"`)
case strings.HasPrefix(line, "description:"):
description = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "description:")), `"`)
}
}
return name, description, nil
}
Acmd/entire/cli/agent/skilldiscovery/scan.go+257
16 unmodified lines
17
18
19
20
21
22
23
239 unmodified lines
263
264
265
265
266
267
268
269
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
274
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
25 unmodified lines
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
307
308
309
310
311
364
365
366
367
368
313
369
370
315
371
372
373
319
374
375
376
377
323
378
379
325
326
327
328
329
330
331
380
381
382
383
16 unmodified lines
"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/internal/entireclient/clusterdiscovery"
"github.com/entireio/cli/internal/entireclient/contexts"
"github.com/entireio/cli/internal/entireclient/httputil"
"github.com/entireio/cli/internal/entireclient/userdirs"
)
239 unmodified lines
httpClient *http.Client
}
// resolveCellSubject picks the jurisdiction-exchange subject: ENTIRE_TOKEN when
// set (exclusive, fail-closed), otherwise the active stored login context. This
// is the ENTIRE_TOKEN-aware dispatcher used by JurisdictionToken;
// NewEntireAPICellClient calls resolveStoredCellSubject directly so its behavior
// is unchanged.
// resolveCellSubject picks the jurisdiction-exchange subject for
// JurisdictionToken (the `entire auth token --jurisdiction` scripting helper):
// ENTIRE_TOKEN when set (exclusive, fail-closed), otherwise the ACTIVE stored
// login context.
//
// It deliberately uses the active context — the same login `entire auth token`
// (no flag) prints a bearer for — rather than resolveStoredCellSubject's
// data-host discovery. `--jurisdiction` mints a token for the caller's SELECTED
// environment, so with (say) a partial.to context active it must mint a
// partial.to token even though the data host defaults to entire.io. Discovery
// keys off api.BaseURL() and would pick whichever context that host trusts,
// silently ignoring the selection. NewEntireAPICellClient is a different case —
// it dials the data plane — so it keeps calling resolveStoredCellSubject.
func resolveCellSubject(ctx context.Context, insecureHTTP bool) (cellSubject, error) {
if raw, ok := os.LookupEnv(EnvTokenVar); ok {
return resolveEnvTokenCellSubject(raw, insecureHTTP)
}
return resolveStoredCellSubject(ctx, insecureHTTP)
return resolveActiveContextCellSubject(ctx, insecureHTTP)
}
// resolveActiveContextCellSubject builds the exchange subject from the active
// stored login context: it refreshes that context's login JWT and uses the
// context's own core as both the environment signal (dataOrigin) and the
// exchange target. See resolveCellSubject for why `--jurisdiction` follows the
// active context instead of discovering one against the data host.
func resolveActiveContextCellSubject(ctx context.Context, insecureHTTP bool) (cellSubject, error) {
if insecureHTTP {
EnableInsecureHTTP()
}
c, ok, err := activeContext()
if err != nil {
return cellSubject{}, err
}
if !ok {
return cellSubject{}, fmt.Errorf("not logged in (run 'entire login' first): %w", ErrNotLoggedIn)
}
loginJWT, err := refreshCellLoginJWT(ctx, c)
if err != nil {
return cellSubject{}, err
}
origin := api.OriginOnly(c.CoreURL)
return cellSubject{
loginJWT: loginJWT,
discoveredCore: origin,
dataOrigin: origin,
httpClient: cellExchangeHTTPClient(origin),
}, nil
}
// resolveStoredCellSubject resolves the exchange subject from the active stored
25 unmodified lines
return cellSubject{}, err
}
loginJWT, err := refreshCellLoginJWT(ctx, selected)
if err != nil {
return cellSubject{}, err
}
return cellSubject{
loginJWT: loginJWT,
discoveredCore: selected.CoreURL,
dataOrigin: dataOrigin,
httpClient: httpClient,
}, nil
}
// refreshCellLoginJWT returns c's login JWT, transparently re-minting it from the
// stored refresh token. Shared by the active-context and discovered-context cell
// subject resolvers, which differ only in how they pick c.
func refreshCellLoginJWT(ctx context.Context, c *contexts.Context) (string, error) {
// Gate the login provider's HTTPS relaxation on the core it actually dials
// (selected.CoreURL) plus the explicit --insecure-http-auth opt-in, matching
// the sibling ResolveDataAPIToken. A loopback data API must not relax HTTPS
// for a non-loopback core.
allowInsecure := insecureHTTPEnabled() || isLoopbackHTTP(selected.CoreURL)
loginProvider, err := NewRefreshingLoginProvider(selected, cellExchangeTransportForTest, allowInsecure)
// plus the explicit --insecure-http-auth opt-in: a loopback core must not
// relax HTTPS for a non-loopback one.
allowInsecure := insecureHTTPEnabled() || isLoopbackHTTP(c.CoreURL)
loginProvider, err := NewRefreshingLoginProvider(c, cellExchangeTransportForTest, allowInsecure)
if err != nil {
return cellSubject{}, err
return "", err
}
loginJWT, err := loginProvider(ctx)
if err != nil {
if errors.Is(err, ErrNotLoggedIn) {
return cellSubject{}, fmt.Errorf("not logged in (run 'entire login' first): %w", err)
return "", fmt.Errorf("not logged in (run 'entire login' first): %w", err)
}
// The provider already prefixes "refresh login token:"; return as-is to
// avoid a doubled prefix.
return cellSubject{}, err
return "", err
}
return cellSubject{
loginJWT: loginJWT,
discoveredCore: selected.CoreURL,
dataOrigin: dataOrigin,
httpClient: httpClient,
}, nil
return loginJWT, nil
}
// resolveEnvTokenCellSubject builds the exchange subject from ENTIRE_TOKEN: the
Mcmd/entire/cli/auth/cell_data_api.go+71/-22
422 unmodified lines
423
424
425
426
427
428
429
426
427
428
429
430
431
432
431
432
433
434
435
436
437
438
439
440
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
441
442
443
444
445
446
457
447
448
459
460
461
462
449
450
451
452
453
2 unmodified lines
456
457
458
471
472
459
460
461
474
475
462
463
464
477
478
465
466
467
480
481
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
422 unmodified lines
}
}
// TestJurisdictionToken_StoredContext exercises the exported token-only path off
// a stored login context: it must return the exchanged identity token and mint
// it with scope=openid, the jurisdiction audience, and the login JWT as
// subject_token. Not parallel: manipulates env + token store.
// TestJurisdictionToken_StoredContext proves the stored path mints from the
// ACTIVE login context (like plain `entire auth token`), deriving the
// environment from that context's core rather than the data host. No
// ENTIRE_API_BASE_URL is set, so the default (entire.io) data host must NOT
// influence the result — only the active context does. Not parallel: manipulates
// env + token store.
func TestJurisdictionToken_StoredContext(t *testing.T) {
t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir())
t.Setenv("ENTIRE_API_BASE_URL", "https://entire.io")
configDir := t.TempDir()
t.Setenv("ENTIRE_CONFIG_DIR", configDir)
t.Setenv("ENTIRE_API_BASE_URL", "")
t.Setenv("ENTIRE_API_AUDIENCE_TEMPLATE", "")
t.Setenv("ENTIRE_CORE_BASE_URL_TEMPLATE", "")
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
t.Cleanup(restore)
var gotAudience, gotScope, gotSubject, gotGrant string
coreSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != oauthTokenPath {
http.NotFound(w, r)
return
}
_ = r.ParseForm() //nolint:errcheck // test handler
gotAudience = r.FormValue("audience")
gotScope = r.FormValue("scope")
gotSubject = r.FormValue("subject_token")
gotGrant = r.FormValue("grant_type")
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"access_token":"cell-identity-token","token_type":"Bearer","expires_in":3600}`)
}))
defer coreSrv.Close()
svc := tokenstore.CoreKeyringService(coreSrv.URL)
loginJWT := makeJWT(t, fmt.Sprintf(`{"iss":%q,"home_jurisdiction":"us","exp":%d}`, coreSrv.URL, time.Now().Add(2*time.Hour).Unix()))
const core = "https://us.auth.entire.io"
svc := tokenstore.CoreKeyringService(core)
loginJWT := makeJWT(t, fmt.Sprintf(`{"iss":%q,"home_jurisdiction":"us","exp":%d}`, core, time.Now().Add(2*time.Hour).Unix()))
if err := tokenstore.Set(svc, "me", tokenstore.EncodeTokenWithExpiration(loginJWT, 7200)); err != nil {
t.Fatalf("seed token: %v", err)
}
ctxObj := &contexts.Context{Name: "me@core", CoreURL: coreSrv.URL, Handle: "me", KeychainService: svc}
writeActiveContext(t, configDir, "me@entire", core, "me", svc)
t.Cleanup(SetResolveContextForCellAPIForTest(t, func(context.Context, string, string, string, *http.Client, clusterdiscovery.DebugFunc) (*contexts.Context, error) {
return ctxObj, nil
}))
t.Cleanup(SetCellExchangeTransportForTest(t, coreSrv.Client().Transport))
ct := &captureTransport{token: "cell-identity-token"}
t.Cleanup(SetCellExchangeTransportForTest(t, ct))
token, err := JurisdictionToken(context.Background(), false, "us")
if err != nil {
2 unmodified lines
if token != "cell-identity-token" {
t.Fatalf("token = %q, want cell-identity-token", token)
}
if gotAudience != usEntireAudience {
t.Errorf("audience = %q, want https://us.entire.io", gotAudience)
if got := ct.form.Get("audience"); got != usEntireAudience {
t.Errorf("audience = %q, want %s", got, usEntireAudience)
}
if gotScope != JurisdictionIdentityScope {
t.Errorf("scope = %q, want %q", gotScope, JurisdictionIdentityScope)
if got := ct.form.Get("scope"); got != JurisdictionIdentityScope {
t.Errorf("scope = %q, want %q", got, JurisdictionIdentityScope)
}
if gotSubject != loginJWT {
t.Errorf("subject_token = %q, want the login JWT", gotSubject)
if got := ct.form.Get("subject_token"); got != loginJWT {
t.Errorf("subject_token = %q, want the login JWT", got)
}
if gotGrant != "urn:ietf:params:oauth:grant-type:token-exchange" {
t.Errorf("grant_type = %q, want token-exchange", gotGrant)
if got := ct.form.Get("grant_type"); got != "urn:ietf:params:oauth:grant-type:token-exchange" {
t.Errorf("grant_type = %q, want token-exchange", got)
}
}
// TestJurisdictionToken_StoredContextFollowsActiveContext is the regression for
// the reported bug: with two contexts (prod entire.io + staging partial.to) and
// partial.to ACTIVE, `auth token --jurisdiction us` must mint a partial.to token
// — not switch to entire.io because the default data host trusts the prod
// context. The exchange audience/subject/core all follow the active partial.to
// context.
func TestJurisdictionToken_StoredContextFollowsActiveContext(t *testing.T) {
configDir := t.TempDir()
t.Setenv("ENTIRE_CONFIG_DIR", configDir)
t.Setenv("ENTIRE_API_BASE_URL", "") // default entire.io data host must not win
t.Setenv("ENTIRE_API_AUDIENCE_TEMPLATE", "")
t.Setenv("ENTIRE_CORE_BASE_URL_TEMPLATE", "")
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
t.Cleanup(restore)
const prodCore = "https://us.auth.entire.io"
const stagingCore = "https://us.auth.partial.to"
prodSvc := tokenstore.CoreKeyringService(prodCore)
stagingSvc := tokenstore.CoreKeyringService(stagingCore)
prodJWT := makeJWT(t, fmt.Sprintf(`{"iss":%q,"home_jurisdiction":"us","exp":%d}`, prodCore, time.Now().Add(2*time.Hour).Unix()))
stagingJWT := makeJWT(t, fmt.Sprintf(`{"iss":%q,"home_jurisdiction":"us","exp":%d}`, stagingCore, time.Now().Add(2*time.Hour).Unix()))
for _, s := range []struct{ svc, jwt string }{{prodSvc, prodJWT}, {stagingSvc, stagingJWT}} {
if err := tokenstore.Set(s.svc, "me", tokenstore.EncodeTokenWithExpiration(s.jwt, 7200)); err != nil {
t.Fatalf("seed token: %v", err)
}
}
prodCtx := &contexts.Context{Name: "me@entire", CoreURL: prodCore, Handle: "me", KeychainService: prodSvc}
stagingCtx := &contexts.Context{Name: "me@partial", CoreURL: stagingCore, Handle: "me", KeychainService: stagingSvc}
// partial.to is the ACTIVE context.
if err := contexts.Save(configDir, &contexts.File{CurrentContext: stagingCtx.Name, Contexts: []*contexts.Context{prodCtx, stagingCtx}}); err != nil {
t.Fatalf("save contexts: %v", err)
}
ct := &captureTransport{token: "partial-identity-token"}
t.Cleanup(SetCellExchangeTransportForTest(t, ct))
token, err := JurisdictionToken(context.Background(), false, "us")
if err != nil {
t.Fatalf("JurisdictionToken: %v", err)
}
if token != "partial-identity-token" {
t.Fatalf("token = %q, want partial-identity-token", token)
}
if got := ct.form.Get("audience"); got != "https://us.partial.to" {
t.Errorf("audience = %q, want https://us.partial.to (active partial.to context, not entire.io)", got)
}
if got := ct.form.Get("subject_token"); got != stagingJWT {
t.Errorf("subject_token = %q, want the partial.to login JWT", got)
}
if got := ct.url; got != stagingCore+oauthTokenPath {
t.Errorf("exchange URL = %q, want %s%s", got, stagingCore, oauthTokenPath)
}
}
Mcmd/entire/cli/auth/cell_data_api_test.go+79/-37
2465 unmodified lines
2466
2467
2468
2469
2469
2470
2471
2472
2465 unmodified lines
}
defer r.Close()
sig, err := signer.Sign(r)
sig, err := signer.Sign(ctx, r)
if err != nil {
logging.Warn(ctx, "failed to sign commit", slog.String("error", err.Error()))
return
Mcmd/entire/cli/checkpoint/persistent.go+1/-1
20 unmodified lines
21
22
23
24
24
25
26
27
20 unmodified lines
err error
}
func (s *stubSigner) Sign(_ io.Reader) ([]byte, error) {
func (s *stubSigner) Sign(_ context.Context, _ io.Reader) ([]byte, error) {
return s.sig, s.err
}
Mcmd/entire/cli/checkpoint/persistent_signing_test.go+1/-1
3 unmodified lines
4
5
6
7
8
9
10
1 unmodified line
12
13
14
15
16
17
18
59 unmodified lines
78
79
80
79
81
82
83
84
85
8 unmodified lines
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
3 unmodified lines
"context"
"encoding/base64"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
1 unmodified line
"strings"
"sync"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/settings"
)
59 unmodified lines
case opts.Unshallow && isShallowRepository(ctx, opts.Dir):
args = append(args, "--unshallow")
}
if !opts.NoFilter && settings.IsFilteredFetchesEnabled(ctx) {
filtered := !opts.NoFilter && settings.IsFilteredFetchesEnabled(ctx)
if filtered {
args = append(args, "--filter=blob:none")
}
args = append(args, opts.Remote)
8 unmodified lines
if err != nil {
return out, fmt.Errorf("git fetch: %w", err)
}
if filtered && IsURL(opts.Remote) {
// Stamp the URL git actually fetched from: with a checkpoint token set,
// newCommand rewrites SSH targets to HTTPS, and git records the
// promisor entry under the rewritten URL.
target := opts.Remote
if token := strings.TrimSpace(os.Getenv(CheckpointTokenEnvVar)); token != "" && isValidToken(token) {
target, _ = resolveTargetForTokenAuth(ctx, target)
}
markPromisorEntrySkipped(ctx, opts.Dir, target)
}
return out, nil
}
// markPromisorEntrySkipped excludes the URL-keyed config section that git
// creates for a filtered URL fetch (remote.<url>.promisor=true) from
// `git fetch --all` and `git remote update`. Git needs the promisor entry to
// lazy-fetch filtered-out objects later, but the entry also makes the URL show
// up as a fetchable remote, so without this every checkpoint URL ever fetched
// from lingers as a phantom remote that bulk fetches keep dialing.
// Best-effort: the fetch already succeeded, so failures only log.
func markPromisorEntrySkipped(ctx context.Context, dir, url string) {
if !gitConfigBool(ctx, dir, "remote."+url+".promisor") {
// Git didn't record a promisor entry for this URL; don't invent a
// config section that wouldn't otherwise exist.
return
}
for _, key := range []string{"skipFetchAll", "skipDefaultUpdate"} {
fullKey := "remote." + url + "." + key
if gitConfigBool(ctx, dir, fullKey) {
// Checked per key so a partially-stamped entry (e.g. an earlier
// run failing between the two writes) still gets completed.
continue
}
cmd := exec.CommandContext(ctx, "git", "config", "--local", fullKey, "true")
if dir != "" {
cmd.Dir = dir
}
if out, cfgErr := cmd.CombinedOutput(); cfgErr != nil {
redactedURL := RedactURL(url)
// The output can echo the key, which embeds the URL — and a URL
// can carry credentials. Redact before logging.
msg := strings.TrimSpace(strings.ReplaceAll(string(out), url, redactedURL))
logging.Warn(ctx, "failed to mark promisor config entry as skipped for bulk fetches",
slog.String("url", redactedURL),
slog.String("key", key),
slog.String("output", msg),
slog.String("error", cfgErr.Error()),
)
return
}
}
}
// gitConfigBool reads a local git config key and reports whether it is set to
// a true value. Missing keys and read errors report false.
func gitConfigBool(ctx context.Context, dir, key string) bool {
cmd := exec.CommandContext(ctx, "git", "config", "--local", "--get", "--type=bool", key)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "true"
}
// FetchBlobs fetches specific objects (typically blobs) by hash from a remote.
// Uses `git fetch-pack` rather than `git fetch` because the high-level
// porcelain enforces partial-clone integrity checks that reject blob-only
Mcmd/entire/cli/checkpoint/remote/git.go+68/-1
790 unmodified lines
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
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
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
790 unmodified lines
}
return m
}
// TestFetch_FilteredURLFetchMarksPromisorSkipped verifies that after a
// filtered fetch from a URL, the URL-keyed promisor config section git creates
// is excluded from `git fetch --all` / `git remote update` — otherwise every
// checkpoint URL ever fetched from lingers as a phantom remote that bulk
// fetches keep dialing.
func TestFetch_FilteredURLFetchMarksPromisorSkipped(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
originBare := filepath.Join(tmpDir, "origin.git")
checkpointBare := filepath.Join(tmpDir, "checkpoints.git")
seedDir := filepath.Join(tmpDir, "seed")
cloneDir := filepath.Join(tmpDir, "clone")
testutil.InitRepo(t, seedDir)
testutil.WriteFile(t, seedDir, "f.txt", "init")
testutil.GitAdd(t, seedDir, "f.txt")
testutil.GitCommit(t, seedDir, "init")
// Separate origin and checkpoint repos, mirroring the real setup where
// checkpoints are fetched by URL from a repo that is not origin.
runIsolatedGit(ctx, t, "", "init", "--bare", originBare)
runIsolatedGit(ctx, t, "", "init", "--bare", checkpointBare)
runIsolatedGit(ctx, t, checkpointBare, "config", "uploadpack.allowFilter", "true")
runIsolatedGit(ctx, t, seedDir, "push", originBare, "HEAD:refs/heads/main")
runIsolatedGit(ctx, t, "", "clone", "--branch", "main", "file://"+originBare, cloneDir)
// A commit only in the checkpoint repo so the filtered fetch has
// something to transfer.
testutil.WriteFile(t, seedDir, "f.txt", "init\nnext\n")
testutil.GitAdd(t, seedDir, "f.txt")
testutil.GitCommit(t, seedDir, "next")
runIsolatedGit(ctx, t, seedDir, "push", checkpointBare, "HEAD:refs/heads/main")
// Filtered fetches read .entire settings from the CWD repo.
testutil.WriteFile(
t,
cloneDir,
".entire/settings.json",
`{"enabled": true, "strategy_options": {"filtered_fetches": true}}`,
)
t.Chdir(cloneDir)
fetchURL := "file://" + checkpointBare
out, err := Fetch(ctx, FetchOptions{
Remote: fetchURL,
RefSpecs: []string{"+refs/heads/main:refs/entire-fetch-tmp/main"},
NoTags: true,
Dir: cloneDir,
})
require.NoError(t, err, "fetch output: %s", out)
// Sanity: git recorded the URL-keyed promisor entry for the filtered fetch.
require.True(t, gitConfigBool(ctx, cloneDir, "remote."+fetchURL+".promisor"),
"expected git to record a promisor entry for the filtered URL fetch")
assert.True(t, gitConfigBool(ctx, cloneDir, "remote."+fetchURL+".skipFetchAll"),
"URL-keyed promisor entry should be excluded from git fetch --all")
assert.True(t, gitConfigBool(ctx, cloneDir, "remote."+fetchURL+".skipDefaultUpdate"),
"URL-keyed promisor entry should be excluded from git remote update")
// git fetch --all must no longer dial the phantom entry: with the
// checkpoint repo gone, --all only succeeds if the URL-keyed entry is
// skipped (origin is still reachable).
require.NoError(t, os.RemoveAll(checkpointBare))
runIsolatedGit(ctx, t, cloneDir, "fetch", "--all", "--no-auto-gc")
}
// TestFetch_UnfilteredFetchDoesNotCreateConfigSection verifies the stamp is
// gated on git having created a promisor entry: a plain (unfiltered) URL fetch
// must not invent a remote.<url> config section.
func TestFetch_UnfilteredFetchDoesNotCreateConfigSection(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
bareDir := filepath.Join(tmpDir, "bare.git")
seedDir := filepath.Join(tmpDir, "seed")
cloneDir := filepath.Join(tmpDir, "clone")
testutil.InitRepo(t, seedDir)
testutil.WriteFile(t, seedDir, "f.txt", "init")
testutil.GitAdd(t, seedDir, "f.txt")
testutil.GitCommit(t, seedDir, "init")
runIsolatedGit(ctx, t, "", "init", "--bare", bareDir)
runIsolatedGit(ctx, t, seedDir, "remote", "add", "origin", bareDir)
runIsolatedGit(ctx, t, seedDir, "push", "origin", "HEAD:refs/heads/main")
runIsolatedGit(ctx, t, "", "clone", "--branch", "main", "file://"+bareDir, cloneDir)
testutil.WriteFile(
t,
cloneDir,
".entire/settings.json",
`{"enabled": true, "strategy_options": {"filtered_fetches": true}}`,
)
t.Chdir(cloneDir)
fetchURL := "file://" + bareDir
out, err := Fetch(ctx, FetchOptions{
Remote: fetchURL,
RefSpecs: []string{"+refs/heads/main:refs/remotes/origin/main"},
NoTags: true,
NoFilter: true,
Dir: cloneDir,
})
require.NoError(t, err, "fetch output: %s", out)
assert.False(t, gitConfigBool(ctx, cloneDir, "remote."+fetchURL+".promisor"))
assert.False(t, gitConfigBool(ctx, cloneDir, "remote."+fetchURL+".skipFetchAll"))
assert.False(t, gitConfigBool(ctx, cloneDir, "remote."+fetchURL+".skipDefaultUpdate"))
}
// TestMarkPromisorEntrySkipped_CompletesPartialStamp verifies the keys are
// checked independently: an entry with skipFetchAll already set (e.g. an
// earlier run failing between the two writes) still gets skipDefaultUpdate.
func TestMarkPromisorEntrySkipped_CompletesPartialStamp(t *testing.T) {
t.Parallel()
ctx := context.Background()
repoDir := t.TempDir()
testutil.InitRepo(t, repoDir)
const url = "https://example.com/org/checkpoints.git"
runIsolatedGit(ctx, t, repoDir, "config", "--local", "remote."+url+".promisor", "true")
runIsolatedGit(ctx, t, repoDir, "config", "--local", "remote."+url+".skipFetchAll", "true")
markPromisorEntrySkipped(ctx, repoDir, url)
assert.True(t, gitConfigBool(ctx, repoDir, "remote."+url+".skipFetchAll"))
assert.True(t, gitConfigBool(ctx, repoDir, "remote."+url+".skipDefaultUpdate"),
"partially-stamped entry should be completed")
}
Mcmd/entire/cli/checkpoint/remote/git_test.go+133
17 unmodified lines
18
19
20
21
22
21
22
23
24
25
26
86 unmodified lines
113
114
115
115
116
116
117
118
119
120
121
66 unmodified lines
188
189
190
189
190
191
192
193
194
192
193
194
195
196
197
198
199
19 unmodified lines
219
220
221
222
223
224
225
226
227
228
229
230
231
232
222
223
233
234
235
236
237
238
51 unmodified lines
290
291
292
281
282
283
284
293
294
295
296
297
298
299
300
8 unmodified lines
309
310
311
312
313
314
315
316
317
318
319
320
321
322
300
323
324
325
326
89 unmodified lines
416
417
418
396
419
420
421
422
17 unmodified lines
const originRemote = "origin"
const (
ProtocolSSH = gitremote.ProtocolSSH
ProtocolHTTPS = gitremote.ProtocolHTTPS
ProtocolSSH = gitremote.ProtocolSSH
ProtocolHTTPS = gitremote.ProtocolHTTPS
ProtocolEntire = gitremote.ProtocolEntire
)
// Info is an alias for gitremote.Info.
86 unmodified lines
checkpointURL, err := deriveCheckpointURLFromInfo(info, config)
if err != nil {
// Origin's protocol can't be mapped to a git transport (e.g. entire://,
// file://). Honor the configured checkpoint_remote by targeting the
// Origin's protocol can't be mapped to a checkpoint URL (e.g. file://,
// or an entire:// mirror of a different forge than the configured
// provider). Honor the configured checkpoint_remote by targeting the
// provider's canonical host over HTTPS rather than falling back to origin.
if providerURL, ok := resolveProviderCheckpointURL(config, opt.WorktreeRoot); ok {
return providerURL, nil
66 unmodified lines
}
return "", true, fmt.Errorf("no push URL found: %w", err)
}
if strings.TrimSpace(os.Getenv(CheckpointTokenEnvVar)) != "" && isDerivableProtocol(pushInfo.Protocol) {
// Coerce a derivable (ssh/https) remote to HTTPS so the token applies,
withToken := strings.TrimSpace(os.Getenv(CheckpointTokenEnvVar)) != ""
if withToken && isDirectGitTransport(pushInfo.Protocol) {
// Coerce a direct (ssh/https) remote to HTTPS so the token applies,
// keeping the host so enterprise installations stay on their own host.
// A non-derivable protocol (e.g. entire://) carries a host that isn't a
// usable HTTPS host, so it's left untouched and falls through to the
// providerCheckpointURL fallback below.
// An entire:// remote carries a cluster host that isn't a usable HTTPS
// host, so it's handled separately after the owner check below.
//
// Keep the port only when the source was already HTTPS. SSH ports
// (e.g., :2222) don't map to HTTPS ports on the same host.
19 unmodified lines
return fallbackURL, false, nil
}
if withToken && pushInfo.Protocol == ProtocolEntire {
// The checkpoint token is an HTTPS credential for the provider host;
// it can't ride through the entire:// helper (which does its own
// auth). Route to the provider over HTTPS instead of the mirror.
if providerURL, ok := resolveProviderCheckpointURL(config, ""); ok {
return providerURL, true, nil
}
}
pushURL, err := deriveCheckpointURLFromInfo(pushInfo, config)
if err != nil {
// The push remote's protocol can't be mapped to a git transport
// (e.g. entire://, file://). Honor the configured checkpoint_remote by
// The push remote's protocol can't be mapped to a checkpoint URL
// (e.g. file://, or an entire:// mirror of a different forge than the
// configured provider). Honor the configured checkpoint_remote by
// targeting the provider's canonical host over HTTPS rather than
// misrouting checkpoints to the origin remote.
if providerURL, ok := resolveProviderCheckpointURL(config, ""); ok {
51 unmodified lines
return info, nil
}
// isDerivableProtocol reports whether deriveCheckpointURLFromInfo can map the
// protocol to a checkpoint URL (i.e. it's a real git transport, not a remote
// helper scheme like entire:// or a local file://).
func isDerivableProtocol(protocol string) bool {
// isDirectGitTransport reports whether the protocol talks to the git host
// directly over ssh/https (where the host is a usable HTTPS host for token
// auth), as opposed to a remote helper scheme like entire:// or a local
// file://.
func isDirectGitTransport(protocol string) bool {
return protocol == ProtocolSSH || protocol == ProtocolHTTPS
}
8 unmodified lines
return fmt.Sprintf("git@%s:%s.git", info.Host, config.Repo), nil
case ProtocolHTTPS:
return fmt.Sprintf("https://%s/%s.git", info.HostPort(), config.Repo), nil
case ProtocolEntire:
// entire:// push-through mirrors are cluster-scoped: keep the cluster
// host and forge segment, swap in the checkpoint repo. Only derivable
// when the forge maps back to the configured provider's host, so a
// github checkpoint_remote never routes through another forge's mirror.
host, ok := providerHost(config.Provider)
if !ok || !strings.EqualFold(info.CanonicalHost(), host) {
return "", fmt.Errorf("entire:// remote forge %q does not match checkpoint provider %q", info.Forge, config.Provider)
}
return fmt.Sprintf("entire://%s/%s/%s", info.HostPort(), info.Forge, config.Repo), nil
default:
return "", fmt.Errorf("unsupported protocol %q in origin remote", info.Protocol)
return "", fmt.Errorf("unsupported protocol %q in remote URL", info.Protocol)
}
}
89 unmodified lines
if err != nil {
continue
}
if strings.EqualFold(info.Host, host) && isDerivableProtocol(info.Protocol) {
if strings.EqualFold(info.Host, host) && isDirectGitTransport(info.Protocol) {
return info, true
}
}
Mcmd/entire/cli/checkpoint/remote/util.go+40/-17
130 unmodified lines
131
132
133
134
134
135
136
137
138
139
140
141
142
143
144
145
172 unmodified lines
318
319
320
315
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
73 unmodified lines
420
421
422
401
402
403
404
405
406
407
408
423
424
425
426
427
428
429
430
431
432
433
434
435
2 unmodified lines
438
439
440
417
441
442
419
443
444
445
446
423
447
448
425
449
450
451
452
429
430
453
454
455
456
457
434
458
459
460
461
144 unmodified lines
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
130 unmodified lines
wantURL: "git@github.com:acme/checkpoints.git",
},
{
name: "entire:// origin without token routes to provider checkpoint url (ssh default)",
name: "entire:// origin without token derives mirror checkpoint url on same cluster",
originURL: "entire://app.entire.io/gh/acme/app",
settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`,
wantURL: "entire://app.entire.io/gh/acme/checkpoints",
},
{
name: "entire:// origin with forge not matching provider routes to provider checkpoint url (ssh default)",
originURL: "entire://app.entire.io/et/acme/app",
settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`,
wantURL: "git@github.com:acme/checkpoints.git",
},
{
172 unmodified lines
wantEnabled: false,
},
{
name: "entire:// origin routes to provider checkpoint url (ssh default)",
name: "entire:// origin derives mirror checkpoint url on same cluster",
originURL: "entire://app.entire.io/gh/acme/app",
pushRemote: "origin",
settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`,
wantURL: "entire://app.entire.io/gh/acme/checkpoints",
wantEnabled: true,
},
{
name: "entire:// origin with forge not matching provider routes to provider checkpoint url (ssh default)",
originURL: "entire://app.entire.io/et/acme/app",
pushRemote: "origin",
settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`,
wantURL: "git@github.com:acme/checkpoints.git",
wantEnabled: true,
},
{
name: "entire:// origin with different owner disables checkpoint push url",
originURL: "entire://app.entire.io/gh/fork/app",
pushRemote: "origin",
settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`,
wantURL: "entire://app.entire.io/gh/fork/app",
wantEnabled: false,
},
{
name: "file:// origin routes to provider checkpoint url (ssh default)",
originURL: "file:///acme/app",
73 unmodified lines
}
}
// TestPushURL_EntireOriginReusesProviderRemoteScheme reproduces the real-world
// setup: origin migrated to an entire:// URL (forge-prefixed /gh/owner/repo)
// with a github checkpoint_remote. The checkpoint URL must route to github
// rather than fall back to the entire:// origin, reusing the auth/scheme the
// repo had for that endpoint — a token forces HTTPS, then an existing remote
// on the provider host, then defaulting to SSH.
func TestPushURL_EntireOriginReusesProviderRemoteScheme(t *testing.T) {
const entireOrigin = "entire://aws-eu-central-1.entire.io/gh/entireio/cli"
// TestPushURL_EntireOriginDerivesMirrorURL reproduces the real-world setup:
// origin migrated to an entire:// URL (forge-prefixed /gh/owner/repo) with a
// github checkpoint_remote. Checkpoints must follow origin through the
// push-through mirror on the same cluster — even when leftover direct github
// remotes (e.g. URL-named promisor entries from filtered fetches) exist. The
// exception is a checkpoint token, which is an HTTPS credential for the
// provider host and therefore forces direct provider HTTPS.
func TestPushURL_EntireOriginDerivesMirrorURL(t *testing.T) {
const entireOrigin = "entire://aws-ap-southeast-2.entire.io/gh/entireio/cli"
const mirrorCheckpointURL = "entire://aws-ap-southeast-2.entire.io/gh/entireio/cli-checkpoints"
tests := []struct {
name string
githubURL string
2 unmodified lines
wantEnabled bool
}{
{
name: "ssh github remote yields ssh checkpoint url",
name: "existing ssh github remote does not divert checkpoints off the mirror",
githubURL: "git@github.com:entireio/cli.git",
wantURL: "git@github.com:entireio/cli-checkpoints.git",
wantURL: mirrorCheckpointURL,
wantEnabled: true,
},
{
name: "https github remote yields https checkpoint url",
name: "existing https github remote does not divert checkpoints off the mirror",
githubURL: "https://github.com/entireio/cli.git",
wantURL: "https://github.com/entireio/cli-checkpoints.git",
wantURL: mirrorCheckpointURL,
wantEnabled: true,
},
{
name: "no signal defaults to ssh",
wantURL: "git@github.com:entireio/cli-checkpoints.git",
name: "entire origin alone derives mirror checkpoint url",
wantURL: mirrorCheckpointURL,
wantEnabled: true,
},
{
name: "token forces https over existing ssh remote",
name: "token forces https on the provider host",
githubURL: "git@github.com:entireio/cli.git",
token: "ci-token",
wantURL: "https://github.com/entireio/cli-checkpoints.git",
144 unmodified lines
checkpointRepo: "org/checkpoints",
want: "ssh://git@git.example.com:2222/org/checkpoints.git",
},
{
name: "entire push remote keeps cluster and forge",
pushRemoteURL: "entire://aws-ap-southeast-2.entire.io/gh/org/main-repo",
checkpointRepo: "org/checkpoints",
want: "entire://aws-ap-southeast-2.entire.io/gh/org/checkpoints",
},
{
name: "entire push remote with non-standard port",
pushRemoteURL: "entire://cluster.example.com:8443/gh/org/main-repo",
checkpointRepo: "org/checkpoints",
want: "entire://cluster.example.com:8443/gh/org/checkpoints",
},
{
name: "entire push remote with forge not matching provider",
pushRemoteURL: "entire://aws-ap-southeast-2.entire.io/et/org/main-repo",
checkpointRepo: "org/checkpoints",
wantDeriveErr: true,
},
{
name: "invalid push remote",
pushRemoteURL: "not-a-url",
Mcmd/entire/cli/checkpoint/remote/util_test.go+59/-17
312 unmodified lines
313
314
315
316
316
317
318
319
320
321
322
323
324
321
322
323
324
325
326
327
328
329
330
331
332
333
334
312 unmodified lines
var gitConfigGuardRepositoryFormatVersionRE = regexp.MustCompile(`(?m)^([ \t]*)repositoryformatversion = [01]$`)
var gitConfigGuardTransportPromisorRemoteRE = regexp.MustCompile(
`(?m)^\[remote "(?:(?:https?|ssh|file)://|/|[A-Za-z]:[\\/]|[^"\n]+@[^"\n]+:[^"\n]+).+"\]\n(?:[ \t]+promisor = true\n[ \t]+partialclonefilter = blob:none\n?|[ \t]+partialclonefilter = blob:none\n[ \t]+promisor = true\n?)`,
`(?m)^\[remote "(?:(?:https?|ssh|file)://|/|[A-Za-z]:[\\/]|[^"\n]+@[^"\n]+:[^"\n]+).+"\]\n(?:[ \t]+(?:promisor = true|partialclonefilter = blob:none|skipFetchAll = true|skipDefaultUpdate = true)\n?){2,4}`,
)
func normalizeGitConfigForGuard(content string) string {
content = gitConfigGuardRepositoryFormatVersionRE.ReplaceAllString(content, `${1}repositoryformatversion = <normalized>`)
// Deliberately ignore only the full promisor+partialclonefilter pair that
// git writes for transport-keyed remotes during filtered fetches. If git ever
// writes a partial section, the guard should still fail loudly.
content = gitConfigGuardTransportPromisorRemoteRE.ReplaceAllString(content, "")
// Deliberately ignore only the URL-keyed remote sections written during
// filtered fetches: git's promisor+partialclonefilter pair plus the
// skipFetchAll/skipDefaultUpdate stamp the CLI adds so bulk fetches skip
// the entry. A section without the full promisor pair (or with any other
// key) still fails loudly.
content = gitConfigGuardTransportPromisorRemoteRE.ReplaceAllStringFunc(content, func(section string) string {
if strings.Contains(section, "promisor = true") && strings.Contains(section, "partialclonefilter = blob:none") {
return ""
}
return section
})
return content
}
Mcmd/entire/cli/integration_test/testenv.go+12/-5
30 unmodified lines
31
32
33
34
35
36
37
38
39
40
41
42
43
44
12 unmodified lines
57
58
59
52
60
61
62
63
64
65
66
67
68
69
30 unmodified lines
agentPluginBinaryPrefix = "entire-agent-"
)
// selfUpdatePluginName is the plugin that replaces the entire binary on
// disk (`entire upgrade` → entire-upgrade).
const selfUpdatePluginName = "upgrade"
// postPluginVersionCheck is a test seam for the version-check notice that
// fires after a successful plugin run.
var postPluginVersionCheck = versioncheck.CheckAndNotify
// MaybeRunPlugin returns (true, exitCode) when an external command was
// resolved and run. On launch failure (e.g. missing executable bit)
// returns (true, 1) after printing to stderr. On no-match returns
12 unmodified lines
maybeTrackPluginInvocation(ctx, pluginName)
// Stderr, matching the built-in PersistentPostRun: the plugin's own
// stdout may be machine-readable and piped.
versioncheck.CheckAndNotify(ctx, os.Stderr, versioninfo.Version)
//
// Skipped after a self-update: this process still carries the
// pre-upgrade compiled-in version, so the check would see itself as
// outdated and prompt to redo the upgrade that just completed.
if pluginName != selfUpdatePluginName {
postPluginVersionCheck(ctx, os.Stderr, versioninfo.Version)
}
}
return true, exitCode
}
Mcmd/entire/cli/plugin.go+15/-1
2 unmodified lines
3
4
5
6
7
8
9
182 unmodified lines
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
2 unmodified lines
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
182 unmodified lines
}
}
// interceptVersionCheck swaps the post-plugin version-check seam for a
// counter and restores it on cleanup.
func interceptVersionCheck(t *testing.T) *int {
t.Helper()
calls := 0
orig := postPluginVersionCheck
postPluginVersionCheck = func(context.Context, io.Writer, string) { calls++ }
t.Cleanup(func() { postPluginVersionCheck = orig })
return &calls
}
func TestMaybeRunPlugin_VersionCheckAfterSuccess(t *testing.T) { //nolint:paralleltest // mutates PATH and the version-check seam
dir := t.TempDir()
writePluginBinary(t, dir, "entire-pgr", filepath.Join(dir, "args.txt"), 0)
withPathDir(t, dir)
calls := interceptVersionCheck(t)
handled, code := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"pgr"})
if !handled || code != 0 {
t.Fatalf("handled=%v code=%d, want handled=true code=0", handled, code)
}
if *calls != 1 {
t.Errorf("version check calls: got %d, want 1", *calls)
}
}
// After `entire upgrade` replaces the binary on disk, this process still
// carries the pre-upgrade compiled-in version — a post-run version check
// would see itself as outdated and prompt to redo the finished upgrade.
func TestMaybeRunPlugin_NoVersionCheckAfterSelfUpdate(t *testing.T) { //nolint:paralleltest // mutates PATH and the version-check seam
dir := t.TempDir()
writePluginBinary(t, dir, "entire-upgrade", filepath.Join(dir, "args.txt"), 0)
withPathDir(t, dir)
calls := interceptVersionCheck(t)
handled, code := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"upgrade", "--nightly"})
if !handled || code != 0 {
t.Fatalf("handled=%v code=%d, want handled=true code=0", handled, code)
}
if *calls != 0 {
t.Errorf("version check calls: got %d, want 0", *calls)
}
}
func TestMaybeRunPlugin_NoVersionCheckAfterFailure(t *testing.T) { //nolint:paralleltest // mutates PATH and the version-check seam
dir := t.TempDir()
writePluginBinary(t, dir, "entire-pgr", filepath.Join(dir, "args.txt"), 3)
withPathDir(t, dir)
calls := interceptVersionCheck(t)
handled, code := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"pgr"})
if !handled || code != 3 {
t.Fatalf("handled=%v code=%d, want handled=true code=3", handled, code)
}
if *calls != 0 {
t.Errorf("version check calls: got %d, want 0", *calls)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
Mcmd/entire/cli/plugin_test.go+60
1170 unmodified lines
1171
1172
1173
1174
1175
1176
1177
4 unmodified lines
1182
1183
1184
1184
1185
1186
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
15 unmodified lines
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1170 unmodified lines
checkpointContext = deps.ReviewCheckpointContext(ctx, worktreeRoot, scopeBaseRef)
}
reviewers := make([]reviewtypes.AgentReviewer, 0, len(launchableEligible))
var excludedWorkers []string
for _, choice := range launchableEligible {
workerName := choice.Name
agentCfg := profile.Agents[workerName]
4 unmodified lines
return fmt.Errorf("resolve agent %s: %w", agentName, agErr)
}
if err := VerifyConfiguredSkillsInstalled(ctx, ag, agentCfg); err != nil {
cmd.SilenceUsage = true
fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
return deps.NewSilentError(err)
// One worker's stale config must not hold the whole crew
// hostage (e.g. codex's legacy auto-preselected "/review",
// orphaned when its curated builtin was removed). Exclude
// the worker loudly and let the remaining reviewers run;
// the all-excluded case fails below.
excludedWorkers = append(excludedWorkers, workerName)
fmt.Fprintf(cmd.ErrOrStderr(), "skipping reviewer %s: %s\n", workerName, err.Error())
continue
}
}
reviewer := deps.ReviewerFor(agentName)
15 unmodified lines
})
}
if len(reviewers) == 0 {
cmd.SilenceUsage = true
err := fmt.Errorf("no runnable reviewers: every configured worker failed skill validation (%s); run `entire review --edit` to reconfigure",
strings.Join(excludedWorkers, ", "))
fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
return deps.NewSilentError(err)
}
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
Mcmd/entire/cli/review/cmd.go+17/-3
3 unmodified lines
4
5
6
7
8
9
10
11
752 unmodified lines
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
771
788
789
790
791
34 unmodified lines
826
827
828
829
830
831
814
815
832
833
834
835
836
837
820
821
838
839
840
841
842
394 unmodified lines
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
3 unmodified lines
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
752 unmodified lines
func TestDispatchFork_MultiAgentPassesPerAgentConfigs(t *testing.T) {
setupCmdTestRepo(t)
// Codex has no curated built-ins — its skills are discovered on disk in
// $name form, so spawn-time validation needs a real SKILL.md under a
// controlled HOME. (Cannot t.Parallel — t.Setenv; setupCmdTestRepo
// already precludes parallelism via t.Chdir.)
home := t.TempDir()
t.Setenv("HOME", home)
skillDir := filepath.Join(home, ".codex", "skills", "code-review")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
skillMD := "---\nname: code-review\ndescription: Review code changes.\n---\n\nbody\n"
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
t.Fatal(err)
}
if err := seedReviewConfig(context.Background(), map[string]settings.ReviewConfig{
"claude-code": {
Skills: []string{"/review"},
Prompt: "Claude saved prompt.",
},
testCodexAgent: {
Skills: []string{"/review"},
Skills: []string{"$code-review"},
Prompt: "Codex saved prompt.",
},
}); err != nil {
34 unmodified lines
for _, tc := range []struct {
name string
reviewer *captureRunConfigReviewer
wantSkill string
wantPrompt string
}{
{name: "claude-code", reviewer: claudeReviewer, wantPrompt: "Claude saved prompt."},
{name: "codex", reviewer: codexReviewer, wantPrompt: "Codex saved prompt."},
{name: "claude-code", reviewer: claudeReviewer, wantSkill: "/review", wantPrompt: "Claude saved prompt."},
{name: "codex", reviewer: codexReviewer, wantSkill: "$code-review", wantPrompt: "Codex saved prompt."},
} {
if !tc.reviewer.called {
t.Fatalf("%s reviewer was not started", tc.name)
}
if got := tc.reviewer.got.Skills; len(got) != 1 || got[0] != "/review" {
t.Fatalf("%s Skills = %v, want [/review]", tc.name, got)
if got := tc.reviewer.got.Skills; len(got) != 1 || got[0] != tc.wantSkill {
t.Fatalf("%s Skills = %v, want [%s]", tc.name, got, tc.wantSkill)
}
if tc.reviewer.got.AlwaysPrompt != tc.wantPrompt {
t.Fatalf("%s AlwaysPrompt = %q, want %q", tc.name, tc.reviewer.got.AlwaysPrompt, tc.wantPrompt)
394 unmodified lines
t.Fatal("auto synthesis should notify the TUI when the final judge starts/completes")
}
}
// TestDispatchFork_InvalidSkillExcludesWorkerNotWholeCrew pins the blast
// radius of spawn-time skill validation in multi-agent runs: a worker whose
// configured skill no longer validates (e.g. codex's legacy auto-preselected
// "/review", orphaned when the curated builtin was removed) is excluded with
// a loud warning, and the remaining reviewers still run. Aborting the whole
// crew for one stale entry held every other agent hostage to a codex
// reconfigure.
func TestDispatchFork_InvalidSkillExcludesWorkerNotWholeCrew(t *testing.T) {
setupCmdTestRepo(t)
// Controlled empty HOME: codex discovery finds nothing, so its "/review"
// (no longer a curated builtin) fails validation. Cannot t.Parallel —
// t.Setenv (setupCmdTestRepo already precludes it via t.Chdir).
t.Setenv("HOME", t.TempDir())
if err := seedReviewConfig(context.Background(), map[string]settings.ReviewConfig{
testAgentName: {
Skills: []string{"/review"},
},
testCodexAgent: {
Skills: []string{"/review"}, // stale legacy entry
},
}); err != nil {
t.Fatal(err)
}
claudeReviewer := &captureRunConfigReviewer{name: testAgentName}
codexReviewer := &captureRunConfigReviewer{name: testCodexAgent}
deps := review.Deps{
GetAgentsWithHooksInstalled: func(_ context.Context) []types.AgentName {
return []types.AgentName{testAgentName, testCodexAgent}
},
NewSilentError: func(err error) error { return err },
HeadHasReviewCheckpoint: func(_ context.Context) (bool, string) {
return false, ""
},
ReviewerFor: func(agentName string) reviewtypes.AgentReviewer {
switch agentName {
case testAgentName:
return claudeReviewer
case testCodexAgent:
return codexReviewer
default:
return nil
}
},
}
cmd := review.NewCommand(deps)
cmd.SetOut(&bytes.Buffer{})
errBuf := &bytes.Buffer{}
cmd.SetErr(errBuf)
cmd.SetArgs([]string{"general"})
if err := cmd.Execute(); err != nil {
t.Fatalf("run should proceed with the valid reviewer, got error: %v", err)
}
if !claudeReviewer.called {
t.Error("claude-code reviewer was not started — valid worker excluded with the invalid one")
}
if codexReviewer.called {
t.Error("codex reviewer started despite failing skill validation")
}
stderr := errBuf.String()
if !strings.Contains(stderr, "/review") || !strings.Contains(stderr, "skipping") {
t.Errorf("stderr should warn about the excluded worker and its skill; got:\n%s", stderr)
}
}
// TestDispatchFork_AllWorkersInvalidStillFails pins the floor: when skill
// validation excludes every worker, the run fails loudly instead of silently
// reviewing with nobody.
func TestDispatchFork_AllWorkersInvalidStillFails(t *testing.T) {
setupCmdTestRepo(t)
t.Setenv("HOME", t.TempDir())
if err := seedReviewConfig(context.Background(), map[string]settings.ReviewConfig{
testCodexAgent: {Skills: []string{"/review"}},
"gemini": {Skills: []string{"$also-missing"}},
}); err != nil {
t.Fatal(err)
}
deps := review.Deps{
GetAgentsWithHooksInstalled: func(_ context.Context) []types.AgentName {
return []types.AgentName{testCodexAgent, "gemini"}
},
NewSilentError: func(err error) error { return err },
HeadHasReviewCheckpoint: func(_ context.Context) (bool, string) {
return false, ""
},
ReviewerFor: func(agentName string) reviewtypes.AgentReviewer {
return &captureRunConfigReviewer{name: agentName}
},
}
cmd := review.NewCommand(deps)
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"general"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected an error when every worker fails skill validation")
}
}
Mcmd/entire/cli/review/cmd_test.go+128/-5
934 unmodified lines
935
936
937
938
938
939
940
941
3 unmodified lines
945
946
947
948
949
950
951
952
953
954
955
956
957
934 unmodified lines
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)")
cmd.Flags().BoolVar(&opts.AgentHelpSkill, flagAgentHelpSkill, false, "Install the stable Entire agent-help skill (points agents at `entire agent-help`) for selected agent(s)")
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Accept all defaults without prompting (in a non-repo directory: init git, create private GitHub repo, commit; then enable all agents and accept telemetry)")
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Accept all defaults without prompting (in a non-repo directory: init git, create private GitHub repo, commit, and push; then enable all agents and accept telemetry)")
addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth)
// Bootstrap flags for non-git-repo folders.
3 unmodified lines
cmd.Flags().StringVar(&bootstrapOpts.RepoOwner, "repo-owner", "", "GitHub user or organization login for the new repo")
cmd.Flags().StringVar(&bootstrapOpts.RepoVisibility, "repo-visibility", "", "GitHub repository visibility: public, private, or internal")
cmd.Flags().BoolVar(&bootstrapOpts.NoGitHub, "no-github", false, "Initialize local git repo only; skip creating a GitHub remote")
cmd.Flags().BoolVar(&bootstrapOpts.Push, "push", false, "When bootstrapping a new repo, push the initial commit to the created GitHub remote (implies creating the remote; without it the repo is created but not pushed)")
cmd.Flags().StringVar(&bootstrapOpts.InitialCommitMessage, "initial-commit-message", "", "Commit message for the initial commit when bootstrapping a new repo")
cmd.Flags().BoolVar(&bootstrapOpts.SkipInitialCommit, "skip-initial-commit", false, "Don't create the initial commit when bootstrapping a new repo")
cmd.MarkFlagsMutuallyExclusive("init-repo", "no-init-repo")
cmd.MarkFlagsMutuallyExclusive("initial-commit-message", "skip-initial-commit")
cmd.MarkFlagsMutuallyExclusive("push", "no-github")
cmd.MarkFlagsMutuallyExclusive("push", "skip-initial-commit")
// Provide a helpful error when --agent is used without a value
defaultFlagErr := cmd.FlagErrorFunc()
Mcmd/entire/cli/setup.go+4/-1
41 unmodified lines
42
43
44
45
46
45
46
47
48
49
50
51
52
53
54
67 unmodified lines
122
123
124
125
126
127
128
36 unmodified lines
165
166
167
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
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
26 unmodified lines
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
2 unmodified lines
253
254
255
256
257
258
259
22 unmodified lines
282
283
284
258
285
286
287
288
15 unmodified lines
304
305
306
307
308
309
281
310
311
312
313
314
286
315
316
317
318
319
320
321
322
323
324
325
16 unmodified lines
342
343
344
345
346
347
348
349
350
351
352
353
354
312
313
314
355
356
357
358
359
360
361
362
363
364
365
318
366
367
368
369
6 unmodified lines
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
10 unmodified lines
418
419
420
347
348
421
422
423
424
425
426
427
428
429
352
430
431
432
433
508 unmodified lines
942
943
944
867
868
869
945
946
947
948
949
950
951
12 unmodified lines
964
965
966
888
967
968
969
970
41 unmodified lines
// still created, but nothing is pushed.
SkipInitialCommit bool
// Yes accepts all defaults without prompting: init repo, create GitHub
// repo under the user's account (private), default commit message.
// Explicit flags (--no-github, --repo-owner, etc.) take precedence.
// repo under the user's account (private), default commit message, and
// push. Explicit flags (--no-github, --repo-owner, etc.) take precedence.
Yes bool
// Push opts into pushing the initial commit to the created GitHub remote
// without prompting. Pushing is otherwise an explicit, separate opt-in
// (interactive "yes" or --yes). Implies creating the remote.
Push bool
}
// bootstrapRunner executes external commands. Tests override this to avoid
67 unmodified lines
visibility string // public/private/internal, if useGitHub
commit bool // false means the user opted out of the initial commit
message string // resolved initial commit message (empty when !commit)
push bool // false means create the GitHub repo but don't push to it
}
// runGitHubBootstrapInit handles the pre-setup half of "enable on a non-git
36 unmodified lines
paths.ClearWorktreeRootCache()
fmt.Fprintln(w, " ✓ Initialized empty git repository")
// Step 3: decide whether to create a GitHub repo. If gh is missing or the
// user passed --no-github, we skip that branch but still bootstrap the
// local repo.
useGitHub := !opts.NoGitHub
if useGitHub {
if !ghAvailable(ctx, runner) {
fmt.Fprintln(errW, "gh CLI not found. Install it from https://cli.github.com/ and run `gh auth login` to add a GitHub remote.")
fmt.Fprintln(errW, "Continuing with local initialization only.")
useGitHub = false
} else if !ghAuthenticated(ctx, runner) {
fmt.Fprintln(errW, "gh CLI is not authenticated. Run `gh auth login` to add a GitHub remote.")
fmt.Fprintln(errW, "Continuing with local initialization only.")
useGitHub = false
}
}
// Step 3b: ask a simple yes/no before diving into owner/name/visibility
// prompts. Skip the confirm when any gh-specific flag is set (the flag
// implies intent) or when we're non-interactive (keep the documented
// happy path: default to yes).
if useGitHub && !opts.Yes && !ghFlagsProvided(opts) && interactive.CanPromptInteractively() {
confirmed, err := confirmCreateGitHubRepo()
if err != nil {
return nil, err
}
if !confirmed {
useGitHub = false
// Step 3: decide whether to create a GitHub repo. Creating a remote is an
// explicit opt-in: it happens only on an explicit signal (repo flags,
// --push, or --yes) or an interactive "yes". A non-interactive run with no
// such signal stays local-only — we never create a repo on the user's
// behalf. --no-github always wins.
useGitHub := false
if !opts.NoGitHub {
explicit := ghCreateRequested(opts)
// Only probe gh (and warn about a missing/unauthenticated CLI) when the
// user actually wants a GitHub repo — explicitly, or via the confirm
// prompt we're about to show interactively.
if explicit || interactive.CanPromptInteractively() {
switch {
case !ghAvailable(ctx, runner):
fmt.Fprintln(errW, "gh CLI not found. Install it from https://cli.github.com/ and run `gh auth login` to add a GitHub remote.")
fmt.Fprintln(errW, "Continuing with local initialization only.")
case !ghAuthenticated(ctx, runner):
fmt.Fprintln(errW, "gh CLI is not authenticated. Run `gh auth login` to add a GitHub remote.")
fmt.Fprintln(errW, "Continuing with local initialization only.")
case explicit:
useGitHub = true
default:
// Interactive with no explicit signal: prompt, defaulting to No.
confirmed, err := confirmCreateGitHubRepo(cwd)
if err != nil {
return nil, err
}
useGitHub = confirmed
}
}
}
26 unmodified lines
}
}
// Step 6: pushing is also an explicit opt-in, separate from creating the
// repo. Publishing the directory's contents is a distinct outward-facing
// action, so it happens only on an explicit signal (--push or --yes) or an
// interactive "yes". Otherwise the repo is created but left unpushed. Only
// relevant when we'll create a GitHub repo and have a commit to push.
push := false
if useGitHub && commit {
switch {
case opts.Yes || opts.Push:
push = true
case interactive.CanPromptInteractively():
confirmed, err := confirmPushToRemote(fullName)
if err != nil {
return nil, err
}
push = confirmed
}
}
return &bootstrapState{
runner: runner,
cwd: cwd,
2 unmodified lines
visibility: visibility,
commit: commit,
message: message,
push: push,
}, nil
}
22 unmodified lines
// Pick a single section title for this phase based on what we'll do.
if s.useGitHub || s.commit {
switch {
case s.useGitHub && s.commit:
case s.useGitHub && s.commit && s.push:
printBootstrapSection(w, "Publishing to GitHub")
case s.useGitHub:
printBootstrapSection(w, "Creating GitHub repository")
15 unmodified lines
fmt.Fprintln(w, " ✓ Nothing to commit — the folder has no files yet")
}
}
// Push only when there's a commit AND the user opted into pushing.
pushed := committed && s.push
if s.useGitHub {
if err := ghRepoCreate(ctx, s.runner, s.cwd, s.fullName, s.visibility, committed); err != nil {
if err := ghRepoCreate(ctx, s.runner, s.cwd, s.fullName, s.visibility, pushed); err != nil {
return fmt.Errorf("gh repo create: %w", err)
}
fmt.Fprintf(w, " ✓ Created %s (%s)\n", s.fullName, s.visibility)
fmt.Fprintf(w, " https://github.com/%s\n", s.fullName)
if committed {
if pushed {
fmt.Fprintln(w, " ✓ Pushed initial commit to origin")
} else if committed {
// Repo created and origin configured, but the user declined the
// push. Tell them how to publish when ready.
fmt.Fprintln(w)
fmt.Fprintln(w, " Skipped push — nothing was published. When you're ready:")
fmt.Fprintln(w, " git push -u origin HEAD")
}
}
if !s.commit {
16 unmodified lines
return opts.RepoName != "" || opts.RepoOwner != "" || opts.RepoVisibility != ""
}
// ghCreateRequested reports whether the caller has explicitly opted into
// creating a GitHub repo without an interactive prompt: --yes, --push (which
// needs a remote to push to), or any repo-targeting flag. When false and the
// session is non-interactive, the bootstrap stays local-only.
func ghCreateRequested(opts GitHubBootstrapOptions) bool {
return opts.Yes || opts.Push || ghFlagsProvided(opts)
}
// confirmCreateGitHubRepo asks the user whether they want to also create
// a matching GitHub repository. Interactive-only; callers gate on
// interactive.CanPromptInteractively.
func confirmCreateGitHubRepo() (bool, error) {
confirmed := true
// interactive.CanPromptInteractively. Pushing to the repo is confirmed
// separately (see confirmPushToRemote).
//
// Defaults to No: creating a remote repository on the user's behalf must
// never happen just because the user pressed Enter. The absolute path is in
// the title so it's clear which directory is the source.
func confirmCreateGitHubRepo(cwd string) (bool, error) {
confirmed := false
form := NewAccessibleForm(
huh.NewGroup(
huh.NewConfirm().
Title("Create a matching repository on GitHub?").
Title(fmt.Sprintf("Create a GitHub repository for %q?", cwd)).
Value(&confirmed),
),
)
6 unmodified lines
return confirmed, nil
}
// confirmPushToRemote asks the user whether to push the initial commit to
// the newly-created GitHub repository. Interactive-only; callers gate on
// interactive.CanPromptInteractively.
//
// Defaults to No: pushing publishes the directory's contents to the remote,
// a distinct outward-facing action from creating the repo, so it must never
// happen just because the user pressed Enter. Declining leaves the repo
// created with origin configured but nothing pushed.
func confirmPushToRemote(fullName string) (bool, error) {
confirmed := false
form := NewAccessibleForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("Push the initial commit to %q?", fullName)).
Value(&confirmed),
),
)
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return false, errBootstrapInterrupted
}
return false, fmt.Errorf("push confirm prompt: %w", err)
}
return confirmed, nil
}
// confirmInitRepo returns true if we should proceed with `git init`. It
// respects --init-repo / --no-init-repo; otherwise prompts. In
// non-interactive mode we return false without printing anything so
10 unmodified lines
return false, nil
}
folder := filepath.Base(cwd)
confirmed := true
// Default to No: `entire enable` is often run reflexively inside an
// existing project, so a stray run in the wrong (non-repo) directory
// must not initialize a repo just because the user pressed Enter. The
// absolute path is in the title so a wrong-directory mistake is obvious
// in both interactive and accessible modes.
confirmed := false
form := NewAccessibleForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("No git repository in %q. Initialize one here?", folder)).
Title(fmt.Sprintf("Warning: Not a git repository. Initialize a new one in %q?", cwd)).
Value(&confirmed),
),
)
508 unmodified lines
return false, fmt.Errorf("gh repo view: %w", err)
}
// ghRepoCreate creates a GitHub repo from the local source directory, adds
// origin as its remote, and pushes if there's anything to push.
func ghRepoCreate(ctx context.Context, runner bootstrapRunner, dir, fullName, visibility string, hasCommits bool) error {
// ghRepoCreate creates a GitHub repo from the local source directory and
// adds origin as its remote. It pushes only when push is true; callers gate
// this on both having a commit and the user opting into the push.
func ghRepoCreate(ctx context.Context, runner bootstrapRunner, dir, fullName, visibility string, push bool) error {
// Create the remote repo and add origin, but don't push yet. We push
// separately below with --no-verify so the pre-push hook doesn't run
// on this first push: the entire/checkpoints/v1 branch has nothing to
12 unmodified lines
if _, err := runner.RunInDir(ctx, dir, "gh", args...); err != nil {
return fmt.Errorf("gh repo create: %w", ghRunnerErr(err))
}
if hasCommits {
if push {
// -q silences "Enumerating objects..." etc. --no-verify bypasses
// the pre-push hook so entire/checkpoints/v1 isn't pushed
// alongside the default branch.
Mcmd/entire/cli/setup_github.go+122/-43
384 unmodified lines
385
386
387
388
388
389
390
391
392
393
33 unmodified lines
427
428
429
430
431
432
433
157 unmodified lines
591
592
593
591
592
593
594
594
595
596
597
598
599
600
601
602
603
600
601
602
603
604
605
606
604
605
606
607
608
609
613
614
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
30 unmodified lines
700
701
702
703
704
705
706
322 unmodified lines
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
28 unmodified lines
1080
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
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
89 unmodified lines
1310
1311
1312
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1313
1314
1315
1316
1317
1318
384 unmodified lines
r.set("git", []string{"add", "-A"}, "", nil)
r.set("git", []string{"status", "--porcelain"}, "", nil)
opts := GitHubBootstrapOptions{InitRepo: true}
// A repo flag is an explicit GitHub request, so gh is probed; since it's
// missing we warn and fall back to local-only.
opts := GitHubBootstrapOptions{InitRepo: true, RepoName: "wanted"}
var errBuf bytes.Buffer
err := runGitHubBootstrapWith(context.Background(), io.Discard, &errBuf, opts, r)
if err != nil {
33 unmodified lines
RepoName: "my-new",
RepoVisibility: "private",
InitialCommitMessage: "Seed",
Push: true,
}
err := runGitHubBootstrapWith(context.Background(), io.Discard, io.Discard, opts, r)
if err != nil {
157 unmodified lines
}
}
// TestRunGitHubBootstrap_NonInteractive_NoFlagsDefaultsToGitHub confirms the
// non-interactive happy path still creates a GitHub repo when the user
// didn't set any explicit flag (the confirm prompt is only interactive).
func TestRunGitHubBootstrap_NonInteractive_NoFlagsDefaultsToGitHub(t *testing.T) {
// TestRunGitHubBootstrap_NonInteractive_NoFlagsStaysLocal confirms that a
// non-interactive bootstrap with no explicit GitHub signal stays local-only:
// it does not probe gh, create a repo, or push. Creating and pushing are
// explicit opt-ins (--repo-*, --push, --yes, or an interactive "yes").
func TestRunGitHubBootstrap_NonInteractive_NoFlagsStaysLocal(t *testing.T) {
dir := t.TempDir()
restoreCwd(t, dir)
r := newFakeRunner()
r.setIdentityConfigured()
r.set("gh", []string{"--version"}, "gh", nil)
r.set("gh", []string{"auth", "status"}, "ok", nil)
r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil)
r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil)
// Default folder slug derived from t.TempDir().
suggested := slugifyRepoName(filepath.Base(dir))
r.set("gh", []string{"repo", "view", "octocat/" + suggested, "--json", "name"}, "", errors.New("not found"))
r.set("git", []string{"init"}, "", nil)
state, err := runGitHubBootstrapInitWith(context.Background(), io.Discard, io.Discard, GitHubBootstrapOptions{InitRepo: true}, r)
if err != nil {
t.Fatalf("init failed: %v", err)
}
if !state.useGitHub {
t.Fatal("non-interactive bootstrap should default to using GitHub")
if state.useGitHub {
t.Fatal("non-interactive bootstrap with no explicit signal must stay local-only")
}
if state.push {
t.Fatal("push must be false when staying local-only")
}
// gh must never be probed when no GitHub repo was requested.
if r.hasCall(func(c fakeCall) bool { return c.name == "gh" }) {
t.Fatal("must not invoke gh when no GitHub repo was requested")
}
}
// TestRunGitHubBootstrap_RepoFlagsCreateButDoNotPush confirms that repo flags
// opt into creating the GitHub repo but NOT into pushing. Non-interactively,
// the repo is created and origin configured, but nothing is pushed unless
// --push or --yes is also given; the user is told how to publish manually.
func TestRunGitHubBootstrap_RepoFlagsCreateButDoNotPush(t *testing.T) {
dir := t.TempDir()
restoreCwd(t, dir)
r := newFakeRunner()
r.setIdentityConfigured()
r.set("gh", []string{"--version"}, "gh 2.81.0", nil)
r.set("gh", []string{"auth", "status"}, "Logged in", nil)
r.set("gh", []string{"api", "user", "--jq", ".login"}, "octocat\n", nil)
r.set("gh", []string{"api", "user/orgs", "--jq", ".[].login"}, "", nil)
r.set("gh", []string{"repo", "view", "octocat/create-only", "--json", "name"}, "", errors.New("not found"))
r.set("git", []string{"init"}, "", nil)
r.set("git", []string{"add", "-A"}, "", nil)
r.set("git", []string{"status", "--porcelain"}, " M f\n", nil)
r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Seed"}, "", nil)
r.set("gh", []string{
"repo", "create", "octocat/create-only",
"--private",
"--source=.",
"--remote=origin",
}, "", nil)
opts := GitHubBootstrapOptions{
InitRepo: true,
RepoName: "create-only",
RepoVisibility: "private",
InitialCommitMessage: "Seed",
}
var out bytes.Buffer
if err := runGitHubBootstrapWith(context.Background(), &out, io.Discard, opts, r); err != nil {
t.Fatalf("bootstrap failed: %v", err)
}
if !r.hasCall(argsMatch("gh", []string{"repo", "create"})) {
t.Fatal("expected gh repo create when repo flags are given")
}
if r.hasCall(argsMatch("git", []string{"push"})) {
t.Fatal("must not push without --push or --yes")
}
if !strings.Contains(out.String(), "Skipped push") {
t.Fatalf("expected 'Skipped push' guidance, got: %s", out.String())
}
}
30 unmodified lines
RepoName: "phased",
RepoVisibility: "private",
InitialCommitMessage: "First",
Push: true,
}
// Phase 1: init. This must NOT call git add/commit/ gh repo create.
322 unmodified lines
}
}
func TestEnableCmd_PushNoGitHubMutuallyExclusive(t *testing.T) {
setupTestRepo(t)
cmd := newEnableCmd()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetOut(&bytes.Buffer{})
cmd.SetArgs([]string{"--push", "--no-github"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --push and --no-github are set")
}
if !strings.Contains(err.Error(), "push") || !strings.Contains(err.Error(), "no-github") {
t.Fatalf("expected error to mention both flags, got: %v", err)
}
}
func TestEnableCmd_InitCommitMessageFlagsMutuallyExclusive(t *testing.T) {
setupTestRepo(t)
28 unmodified lines
}
}
// withInteractivePromptStdin forces interactive, accessible (text-based)
// prompt mode and feeds input to os.Stdin for the duration of the test, so a
// huh prompt reads a scripted answer instead of opening /dev/tty or blocking
// on a real terminal. ENTIRE_TEST_TTY makes CanPromptInteractively report
// true; ACCESSIBLE makes the form read os.Stdin rather than dial the terminal.
func withInteractivePromptStdin(t *testing.T, input string) {
t.Helper()
t.Setenv("ENTIRE_TEST_TTY", "1")
t.Setenv("ACCESSIBLE", "1")
pr, pw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pr.Close() })
go func() {
pw.WriteString(input) //nolint:errcheck // test helper
pw.Close()
}()
old := os.Stdin
os.Stdin = pr
t.Cleanup(func() { os.Stdin = old })
}
// TestConfirmInitRepo_DefaultsToNo verifies that pressing Enter (empty
// input) at the init-repo prompt declines. `entire enable` is often run
// reflexively, so a stray run in a non-repo directory must not initialize
// a repo on the user's behalf. Regression guard for issue #1717.
func TestConfirmInitRepo_DefaultsToNo(t *testing.T) {
withInteractivePromptStdin(t, "\n")
proceed, err := confirmInitRepo(io.Discard, t.TempDir(), GitHubBootstrapOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if proceed {
t.Fatal("confirmInitRepo should default to No (decline) on empty input")
}
}
// TestConfirmInitRepo_ExplicitYesProceeds verifies an explicit "y" still
// opts in, so the safer default doesn't block intentional use.
func TestConfirmInitRepo_ExplicitYesProceeds(t *testing.T) {
withInteractivePromptStdin(t, "y\n")
proceed, err := confirmInitRepo(io.Discard, t.TempDir(), GitHubBootstrapOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !proceed {
t.Fatal("confirmInitRepo should proceed when the user explicitly answers yes")
}
}
// TestConfirmCreateGitHubRepo_DefaultsToNo verifies that pressing Enter at
// the GitHub-repo prompt declines. Creating and pushing a remote repository
// publishes the directory's contents, so it must never happen just because
// the user pressed Enter. Regression guard for issue #1717.
func TestConfirmCreateGitHubRepo_DefaultsToNo(t *testing.T) {
withInteractivePromptStdin(t, "\n")
confirmed, err := confirmCreateGitHubRepo(t.TempDir())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if confirmed {
t.Fatal("confirmCreateGitHubRepo should default to No on empty input")
}
}
// TestConfirmPushToRemote_DefaultsToNo verifies that pressing Enter at the
// push prompt declines. Pushing publishes the directory's contents, so it
// must never happen just because the user pressed Enter, even after they
// opted into creating the repo. Regression guard for issue #1717.
func TestConfirmPushToRemote_DefaultsToNo(t *testing.T) {
withInteractivePromptStdin(t, "\n")
confirmed, err := confirmPushToRemote("octocat/example")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if confirmed {
t.Fatal("confirmPushToRemote should default to No on empty input")
}
}
// TestRunGitHubBootstrapFinalize_HonorsPushFalse verifies that finalize
// respects state.push == false: the GitHub repo is still created and origin
// configured, but nothing is pushed and the user is told how to publish
// manually. The push *decision* (default No on Enter) is covered separately
// by TestConfirmPushToRemote_DefaultsToNo; this test covers finalize honoring
// that decision.
func TestRunGitHubBootstrapFinalize_HonorsPushFalse(t *testing.T) {
t.Parallel()
dir := t.TempDir()
r := newFakeRunner()
r.set("git", []string{"add", "-A"}, "", nil)
r.set("git", []string{"status", "--porcelain"}, " M f\n", nil)
r.set("git", []string{"-c", "commit.gpgsign=false", "commit", "-m", "Seed"}, "", nil)
r.set("gh", []string{
"repo", "create", "octocat/no-push",
"--private",
"--source=.",
"--remote=origin",
}, "", nil)
s := &bootstrapState{
runner: r,
cwd: dir,
useGitHub: true,
fullName: "octocat/no-push",
visibility: "private",
commit: true,
message: "Seed",
push: false,
}
var out bytes.Buffer
if err := runGitHubBootstrapFinalize(context.Background(), &out, s); err != nil {
t.Fatalf("finalize failed: %v", err)
}
// The repo is still created (create guard was accepted)...
if !r.hasCall(argsMatch("gh", []string{"repo", "create"})) {
t.Fatal("expected gh repo create to run")
}
// ...but the push guard was declined, so nothing is pushed.
if r.hasCall(argsMatch("git", []string{"push"})) {
t.Fatal("git push must not run when the push guard was declined")
}
if !strings.Contains(out.String(), "Skipped push") {
t.Fatalf("expected 'Skipped push' guidance in output, got: %s", out.String())
}
}
// restoreCwd chdirs into dir for the duration of the test.
func restoreCwd(t *testing.T, dir string) {
t.Helper()
89 unmodified lines
// When --yes is set, the name is taken, and a TTY is available,
// resolveRepoName should print a conflict message and fall through
// to the interactive prompt. We verify the conflict message was
// printed (proving the fallback path was taken).
t.Setenv("ENTIRE_TEST_TTY", "1")
// Force accessible (text-based) mode so the huh form reads from
// os.Stdin instead of trying to open /dev/tty via bubbletea.
// Pipe a unique name so the form completes instead of blocking.
t.Setenv("ACCESSIBLE", "1")
pr, pw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pr.Close() })
go func() {
// The form reads one line; provide a unique name so it exits the loop.
pw.WriteString("unique-test-repo\n") //nolint:errcheck // test helper
pw.Close()
}()
oldStdin := os.Stdin
os.Stdin = pr
t.Cleanup(func() { os.Stdin = oldStdin })
// printed (proving the fallback path was taken). Pipe a unique name so
// the form completes with it instead of blocking.
withInteractivePromptStdin(t, "unique-test-repo\n")
dir := t.TempDir()
restoreCwd(t, dir)
Mcmd/entire/cli/setup_github_test.go+222/-34
335 unmodified lines
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
335 unmodified lines
assert.False(t, ps.pushDisabled)
}
// Not parallel: uses t.Chdir()
//
// When origin is an entire:// push-through mirror whose forge (gh) matches the
// configured checkpoint provider (github), checkpoints route through the same
// cluster mirror instead of falling back to a direct github.com URL.
func TestResolvePushSettings_WithCheckpointRemote_EntireMirror(t *testing.T) {
ctx := context.Background()
localDir := t.TempDir()
testutil.InitRepo(t, localDir)
testutil.WriteFile(t, localDir, "f.txt", "init")
testutil.GitAdd(t, localDir, "f.txt")
testutil.GitCommit(t, localDir, "init")
// Origin is an entire:// mirror on cluster app.entire.io for forge gh.
cmd := exec.CommandContext(ctx, "git", "remote", "add", "origin", "entire://app.entire.io/gh/org/main-repo")
cmd.Dir = localDir
cmd.Env = testutil.GitIsolatedEnv()
require.NoError(t, cmd.Run())
entireDir := filepath.Join(localDir, ".entire")
require.NoError(t, os.MkdirAll(entireDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(entireDir, "settings.json"),
[]byte(`{"enabled": true, "strategy_options": {"checkpoint_remote": {"provider": "github", "repo": "org/checkpoints"}}}`),
0o644,
))
// Seed the local v1 metadata branch so resolvePushSettings finds it and
// skips fetchMetadataBranchIfMissing — which would otherwise invoke the
// entire:// remote helper against a live cluster.
runCheckpointRemoteGit(ctx, t, localDir, "branch", paths.MetadataBranchName)
t.Chdir(localDir)
ps := resolvePushSettings(ctx, "origin")
assert.True(t, ps.hasCheckpointURL())
// Keeps the cluster host and forge segment, swaps in the checkpoint repo.
assert.Equal(t, "entire://app.entire.io/gh/org/checkpoints", ps.pushTarget())
assert.False(t, ps.pushDisabled)
}
// Not parallel: uses t.Chdir()
//
// When origin is an entire:// mirror of a different forge (et) than the
// configured checkpoint provider (github), it must not route through the
// mirror; it falls back to the provider's canonical host.
func TestResolvePushSettings_EntireMirrorForgeMismatchFallsBackToProvider(t *testing.T) {
ctx := context.Background()
cmd := exec.CommandContext(ctx, "git", "remote", "add", "origin", "entire://app.entire.io/et/org/main-repo")
cmd.Dir = localDir
cmd.Env = testutil.GitIsolatedEnv()
require.NoError(t, cmd.Run())
// Seed the local v1 branch so the provider-host fallback URL isn't fetched
// from github.com for real.
runCheckpointRemoteGit(ctx, t, localDir, "branch", paths.MetadataBranchName)
t.Chdir(localDir)
ps := resolvePushSettings(ctx, "origin")
assert.True(t, ps.hasCheckpointURL())
// Provider host over SSH (default transport), not the non-matching mirror.
assert.Equal(t, "git@github.com:org/checkpoints.git", ps.pushTarget())
assert.False(t, ps.pushDisabled)
}
// Not parallel: uses t.Chdir()
func TestResolvePushSettings_CheckpointURLDoesNotAffectRemoteField(t *testing.T) {
ctx := context.Background()
Mcmd/entire/cli/strategy/checkpoint_remote_test.go+82
6 unmodified lines
7
8
9
10
11
12
13
448 unmodified lines
462
463
464
464
465
466
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
6 unmodified lines
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
448 unmodified lines
if err != nil {
return "", fmt.Errorf("--absolute-git-hook-path: failed to resolve binary path: %w", err)
}
resolved, err := filepath.EvalSymlinks(exe)
resolved, err := resolveHookExePath(exe, filepath.EvalSymlinks, runtime.GOOS)
if err != nil {
return "", fmt.Errorf("--absolute-git-hook-path: failed to resolve symlinks for %s: %w", exe, err)
return "", err
}
return shellQuote(resolved), nil
}
return "entire", nil
}
// resolveHookExePath resolves exe through symlinks for embedding as an absolute
// path in a git hook. On Windows, filepath.EvalSymlinks can fail when a path
// component is an NTFS directory junction rather than a plain symlink — notably
// Scoop's `…\scoop\apps\<app>\current\` junction, which yields "The system
// cannot find the path specified" (issue #1424). The unresolved os.Executable()
// path is itself a valid, launchable absolute path (and on Scoop the stable
// `current\` junction path is actually preferable, since it survives version
// updates that repoint the junction), so on Windows we fall back to it rather
// than failing the hook install outright. Off Windows, an EvalSymlinks failure
// is unexpected and still surfaced as an error.
func resolveHookExePath(exe string, evalSymlinks func(string) (string, error), goos string) (string, error) {
resolved, err := evalSymlinks(exe)
if err != nil {
if goos == "windows" {
return exe, nil
}
return "", fmt.Errorf("--absolute-git-hook-path: failed to resolve symlinks for %s: %w", exe, err)
}
return resolved, nil
}
// shellQuote wraps a string in single quotes for safe use in #!/bin/sh scripts.
// Handles paths containing spaces, apostrophes, or other shell metacharacters
// (e.g., /Users/John O'Brien/bin/entire).
Mcmd/entire/cli/strategy/hooks.go+24/-2
1 unmodified line
2
3
4
5
6
7
8
1691 unmodified lines
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1 unmodified line
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
1691 unmodified lines
t.Errorf("error should mention 'failed to remove hooks', got: %v", err)
}
}
// TestResolveHookExePath covers the absolute-git-hook-path symlink resolution,
// including the Windows fallback for NTFS junctions that EvalSymlinks cannot
// resolve (e.g. Scoop's `…\current\` junction — issue #1424). GOOS and the
// symlink resolver are injected so every branch runs on any host.
func TestResolveHookExePath(t *testing.T) {
t.Parallel()
const exe = `C:\Users\admin\scoop\apps\cli\current\entire.exe`
// Stand-in for the Windows junction error ("The system cannot find the path
// specified") that filepath.EvalSymlinks returns on Scoop's `current\`.
junctionErr := errors.New("cannot find the path specified")
t.Run("resolves normally when EvalSymlinks succeeds", func(t *testing.T) {
t.Parallel()
got, err := resolveHookExePath("/tmp/linkto", func(string) (string, error) {
return "/opt/entire/entire", nil
}, "linux")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "/opt/entire/entire" {
t.Errorf("got %q, want resolved target", got)
}
})
t.Run("windows falls back to unresolved path on EvalSymlinks failure", func(t *testing.T) {
t.Parallel()
got, err := resolveHookExePath(exe, func(string) (string, error) {
return "", junctionErr
}, "windows")
if err != nil {
t.Fatalf("windows should fall back, got error: %v", err)
}
if got != exe {
t.Errorf("got %q, want unresolved exe %q", got, exe)
}
})
t.Run("non-windows surfaces EvalSymlinks failure", func(t *testing.T) {
t.Parallel()
_, err := resolveHookExePath("/usr/local/bin/entire", func(string) (string, error) {
return "", junctionErr
}, "linux")
if err == nil {
t.Fatal("expected error on non-windows EvalSymlinks failure")
}
if !strings.Contains(err.Error(), "failed to resolve symlinks") {
t.Errorf("error should mention symlink resolution, got: %v", err)
}
})
}
Mcmd/entire/cli/strategy/hooks_test.go+53
49 unmodified lines
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
49 unmodified lines
3. Calculating agent work (base → shadow minus accumulated user edits)
4. Computing the final percentage
### Attribution Metadata Fields
Attribution is calculated for commits associated with an Entire-tracked agent
session/checkpoint. It does not track keystrokes or exact authorship. The
metrics are inferred from line diffs and hook timing. Changes made after a
completed checkpoint and detected before the next agent turn, or after the
latest checkpoint before commit, are treated as user-side changes. Changes
already present before the first agent turn are treated as a pre-session
baseline and excluded from the final human contribution counts.
- `agent_lines` - agent-attributed added lines that remain in the commit
- `agent_removed` - agent-attributed deletions that remain deleted in the commit
- `human_added` - user-side additions that are not paired with removals as modifications
- `human_modified` - aggregate estimate calculated as the smaller of eligible user-side additions and removals; pairing is not tracked per hunk or file
- `human_removed` - estimated unpaired user-side removals captured between prompts or after the latest checkpoint in agent-touched files
- `total_committed` - legacy additions-focused compatibility metric; not guaranteed to equal the commit's literal net line count
- `total_lines_changed` - total changed lines used as the attribution denominator
- `agent_percentage` - agent changed lines divided by total changed lines
For example, if a user-side diff removes 5 lines and adds 2 replacement lines,
the model classifies that as:
```text
human_modified: 2
human_removed: 3
human_added: 0
```
The 2 added lines are still counted as user-side work, but under
`human_modified` because they replaced existing lines. If the user instead adds
2 lines on top of the existing content without removing anything, those lines
count as `human_added: 2`.
### Key Files
- `manual_commit_attribution.go` - Core attribution calculation logic
Mdocs/architecture/attribution.md+33
29 unmodified lines
30
31
32
33
33
34
35
36
90 unmodified lines
127
128
129
130
130
131
132
133
29 unmodified lines
- **Destructive local-v1-ref moves** — the single most re-broken area: #953, `96034892c`, #1252, #1251, #1260 (ahead/behind/diverged/disconnected/shallow × resume/explain/doctor/pre-push).
- **Cross-clone replay fidelity**: no-op commit tree clobber (`743c43f4c` — *no test*), >1000-commit replay cap (`4cf01edb3` — *no test*), stale ls-remote hash TOCTOU (`1e8628ade` — *no test*), double-replay (#1260 has a test).
- **Remote-target precedence**: hardcoded-origin blob fetch (#976), `entire://`/`file://` non-derivable origin (#1279), token SSH→HTTPS coercion on fetch missing (`7afdaa33e`), silent origin fallback faking success (`53bc37a88`).
- **Remote-target precedence**: hardcoded-origin blob fetch (#976), `entire://`/`file://` non-derivable origin (#1279 — since superseded: a forge-matching `entire://` origin now derives a mirror checkpoint URL on the same cluster; the provider-host fallback remains for `file://` and forge-mismatched mirrors), token SSH→HTTPS coercion on fetch missing (`7afdaa33e`), silent origin fallback faking success (`53bc37a88`).
- **Errors masked as not-found**: partial-clone `ErrFileNotFound` (#1069), git-refs read paths (`7bbdad09c`).
- **Self-inflicted shallow grafts** (#1443), promisor config pollution of `[remote "origin"]` (#934), gc pack race (#1276).
- **Hook/transport hangs**: grandchild helper holding pipes (#1282), timeout stacking (`2e2c1b73a`), protected-ref GH013 silence (#1033).
90 unmodified lines
| F3 | 401→token-retry over HTTPS for git-refs batch push (v1 version exists: `TestHTTPS_PushFailsWithoutToken`) | integration (HTTPS) | gr |
| F4 | Checkpoint policy sync in the git-refs pre-push path (regression `7bbdad09c` — policy check was skipped): blocked policy skips checkpoint refs but not the user push | integration | gr |
| F5 | Detached HEAD: session + checkpoint while detached; `git push origin HEAD:branch`; resume from detached clone (today detach is only setup plumbing) | integration | both |
| F6 | `entire://` origin with checkpoint_remote configured → provider-host routing, not a push at the helper (regression #1279) — currently unit-only; needs a fake provider mapping or injectable host table | integration | gb |
| F6 | `entire://` origin with checkpoint_remote configured → checkpoints follow the mirror on the same cluster when the forge matches the provider; provider-host routing only for forge-mismatched mirrors and `file://` (supersedes the #1279 behavior) — currently unit-only; needs a fake provider mapping or injectable host table | integration | gb |
### G. E2E additions (real agents optional, vogon default) — P1
Mdocs/testing/git-remote-test-plan.md+2/-2
15 unmodified lines
16
17
18
19
20
21
19
20
21
22
23
24
25
26
27
27
28
29
30
51 unmodified lines
82
83
84
85
86
85
86
87
88
89
15 unmodified lines
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
github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260521161150-3af8745c291b
github.com/go-git/x/plugin/objectsigner/auto v0.1.0
github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260506121155-e7fc238fcab6
github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260713100844-d5e9b9c7895b
github.com/go-git/x/plugin/objectsigner/auto v0.1.1-0.20260624122410-382b2905c041
github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041
github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0
github.com/mattn/go-isatty v0.0.22
github.com/mattn/go-runewidth v0.0.24
github.com/muesli/termenv v0.16.0
github.com/ogen-go/ogen v1.22.0
github.com/ogen-go/ogen v1.23.0
github.com/oklog/ulid/v2 v2.1.1
github.com/posthog/posthog-go v1.17.5
github.com/sergi/go-diff v1.4.0
51 unmodified lines
github.com/gitleaks/go-gitdiff v0.9.1 // indirect
github.com/go-faster/yaml v0.4.6 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 // indirect
github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 // indirect
github.com/go-git/x/plugin/objectsigner/gpg v0.2.1-0.20260624122410-382b2905c041 // indirect
github.com/go-git/x/plugin/objectsigner/ssh v0.2.1-0.20260624122410-382b2905c041 // indirect
github.com/go-sprout/sprout v1.0.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
Mgo.mod+6/-6
134 unmodified lines
135
136
137
138
139
140
141
142
143
144
145
146
147
138
139
140
141
142
143
144
145
146
147
148
149
150
77 unmodified lines
228
229
230
231
232
231
232
233
234
235
134 unmodified lines
github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E=
github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA=
github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ=
github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260521161150-3af8745c291b h1:99k+na4J/y/rKvB21GFeFhIf/Rqskfc4a6mvTf4wbJA=
github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260521161150-3af8745c291b/go.mod h1:OTUSi3RzPFoC0j/+uxHdVG1X/xXz84QCxLzYvXRvyXk=
github.com/go-git/x/plugin/objectsigner/auto v0.1.0 h1:RcLW29RgwSCmqrNSs7QOxvWkRbM1vPu0Vp9TCECZjMs=
github.com/go-git/x/plugin/objectsigner/auto v0.1.0/go.mod h1:iP2cXPyXc//9v9THS3y/MLi0jnt7vEqwUDj11qQfFPg=
github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 h1:NEGVSOD+LPnus6j4iNkAZaHVTc4DNY223y1/I2Jq2yI=
github.com/go-git/x/plugin/objectsigner/gpg v0.1.0/go.mod h1:1iosWq3OOqZxtNrwDHtcjicswuaOT45J5GMFyCk80wc=
github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260506121155-e7fc238fcab6 h1:ZRy5GVQf/EisYhLj3zwU+eGVhMDWhYxCfaq3wBusGsM=
github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260506121155-e7fc238fcab6/go.mod h1:qqkRcAeBDQLDJTBiN/s4k4Xj6eFBP+2cdoZDzsld0b0=
github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 h1:lAeeDgc1oxsMMvVUed6ssrqJnD97UR1K/dXIDdeg1Yc=
github.com/go-git/x/plugin/objectsigner/ssh v0.1.0/go.mod h1:6BvpZj9Yry1ZFNw4N5OZDc+7M1T8oyrZilLNFg2aTsM=
github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260713100844-d5e9b9c7895b h1:RP3bg2PI8ZMeyGr31iJIqLTqLMEEXWGE2pSbAn/oRLk=
github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260713100844-d5e9b9c7895b/go.mod h1:6B0m9RQWmjdn10aHogqSpI7z7kwEUtELzzA8wW8gogg=
github.com/go-git/x/plugin/objectsigner/auto v0.1.1-0.20260624122410-382b2905c041 h1:ATVPaVKC1wbuQdvGKfKXotuwXYeGfigyHERl7lmNG+I=
github.com/go-git/x/plugin/objectsigner/auto v0.1.1-0.20260624122410-382b2905c041/go.mod h1:Cpmdf+1Pmw6nPWTpfBMsPmWju2Tb+qjwccWR5AvOBC4=
github.com/go-git/x/plugin/objectsigner/gpg v0.2.1-0.20260624122410-382b2905c041 h1:Tni6GTpv/Nx4HAub64YmnxGWe99za33jfzy3GesditQ=
github.com/go-git/x/plugin/objectsigner/gpg v0.2.1-0.20260624122410-382b2905c041/go.mod h1:1iosWq3OOqZxtNrwDHtcjicswuaOT45J5GMFyCk80wc=
github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041 h1:3SNIy+i6ou6OX1ekdFKpuTg+BGPO3Q4Jj6by0KX/2lY=
github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041/go.mod h1:qqkRcAeBDQLDJTBiN/s4k4Xj6eFBP+2cdoZDzsld0b0=
github.com/go-git/x/plugin/objectsigner/ssh v0.2.1-0.20260624122410-382b2905c041 h1:mmJ/LFr0c7ij9UYQorU66989ge06vf1H07ud533UQ/I=
github.com/go-git/x/plugin/objectsigner/ssh v0.2.1-0.20260624122410-382b2905c041/go.mod h1:6BvpZj9Yry1ZFNw4N5OZDc+7M1T8oyrZilLNFg2aTsM=
github.com/go-sprout/sprout v1.0.3 h1:LLuz0D3aYazgbVTOwCVuMor3LOUVYinipXRIdjA/D+I=
github.com/go-sprout/sprout v1.0.3/go.mod h1:cFFzpnyGGry3cmN0UNCAM1f7AGok6vPVabeYQzBMBZY=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
77 unmodified lines
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/ogen-go/ogen v1.22.0 h1:7wU+jcIKg/JBAhM95909ULLdAkGr43KQOuvNpJ7Mxb4=
github.com/ogen-go/ogen v1.22.0/go.mod h1:7BOh9a51QiPCC92RMrj1LlkLjejhBAyPhR+oMc6lR9g=
github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo=
github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
Mgo.sum+12/-12
322 unmodified lines
323
324
325
326
326
327
328
329
2 unmodified lines
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
322 unmodified lines
stdin := bufio.NewReader(strings.NewReader("\n"))
var stdout bytes.Buffer
err := handlePush(context.Background(), ft, firstLine, &Options{}, stdin, &stdout)
err := handlePush(context.Background(), ft, &refAdvCache{}, firstLine, &Options{}, stdin, &stdout)
if err == nil {
t.Fatal("expected error from send-pack exit 1")
}
2 unmodified lines
stdout.String(), helperStatusLine)
}
}
// TestInvariant_PushReusesListForPushAdvertisement pins the fix for ENCLI-267.
// Within one helper session the "push" command MUST reuse the ref
// advertisement fetched during "list for-push" rather than re-fetching
// info/refs. Git snapshots the remote refs from "list for-push" into its
// remote_refs list *before* running the pre-push hook, and the hook pushes
// per-checkpoint refs to the same remote. A fresh info/refs at push time then
// hands send-pack a ref (the freshly-pushed checkpoint) Git never asked to
// push; send-pack emits `error <ref> no match`, and Git — not finding it in
// remote_refs — warns `helper reported unexpected status of <ref>`. Reusing
// the list-for-push snapshot mirrors remote-curl.c's discovery cache and keeps
// that phantom ref out of send-pack's view.
func TestInvariant_PushReusesListForPushAdvertisement(t *testing.T) {
// No t.Parallel(): t.Setenv("PATH", ...) mutates process-global state.
if runtime.GOOS == "windows" {
t.Skip("shell-script PATH stub is POSIX-only")
}
ref := testRefMain
oldSHA := strings.Repeat("a", 40)
// Stub git send-pack: emit the empty-request terminator, drain stdin,
// then the trailing flush + a plain "ok" helper-status, exit 0.
stubDir := t.TempDir()
stub := "#!/bin/sh\nprintf '0000'\ncat > /dev/null\nprintf '0000ok " + ref + "\\n'\nexit 0\n"
if err := os.WriteFile(filepath.Join(stubDir, "git"), []byte(stub), 0o755); err != nil {
t.Fatalf("writing stub git: %v", err)
}
t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH"))
// The checkpoint ref the pre-push hook would push between list-for-push
// and push: absent from the first advertisement, present in the second, so
// a re-fetch (the bug) would expose it to send-pack.
checkpointRef := "refs/entire/checkpoints/9H/01KX2ATMJ3FAZZFZ8CP1CA279H"
receivePackCalls := 0
ft := &fakeTransport{
infoRefsResp: func() (io.ReadCloser, error) {
receivePackCalls++
refLine := oldSHA + " " + ref + "\x00report-status object-format=sha1\n"
if receivePackCalls == 1 {
return stringRC(serviceAnnouncement(serviceReceivePack, refLine)), nil
}
return stringRC(serviceAnnouncement(serviceReceivePack, refLine,
oldSHA+" "+checkpointRef+"\n")), nil
},
serviceRPCResp: func(string, []byte) (io.ReadCloser, error) {
return stringRC(""), nil
},
}
stdin := strings.NewReader("list for-push\npush " + oldSHA + ":" + ref + "\n\n")
var stdout bytes.Buffer
if err := Run(context.Background(), ft, 2, stdin, &stdout); err != nil {
t.Fatalf("Run: %v", err)
}
if receivePackCalls != 1 {
t.Fatalf("receive-pack info/refs fetched %d times; want 1 (push must reuse the list-for-push advertisement)", receivePackCalls)
}
}
Minternal/remotehelper/githelper/invariants_test.go+61/-1
17 unmodified lines
18
19
20
21
21
22
23
24
25
26
26
27
28
29
17 unmodified lines
// writes one "<value> <name>" line per ref followed by a blank-line
// terminator. HEAD is emitted as "@<target> HEAD" when the symref
// capability resolves; detached HEAD falls back to "<sha> HEAD".
func handleList(ctx context.Context, t Transport, forPush bool, stdout io.Writer) error {
func handleList(ctx context.Context, t Transport, adv *refAdvCache, forPush bool, stdout io.Writer) error {
service := serviceUploadPack
if forPush {
service = serviceReceivePack
}
refs, err := t.InfoRefs(ctx, service)
refs, err := adv.infoRefs(ctx, t, service)
if err != nil {
return fmt.Errorf("list %s info/refs: %w", service, err)
}
Minternal/remotehelper/githelper/list.go+2/-2
106 unmodified lines
107
108
109
110
110
111
112
113
106 unmodified lines
defer server.Close()
var out bytes.Buffer
if err := handleList(context.Background(), testTransport(server), tt.forPush, &out); err != nil {
if err := handleList(context.Background(), testTransport(server), &refAdvCache{}, tt.forPush, &out); err != nil {
t.Fatalf("handleList: %v", err)
}
if out.String() != tt.want {
Minternal/remotehelper/githelper/list_test.go+1/-1
41 unmodified lines
42
43
44
45
45
46
47
48
49
50
51
51
52
53
54
55
56
57
58
41 unmodified lines
// 6. Send-pack writes a trailing flush + helper-status lines to
// stdout; we discard the flush and relay helper-status to git,
// then append the blank line that terminates the status batch.
func handlePush(ctx context.Context, t Transport, firstLine string, opts *Options, stdin *bufio.Reader, stdout io.Writer) error {
func handlePush(ctx context.Context, t Transport, adv *refAdvCache, firstLine string, opts *Options, stdin *bufio.Reader, stdout io.Writer) error {
refspecs, err := readPushBatch(firstLine, stdin)
if err != nil {
return err
}
refsResp, err := t.InfoRefs(ctx, serviceReceivePack)
// Reuse the advertisement "list for-push" already fetched. Re-fetching
// here would observe refs the pre-push hook pushed after Git's ref
// snapshot, which send-pack reports and Git flags as "unexpected status"
// (see refAdvCache / ENCLI-267).
refsResp, err := adv.infoRefs(ctx, t, serviceReceivePack)
if err != nil {
return fmt.Errorf("fetching receive-pack info/refs: %w", err)
}
Minternal/remotehelper/githelper/push.go+6/-2
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
package githelper
import (
"bytes"
"context"
"fmt"
"io"
)
// refAdvCache memoizes the receive-pack ref advertisement across a single
// helper session so the "push" command reuses the exact ref snapshot that
// "list for-push" fetched. This mirrors remote-curl.c's discovery cache
// (get_refs/last_refs): Git builds its remote_refs list from the
// "list for-push" advertisement, then runs the pre-push hook, then issues
// "push". The Entire pre-push hook pushes per-checkpoint refs to the same
// remote in that window, so a fresh info/refs at push time would hand
// send-pack a ref Git never asked to push. send-pack then reports
// `error <ref> no match` and Git — not finding it in remote_refs — warns
// `helper reported unexpected status of <ref>` (ENCLI-267). Reusing the
// snapshot keeps that phantom ref out of send-pack's view.
//
// Only the receive-pack (for-push) advertisement is cached; upload-pack and
// v2 fetches pass straight through, matching remote-curl's per-for_push cache.
type refAdvCache struct {
receivePack []byte
cached bool
}
// infoRefs returns the ref advertisement for service. The receive-pack
// advertisement is fetched from the Transport once and replayed from an
// in-memory buffer on subsequent calls; every other service is fetched fresh.
func (c *refAdvCache) infoRefs(ctx context.Context, t Transport, service string) (io.ReadCloser, error) {
if service != serviceReceivePack {
rc, err := t.InfoRefs(ctx, service)
if err != nil {
return nil, fmt.Errorf("fetch %s advertisement: %w", service, err)
}
return rc, nil
}
if c.cached {
return io.NopCloser(bytes.NewReader(c.receivePack)), nil
}
rc, err := t.InfoRefs(ctx, service)
if err != nil {
return nil, fmt.Errorf("fetch %s advertisement: %w", service, err)
}
defer rc.Close()
buf, err := io.ReadAll(rc)
if err != nil {
return nil, fmt.Errorf("buffer %s advertisement: %w", service, err)
}
c.receivePack = buf
c.cached = true
return io.NopCloser(bytes.NewReader(buf)), nil
}
Ainternal/remotehelper/githelper/refadv_cache.go+55
30 unmodified lines
31
32
33
34
35
36
37
38
39
21 unmodified lines
61
62
63
61
64
65
66
67
20 unmodified lines
88
89
90
88
91
92
93
94
30 unmodified lines
func Run(ctx context.Context, t Transport, protocolVersion int, stdin io.Reader, stdout io.Writer) error {
commandReader := bufio.NewReader(stdin)
opts := &Options{}
// One advertisement snapshot per session: "push" reuses what
// "list for-push" fetched. See refAdvCache / ENCLI-267.
adv := &refAdvCache{}
for {
line, err := commandReader.ReadString('\n')
21 unmodified lines
fmt.Fprintln(stdout)
case line == "list" || line == "list for-push":
if err := handleList(ctx, t, line == "list for-push", stdout); err != nil {
if err := handleList(ctx, t, adv, line == "list for-push", stdout); err != nil {
return err
}
20 unmodified lines
return nil
case strings.HasPrefix(line, "push "):
if err := handlePush(ctx, t, line, opts, commandReader, stdout); err != nil {
if err := handlePush(ctx, t, adv, line, opts, commandReader, stdout); err != nil {
return err
}
Minternal/remotehelper/githelper/run.go+5/-2