@feigi/fleet-ctl 3.18.10 → 3.18.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- // The member-telemetry adapter (#1342, ruled on #1302). ONE per-member
1
+ // The member-telemetry adapter. ONE per-member
2
2
  // record shape, TWO readers a tree walk chooses by content, never by
3
3
  // caller-supplied config: board.mjs (the live spend panel) and
4
4
  // member-outcomes.mjs (the scraper) build on the primitives here rather than
@@ -13,9 +13,8 @@
13
13
  // — no pricing table exists in this repo, and inventing one is not this
14
14
  // module's business.
15
15
  //
16
- // `thinking` is the harness-written level, blank when a hole is visible
17
- // (#1302's ruling on "auto"). `subagent_type` (#1066) is what the member was
18
- // DISPATCHED AS — the agent DEFINITION the dispatch named, never the
16
+ // `thinking` is the harness-written level, blank when a hole is visible.
17
+ // `subagent_type` is what the member was DISPATCHED AS — the agent DEFINITION the dispatch named, never the
19
18
  // member's own name: it is the only field that separates a deliberate
20
19
  // alternate-tier pair from two members whose models already happened to
21
20
  // differ, and both readers keep it because it is derived rather than
@@ -46,7 +45,7 @@ import { classifyRole, canonicalMemberName, CANONICAL_MEMBER_NAME_PREFIXES } fro
46
45
  // (`.claude` -> `-.claude`); non-home paths are realpath-resolved (so `/tmp/x`,
47
46
  // a symlink to `/private/tmp/x` on macOS, encodes under the resolved name)
48
47
  // and double-dash-wrapped. Both forms are measured against real
49
- // `~/.omp/agent/sessions/*` directory names in member-record.test.mjs - a
48
+ // `~/.omp/agent/sessions/*` directory names - a
50
49
  // `~/dev/fleet-plugin`, `--private-tmp-fx685-scratch--` all exist on disk
51
50
  // today.
52
51
  export function encodeProjectDir(cwd, { home = process.env.HOME, realpath = realpathSync } = {}) {
@@ -81,7 +80,7 @@ export function normalizeModel(raw) {
81
80
 
82
81
  // A member's name is the only place its unit of work is recorded — there is
83
82
  // no dispatch sidecar at all (see ompMemberRecord). The one exception is a
84
- // PR the member OPENED (#2209): an `impl-<ticket>` name can never carry it,
83
+ // PR the member OPENED: an `impl-<ticket>` name can never carry it,
85
84
  // so ompMemberRecord reads it off the member's own `gh pr create` result
86
85
  // (createdPrNumbers) — this function still answers `pr: ""` for that name.
87
86
  //
@@ -91,7 +90,7 @@ export function normalizeModel(raw) {
91
90
  // members their join key to tier-outcomes.tsv. The fix-pr-<n> and
92
91
  // review-pr-<n> families share the first pattern only because the infix is
93
92
  // the same — they are NOT finisher spellings. `finisher-pr-<n>` is the
94
- // canonical name run-team now fixes (#326); the other three stay matched
93
+ // canonical name run-team now fixes; the other three stay matched
95
94
  // because the runs that used them are already in the record.
96
95
  //
97
96
  // merge-bot-<n> is deliberately excluded: its number is a per-run dispatch
@@ -101,7 +100,7 @@ export function normalizeModel(raw) {
101
100
  // re-dispatched member works the same unit. A trailing `-v<n>` (`-v2`,
102
101
  // `-v10`, ...) is a DIFFERENT spelling of the same re-dispatch, used when a
103
102
  // controller re-dispatches a finisher/reviewer against a PR whose head moved
104
- // after label (#1482, measured: ~67% of one finisher's tokens fell through
103
+ // after label (measured: ~67% of one finisher's tokens fell through
105
104
  // to a blank pr column under the old letter-only regex); it is stripped for
106
105
  // the same reason, not because it looks like a second-ticket suffix — no
107
106
  // naming convention in this repo otherwise uses a literal `-v` + digits
@@ -120,28 +119,28 @@ export function normalizeModel(raw) {
120
119
  // ticket the way `impl-<ticket>-<n>`'s can — there is only ever one PR per
121
120
  // such name. A trailing `-\d+` there (`finisher-pr-1440-2`, `fix-pr-1281-2`)
122
121
  // is the same re-dispatch retry the letter and `-v<n>` suffixes above
123
- // already cover, and is now stripped for PR-shaped names only (#1482,
124
- // measured: 476,202 cache-create tokens across 7 real rows fell through to a
122
+ // already cover, and is now stripped for PR-shaped names only
123
+ // (measured: 476,202 cache-create tokens across 7 real rows fell through to a
125
124
  // blank pr column this way — more than the 37,580 the `-v<n>` fix above
126
125
  // addressed). The ticket-shaped `impl-<ticket>-<n>` family above is
127
126
  // untouched: its second-ticket ambiguity is real, and a PR-shaped name's is
128
127
  // not.
129
128
  //
130
- // `resolve` joins the `-pr-` alternation for #1250: `resolve-pr-<n>` is a
129
+ // `resolve` joins the `-pr-` alternation: `resolve-pr-<n>` is a
131
130
  // controller-dispatched conflict/rebase resolver against an already-open PR
132
- // (measured session descriptions: "Resolve conflict on PR 1232", "Rebase
133
- // and resolve conflicts for PR #1310") — the same shape as `fix-pr-<n>`'s
131
+ // (measured session descriptions: "Resolve conflict on PR <n>", "Rebase
132
+ // and resolve conflicts for PR #<n>") — the same shape as `fix-pr-<n>`'s
134
133
  // applier and `review-pr-<n>`'s reviewer, just not a name run-team's own
135
134
  // naming convention fixes, so it stays out of the canonical list in
136
135
  // SKILL.md/member-lifecycle.md the same way `finish-<n>`/`finisher-<n>` do.
137
136
  // Unrecognised, a `resolve-pr-<n>` member fell through to `{ticket:"",
138
137
  // pr:""}`, losing its join key into tier-outcomes.tsv exactly the way an
139
- // unmatched finisher spelling once did (#1072).
138
+ // unmatched finisher spelling once did.
140
139
  //
141
140
  // Every pattern below is written against the lower-kebab spelling, so the
142
141
  // name is first rewritten by `canonicalMemberName` (compute-spend.mjs), the
143
142
  // same rewrite classifyRole applies: `Impl327` and `FixPr774` book the
144
- // ticket and PR their kebab forms do instead of blanks (#2396).
143
+ // ticket and PR their kebab forms do instead of blanks.
145
144
  export function parseMemberName(name) {
146
145
  const s = canonicalMemberName(name).replace(/-(?:[a-z]|v\d+)$/, "");
147
146
  let m = /^(?:fix|review|finish(?:er)?|resolve)-pr-(\d+)(?:-\d+)?$/.exec(s);
@@ -161,7 +160,7 @@ export function parseMemberName(name) {
161
160
  // lives in the DIRECTORY name, never per line; omp's envelope is
162
161
  // `{type,id,parentId,timestamp,message}`. That is the structural signature
163
162
  // this reader refuses on if it is ever absent — a validation CHECK inside
164
- // this reader, per #1302's ruling: every root this module is ever handed is
163
+ // this reader: every root this module is ever handed is
165
164
  // an omp `~/.omp/agent/sessions/**` tree, so a line failing this shape
166
165
  // signals a corrupted or foreign file, not a harness to dispatch to.
167
166
  //
@@ -185,7 +184,7 @@ function assertOmpShaped(d, filePath) {
185
184
  }
186
185
  }
187
186
 
188
- // `openedPrs` (#2209) is every PR number the member's OWN `gh pr create`
187
+ // `openedPrs` is every PR number the member's OWN `gh pr create`
189
188
  // printed — the source of `pr` for a member whose name carries none, which is
190
189
  // every implementer (`impl-<ticket>` names a ticket, never a PR). Chosen over
191
190
  // the ledger's `impl-<N>=PR#M` settle token because that token is written by a
@@ -195,8 +194,8 @@ function assertOmpShaped(d, filePath) {
195
194
  // transcripts printed a created PR from a `bash` toolCall whose result held
196
195
  // gh's stdout — the URL on a line of its own — then omp's "Wall time"
197
196
  // trailer. A create is also sometimes run via `eval` instead of `bash` —
198
- // measured on impl-1113 (a Python subprocess, `gh pr create` printing PR
199
- // #1535) — and stays blank there too: `eval` is not read, because a cell
197
+ // measured on impl-1113 (a Python subprocess, `gh pr create` printing a PR
198
+ // URL) — and stays blank there too: `eval` is not read, because a cell
200
199
  // that only READS transcripts (or `gh pr view`s) prints the very same URLs.
201
200
  //
202
201
  // Three filters keep a URL that is NOT a PR this member opened out:
@@ -205,7 +204,7 @@ function assertOmpShaped(d, filePath) {
205
204
  // keyword, optionally through `rtk`/`env`/`command`/`timeout <n>`, a
206
205
  // `NAME=value` prefix, or a path-qualified `.../gh` — a `gh pr view`/
207
206
  // `gh pr list` result prints bare PR URLs too, and `gh pr create` quoted
208
- // as text is no invocation (measured false-miss shapes: #2213 review);
207
+ // as text is no invocation (measured false-miss shapes);
209
208
  // - the URL must be a LINE of its own, the shape gh prints, never a URL
210
209
  // inside prose;
211
210
  // - a URL right after gh's "... already exists:" refusal is skipped: that PR
@@ -225,7 +224,7 @@ function assertOmpShaped(d, filePath) {
225
224
  // `view` copy of an "already exists:" URL is not caught by the one-line-back
226
225
  // skip above. Measured 0 real occurrences of any of these across the whole
227
226
  // local `~/.omp/agent/sessions/**/*.jsonl` corpus and every filled `pr` in
228
- // the committed TSV (#2213 review); accepted as known gaps rather than
227
+ // the committed TSV; accepted as known gaps rather than
229
228
  // hand-rolling shell-quote/heredoc awareness into a single regex.
230
229
  const GH_PR_CREATE_RE =
231
230
  /(?:^|[;&|(\n]|\b(?:if|then|do|!)\s)\s*(?:(?:env|command|rtk|timeout\s+\S+)\s+|[A-Za-z_]\w*=\S*\s+)*(?:\S*\/)?gh\s+pr\s+create\b/;
@@ -253,22 +252,22 @@ function createdPrNumbers(text) {
253
252
  // "cacheRead":0,"cacheWrite":0.0783,"total":0.0803}}}}
254
253
  //
255
254
  // `thinking` reads the harness-WRITTEN level, never the frontmatter — that
256
- // is what makes #1298's declared-vs-resolved comparison possible (#1302's
257
- // ruling). It stays `null` here (ompMemberRecord turns that into the
258
- // record's `-`) when no `thinking_level_change` line exists, rather than
255
+ // is what makes a declared-vs-resolved comparison possible. It stays `null`
256
+ // here (ompMemberRecord turns that into the record's `-`) when no `thinking_level_change` line exists, rather than
259
257
  // guessing the default: a member that is not a fleet definition genuinely
260
258
  // has no recorded level, and the hole must stay visible.
261
259
  //
262
260
  // `session_init.task` is the closest thing to a dispatch sidecar this
263
261
  // module has — there is no separate description file at all.
264
262
  // ompMemberRecord below uses it, together with the transcript's own nesting
265
- // depth AND the AgentId itself, as REAL classifyRole() signals. A
266
- // canonically-stemmed AgentId (`impl-<n>`, `fix-pr-<n>`, `finisher-<n>`,
267
- // `review-pr-<n>`, `merge-bot-<n>`) IS matched against classifyRole, as
268
- // `memberName`, per #1506; only a non-canonical AgentId — a generated word
269
- // pair that names nothing — is still never matched.
263
+ // depth AND the AgentId itself, as REAL classifyRole() signals. The AgentId
264
+ // is passed as `memberName` whatever its shape (the RULED paragraph below): a
265
+ // canonically-stemmed one (`impl-<n>`, `fix-pr-<n>`, `finisher-<n>`,
266
+ // `review-pr-<n>`, `merge-bot-<n>`) matches a name-driven branch, while a
267
+ // generated word pair that names nothing satisfies no hyphen-anchored or
268
+ // multi-word branch, so it falls through to the name-plus-description blend.
270
269
  //
271
- // `resolvedModelIdentity` (#1345) is `session_init`'s OWN field, written at
270
+ // `resolvedModelIdentity` is `session_init`'s OWN field, written at
272
271
  // DISPATCH — before the member's first assistant turn exists, which is what
273
272
  // makes it different from `model` above: a member still working folds to
274
273
  // `model: null` (no assistant turn yet) but already carries
@@ -280,14 +279,14 @@ function createdPrNumbers(text) {
280
279
  // per-turn value (which can in principle change mid-run) is the fact they
281
280
  // want, not the dispatch-time identity.
282
281
  //
283
- // `agent` (#1066) is `session_init`'s dispatch-time record of WHICH AGENT
282
+ // `agent` is `session_init`'s dispatch-time record of WHICH AGENT
284
283
  // DEFINITION this member is. Measured 2026-09-12 across real
285
284
  // `~/.omp/agent/sessions/**`: present on every one of the 1,191 transcripts
286
285
  // carrying a `session_init` line (`fleet-implementer` 51,
287
286
  // `fleet-implementer-alt` 17, the default `task` 220, plus the review
288
287
  // fan-out's own definitions), absent only where the line itself is.
289
288
  //
290
- // `entries` (#1717) is the per-agent tool stream compute-spend.mjs's
289
+ // `entries` is the per-agent tool stream compute-spend.mjs's
291
290
  // attributeTools reads: `assistant`/`result` entries. Measured 2026-09-26
292
291
  // across 5,085 real `~/.omp/agent/sessions/**/*.jsonl` files, the call is a
293
292
  // block on the assistant message and the result a line of its own:
@@ -323,7 +322,7 @@ function createdPrNumbers(text) {
323
322
  // reached the API its usage is all zero, and attributeTools books the batch
324
323
  // before it at 0.
325
324
  //
326
- // `openedPrs` (#2209) folds the `bash` calls that invoke `gh pr create`
325
+ // `openedPrs` folds the `bash` calls that invoke `gh pr create`
327
326
  // against their own results, via createdPrNumbers above — distinct PR
328
327
  // numbers in first-seen order.
329
328
  export function foldOmpTranscript(jsonlText, filePath) {
@@ -342,7 +341,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
342
341
  let d;
343
342
  // A torn tail (transcript read mid-write) is dropped, and costs at most
344
343
  // that one turn either way. A MIDDLE line failing the same parse is not
345
- // a live write in progress — it is lost data, and (#1717) it can desync
344
+ // a live write in progress — it is lost data, and it can desync
346
345
  // a toolCall from its toolResult, not just a turn's totals, so it is
347
346
  // counted, never for the last line.
348
347
  try { d = JSON.parse(raw); }
@@ -404,7 +403,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
404
403
  // One member record from one omp transcript's fold. `member` is the AgentId
405
404
  // (the filename stem, e.g. `InstallVerifySearch`) — there is no separate
406
405
  // display name, so `ticket`/`pr` extraction runs against it directly — except
407
- // a `pr` the name cannot carry, read off the fold's `openedPrs` (#2209).
406
+ // a `pr` the name cannot carry, read off the fold's `openedPrs`.
408
407
  //
409
408
  // `role` is NEVER guessed off the bare AgentId ALONE — a generated CamelCase
410
409
  // word pair names nothing classifyRole can read. FOUR real signals exist:
@@ -412,16 +411,16 @@ export function foldOmpTranscript(jsonlText, filePath) {
412
411
  // `session_init.task` (the dispatch prompt, when present), `spawnDepth` (the
413
412
  // transcript's own nesting depth, supplied by readOmpSession from the walk —
414
413
  // a fact about where the file lives, not a guess about what it is), and —
415
- // per #1506's ruling below — the AgentId itself, handed to classifyRole as
414
+ // per the RULED paragraph below — the AgentId itself, handed to classifyRole as
416
415
  // `memberName`. Depth matters on its own: classifyRole checks depth BEFORE
417
416
  // any text match, specifically so a nested member whose task happens to
418
- // read like a reviewer's ("Review PR 1353 correctness") still books as the
417
+ // read like a reviewer's ("Review PR <n> correctness") still books as the
419
418
  // fan-out specialist it structurally is, not a reviewer. None present
420
419
  // yields `"-"` — the same visible-hole spelling as `thinking`, never a
421
420
  // default like "other", which only makes sense where a real dispatch record
422
421
  // backs it.
423
422
  //
424
- // `agent` reaches classifyRole as its `agentDefinition` (#1486, #1505). It
423
+ // `agent` reaches classifyRole as its `agentDefinition`. It
425
424
  // is the same value the `subagent_type` column below already records, and
426
425
  // withholding it here made classifyRole's FIRST branch — the one whose
427
426
  // comment says memory-system work must "never land in review spend" —
@@ -431,7 +430,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
431
430
  // definition (`fleet-review-verifier`) split across four buckets on nothing
432
431
  // but how each prompt happened to read.
433
432
  //
434
- // #1506's gap: `memberName` used to be left unset here on the theory that
433
+ // The gap this closes: `memberName` used to be left unset here on the theory that
435
434
  // the AgentId genuinely is not a name — a generated CamelCase word pair
436
435
  // (`InstallVerifySearch`) names nothing the classifier can read. True for
437
436
  // the ordinary case, but false for members dispatched under run-team's own
@@ -441,7 +440,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
441
440
  // dispatched under the generic default `task` definition (`folded.agent` is
442
441
  // `undefined`), so the definition-based fix above cannot reach them, and
443
442
  // they fall through to prose classification of `folded.task` alone.
444
- // Measured against docs/metrics/member-outcomes.tsv (#1506): 478 omp rows
443
+ // Measured against docs/metrics/member-outcomes.tsv: 478 omp rows
445
444
  // carry such an AgentId, 52 of them booked `other` for want of this signal —
446
445
  // 31 `fix-pr-*`, 9 `impl-*` and 12 `merge-bot-*` members whose dispatch
447
446
  // prompt never happens to name the role.
@@ -452,9 +451,9 @@ export function foldOmpTranscript(jsonlText, filePath) {
452
451
  // multi-word phrase ("implement ticket", "review pr"), so a stray generated
453
452
  // word pair cannot coincidentally satisfy one; the one bare-word pattern,
454
453
  // `finisher`, carries that risk today and has not fired on the 4,574
455
- // sidecars measured for #1505. `OMP_CANONICAL_STEM_RE` below exists only to
454
+ // sidecars measured. `OMP_CANONICAL_STEM_RE` below exists only to
456
455
  // widen `hasRoleSignal` itself: a canonically-named member whose transcript
457
- // predates #1343 (no `session_init` line at all, so neither `task` nor
456
+ // predates `session_init` (no such line at all, so neither `task` nor
458
457
  // `agent` exist) still holds a readable identity and must not fall back to
459
458
  // the "-" hole. Measured 2026-09-16: zero such rows on disk today, but the
460
459
  // gate exists so the design does not assume that stays true forever. Built
@@ -471,7 +470,7 @@ export function ompMemberRecord(folded, agentStem, spawnDepth = 0) {
471
470
  const member = agentStem;
472
471
  const { ticket, pr: namedPr } = parseMemberName(member);
473
472
  // A name-carried PR (`fix-pr-<n>`, `finisher-<n>`, ...) is the member's unit
474
- // of work by construction and wins. Otherwise (#2209) the one PR its own
473
+ // of work by construction and wins. Otherwise the one PR its own
475
474
  // `gh pr create` printed; none, or more than one (measured: impl-1578 opened
476
475
  // its real PR plus a throwaway probe PR), stays blank — never a guess.
477
476
  // `folded.openedPrs` is `foldOmpTranscript`'s own unconditional return
@@ -487,11 +486,11 @@ export function ompMemberRecord(folded, agentStem, spawnDepth = 0) {
487
486
  role,
488
487
  member,
489
488
  model: folded.model,
490
- // Additive only (#1345) — `model` above stays the per-turn value
489
+ // Additive only — `model` above stays the per-turn value
491
490
  // board.mjs/member-outcomes.mjs already key cost/spend attribution on;
492
491
  // this is the dispatch-time identity `session_init` wrote before any
493
- // turn existed, `null` when the transcript predates #1343 or carries no
494
- // `session_init` line at all (never guessed).
492
+ // turn existed, `null` when the transcript carries no `session_init`
493
+ // line at all (never guessed).
495
494
  resolvedModelIdentity: folded.resolvedModelIdentity ?? null,
496
495
  thinking: folded.thinking ?? "-",
497
496
  // The dispatch record's own agent definition, `""` when the transcript
@@ -507,7 +506,7 @@ export function ompMemberRecord(folded, agentStem, spawnDepth = 0) {
507
506
 
508
507
  // ompMemberRecord straight from the transcript text. board.mjs's live spend
509
508
  // panel calls the two halves itself instead, because it needs the fold's tool
510
- // stream (#1717) beside the record, and folding the file twice for it would
509
+ // stream beside the record, and folding the file twice for it would
511
510
  // parse every transcript twice on every tick.
512
511
  export function readOmpMember(jsonlText, filePath, agentStem, spawnDepth = 0) {
513
512
  return ompMemberRecord(foldOmpTranscript(jsonlText, filePath), agentStem, spawnDepth);
@@ -515,7 +514,7 @@ export function readOmpMember(jsonlText, filePath, agentStem, spawnDepth = 0) {
515
514
 
516
515
  // One omp session directory's member transcripts, as the walk both readers of
517
516
  // that directory need it: readOmpSession below, and board.mjs's live spend
518
- // panel (#1716), which folds each file itself so it can keep its own
517
+ // panel, which folds each file itself so it can keep its own
519
518
  // per-transcript skip tally. RECURSIVE: a member can itself dispatch further
520
519
  // members (measured on disk — a research session's `Facts1303/` held seven
521
520
  // more `.jsonl` files one level down), and `agent` is the path-relative stem
@@ -539,9 +538,8 @@ export function ompSessionTranscripts(sessionDir) {
539
538
  //
540
539
  // Deliberately NOT wrapped in a blanket try/catch around readOmpMember: the
541
540
  // wrong-shape refusal (assertOmpShaped, inside foldOmpTranscript) must
542
- // propagate all the way out of readMembers, uncaught, per #1342's acceptance
543
- // criterion. Only the directory listing and the raw file read are given the
544
- // ordinary per-member tolerance.
541
+ // propagate all the way out of readMembers, uncaught. Only the directory
542
+ // listing and the raw file read are given the ordinary per-member tolerance.
545
543
  export function readOmpSession(sessionDir) {
546
544
  let transcripts;
547
545
  try { transcripts = ompSessionTranscripts(sessionDir); }
@@ -567,7 +565,7 @@ export function readOmpSession(sessionDir) {
567
565
  // per-session directories, so `findOmpSessionDirs` recursing from the
568
566
  // encoded-cwd dir hit the `.jsonl` test there first, returned the whole
569
567
  // project as "one session", stamped every row `session=-dev-fleet-plugin`
570
- // instead of the `<ISO>_<uuid>` name #1302 rules the row key on, and booked
568
+ // instead of the `<ISO>_<uuid>` name the row key is built on, and booked
571
569
  // the controller's own top-level transcripts as members. Name-only matching
572
570
  // costs nothing a real fixture needs: every fixture in this repo already
573
571
  // names its session dir in the real shape.
@@ -595,7 +593,7 @@ function findOmpSessionDirs(root) {
595
593
 
596
594
  // The adapter's public entry point. A root that resolves to no session
597
595
  // directory anywhere is refused rather than silently skipped: silently
598
- // returning no rows for a typo'd path is the blackout #1302's ruling exists
596
+ // returning no rows for a typo'd path is the blackout this refusal exists
599
597
  // to prevent.
600
598
  export function readMembers(roots) {
601
599
  const rows = [];
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { slowTransport, SSH_URL } from "./slow-transport.mjs";
19
19
  import { writeExecStub } from "./exec-stub.mjs";
20
+ import { hasAttribute, worktreeNames } from "./worktree-porcelain.mjs";
20
21
 
21
22
  const SCRIPT = fileURLToPath(new URL("./reap.sh", import.meta.url));
22
23
 
@@ -43,6 +44,9 @@ const ENV = {
43
44
  const git = (cwd, ...args) =>
44
45
  execFileSync("git", args, { cwd, env: ENV, encoding: "utf8" }).trim();
45
46
 
47
+ /** worktreeNames() of `w`'s listing: never its paths, which carry TMPDIR (#2531). */
48
+ const listedNames = (w) => worktreeNames(git(w, "worktree", "list", "--porcelain"));
49
+
46
50
  // Absolute path to the real git, for any test that shadows `git` on PATH.
47
51
  const REAL_GIT = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
48
52
 
@@ -746,14 +750,14 @@ test("a successful --apply run still reaps, still prunes, and exits 0 unchanged"
746
750
  const wtDir = join(w, "..", "stale-wt");
747
751
  git(w, "worktree", "add", "-q", "-b", "scratch/stale", wtDir, "main");
748
752
  rmSync(wtDir, { recursive: true, force: true });
749
- assert.match(git(w, "worktree", "list"), /stale-wt/, "fixture must start with a prunable registration");
753
+ assert.ok(listedNames(w).includes("stale-wt"), "fixture must start with a prunable registration");
750
754
 
751
755
  const { code, json } = runReap(w, ["--apply"]);
752
756
 
753
757
  assert.equal(code, 0);
754
758
  assert.deepEqual(json.reaped, ["feature/merged"]);
755
759
  assert.deepEqual(json.kept, []);
756
- assert.doesNotMatch(git(w, "worktree", "list"), /stale-wt/, "the prune must still run and clear the stale registration");
760
+ assert.ok(!listedNames(w).includes("stale-wt"), "the prune must still run and clear the stale registration");
757
761
  });
758
762
 
759
763
  // The other half of the same guard, and the half no test had: the default
@@ -1307,7 +1311,7 @@ test("a [gone] branch whose worktree directory was deleted by hand is reaped, ne
1307
1311
  const w = repo(t);
1308
1312
  const wt = mergedGoneBranchWithWorktree(w, "feature/merged", "merged work");
1309
1313
  rmSync(wt, { recursive: true, force: true });
1310
- assert.match(git(w, "worktree", "list"), /feature\/merged/, "fixture: the stale registration must still be listed");
1314
+ assert.ok(listedNames(w).includes("feature/merged"), "fixture: the stale registration must still be listed");
1311
1315
 
1312
1316
  const { code, json } = runReap(w, ["--apply"]);
1313
1317
 
@@ -1315,7 +1319,7 @@ test("a [gone] branch whose worktree directory was deleted by hand is reaped, ne
1315
1319
  assert.deepEqual(json.reaped, ["feature/merged"], "a directory that is not there holds no work to protect");
1316
1320
  assert.deepEqual(json.kept, []);
1317
1321
  assert.equal(branchExists(w, "feature/merged"), false);
1318
- assert.doesNotMatch(git(w, "worktree", "list"), /feature\/merged/, "the stale registration must not survive the run");
1322
+ assert.ok(!listedNames(w).includes("feature/merged"), "the stale registration must not survive the run");
1319
1323
  });
1320
1324
 
1321
1325
  test("a dry run and --apply agree about a deleted worktree directory (#128)", (t) => {
@@ -2010,7 +2014,7 @@ test("a refusal that LEFT the registration in place is reported as such, distinc
2010
2014
  assert.doesNotMatch(json.kept[0].reason, /\n/, "git's message here really is two lines — it must arrive flattened");
2011
2015
  assert.match(stderr, /KEEP feature\/merged/);
2012
2016
 
2013
- assert.match(git(w, "worktree", "list", "--porcelain"), /feature\/merged/, "the registration really did survive");
2017
+ assert.ok(listedNames(w).includes("feature/merged"), "the registration really did survive");
2014
2018
  assert.equal(existsSync(wt), true);
2015
2019
  assert.equal(branchExists(w, "feature/merged"), true);
2016
2020
  });
@@ -2543,9 +2547,9 @@ test("the dry run removes nothing, and still cannot predict a refusal (#391)", (
2543
2547
  // Nothing was touched: directories, registrations and branches all intact.
2544
2548
  assert.equal(existsSync(healthy), true, "a dry run must not remove the worktree it COULD have removed");
2545
2549
  assert.equal(existsSync(locked), true);
2546
- const reg = git(w, "worktree", "list", "--porcelain");
2547
- assert.match(reg, /feature\/a-healthy/);
2548
- assert.match(reg, /feature\/b-locked/);
2550
+ const reg = listedNames(w);
2551
+ assert.ok(reg.includes("feature/a-healthy"));
2552
+ assert.ok(reg.includes("feature/b-locked"));
2549
2553
  assert.equal(branchExists(w, "feature/a-healthy"), true);
2550
2554
  assert.equal(branchExists(w, "feature/b-locked"), true);
2551
2555
  });
@@ -2663,7 +2667,8 @@ test("a detached worktree whose HEAD is merged is removed, not walked past (#381
2663
2667
  const w = repo(t);
2664
2668
  const wt = detachedMergedWorktree(w, "docs/79-brief", "work that landed");
2665
2669
  assert.ok(existsSync(wt), "fixture");
2666
- assert.match(git(w, "worktree", "list"), /\(detached HEAD\)/, "fixture: the worktree must really be detached");
2670
+ assert.ok(hasAttribute(git(w, "worktree", "list", "--porcelain"), "detached"), "fixture: the worktree must really be detached");
2671
+ assert.ok(listedNames(w).includes("79-brief"), "fixture: the worktree must start out listed");
2667
2672
 
2668
2673
  const { code, json, stderr } = runReap(w, ["--apply"]);
2669
2674
 
@@ -2674,7 +2679,7 @@ test("a detached worktree whose HEAD is merged is removed, not walked past (#381
2674
2679
  assert.ok(stderr.includes(` REMOVED worktree ${wt}`), `the removal must be reported: ${stderr}`);
2675
2680
  // The registration goes with it, or the in-flight probe still reads the
2676
2681
  // ticket as taken — the whole cost this ticket exists to stop.
2677
- assert.doesNotMatch(git(w, "worktree", "list", "--porcelain"), /79-brief/);
2682
+ assert.ok(!listedNames(w).includes("79-brief"));
2678
2683
  });
2679
2684
 
2680
2685
  test("a branchless sweep whose awk cannot read the listing declines, never sweeps silently (#993)", (t) => {
@@ -2798,7 +2803,7 @@ test("a detached worktree whose HEAD git cannot resolve is kept, never swept (#3
2798
2803
  writeFileSync(join(adminEntry(w, wt), "HEAD"), "not an object id\n");
2799
2804
  assert.match(
2800
2805
  git(w, "worktree", "list", "--porcelain"),
2801
- /HEAD 0{40}/,
2806
+ /^HEAD 0{40}$/m,
2802
2807
  "fixture: git must report the null object id for this worktree",
2803
2808
  );
2804
2809
 
@@ -2958,14 +2963,15 @@ test("a detached worktree whose directory was deleted by hand still has its regi
2958
2963
  const w = repo(t);
2959
2964
  const wt = detachedMergedWorktree(w, "docs/79-brief", "work that landed");
2960
2965
  rmSync(wt, { recursive: true, force: true });
2961
- assert.match(git(w, "worktree", "list", "--porcelain"), /prunable/, "fixture: the stale registration must still be listed");
2966
+ assert.ok(hasAttribute(git(w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the stale registration must still be listed");
2967
+ assert.ok(listedNames(w).includes("79-brief"), "fixture: the stale registration must be listed by name before the run");
2962
2968
 
2963
2969
  const { code, json } = runReap(w, ["--apply"]);
2964
2970
 
2965
2971
  assert.equal(code, 0);
2966
2972
  assert.deepEqual(json.worktreesRemoved, [wt]);
2967
2973
  assert.deepEqual(json.kept, []);
2968
- assert.doesNotMatch(git(w, "worktree", "list", "--porcelain"), /79-brief/);
2974
+ assert.ok(!listedNames(w).includes("79-brief"));
2969
2975
  });
2970
2976
 
2971
2977
  test("a detached worktree behind an unreadable parent is kept, never removed as absent (#381, #83)", (t) => {
@@ -22,6 +22,7 @@ import { dirname, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { slowTransport, SSH_URL } from "./slow-transport.mjs";
24
24
  import { writeExecStub } from "./exec-stub.mjs";
25
+ import { hasAttribute } from "./worktree-porcelain.mjs";
25
26
 
26
27
  const SCRIPT = fileURLToPath(new URL("./release-ticket.sh", import.meta.url));
27
28
  const INFLIGHT = fileURLToPath(new URL("./inflight.sh", import.meta.url));
@@ -994,6 +995,7 @@ test("the locked-stray unlock hint survives a literal ' in the worktree path (#1
994
995
  git(c.wt, "checkout", "-q", "--detach", "HEAD");
995
996
  git(r.w, "worktree", "lock", c.wt, "--reason", "held by a review");
996
997
  rmSync(c.wt, { recursive: true, force: true });
998
+ assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"), "fixture: the stray must really be locked before the remedy runs");
997
999
 
998
1000
  const { code, json, stderr } = release(r, c);
999
1001
  assert.equal(code, 1, stderr);
@@ -1005,7 +1007,7 @@ test("the locked-stray unlock hint survives a literal ' in the worktree path (#1
1005
1007
  execFileSync("sh", ["-c", remedy], { cwd: r.w, encoding: "utf8", env: ENV });
1006
1008
 
1007
1009
  assert.ok(
1008
- !git(r.w, "worktree", "list", "--porcelain").includes("locked"),
1010
+ !hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"),
1009
1011
  `the pasted command actually unlocked the real entry, not a truncated one: ${remedy}`,
1010
1012
  );
1011
1013
  });
@@ -1034,6 +1036,7 @@ test("the locked-stray unlock hint survives a plain space in the worktree path (
1034
1036
  git(c.wt, "checkout", "-q", "--detach", "HEAD");
1035
1037
  git(r.w, "worktree", "lock", c.wt, "--reason", "held by a review");
1036
1038
  rmSync(c.wt, { recursive: true, force: true });
1039
+ assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"), "fixture: the stray must really be locked before the remedy runs");
1037
1040
 
1038
1041
  const { code, json, stderr } = release(r, c);
1039
1042
  assert.equal(code, 1, stderr);
@@ -1045,7 +1048,7 @@ test("the locked-stray unlock hint survives a plain space in the worktree path (
1045
1048
  execFileSync("sh", ["-c", remedy], { cwd: r.w, encoding: "utf8", env: ENV });
1046
1049
 
1047
1050
  assert.ok(
1048
- !git(r.w, "worktree", "list", "--porcelain").includes("locked"),
1051
+ !hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"),
1049
1052
  `the pasted command actually unlocked the real entry, not a truncated one: ${remedy}`,
1050
1053
  );
1051
1054
  });
@@ -2732,7 +2735,7 @@ test("an unrelated prunable worktree does not block release of a branch it never
2732
2735
  const stale = join(r.w, "..", "stale-wt");
2733
2736
  git(r.w, "worktree", "add", "-q", "--detach", stale, "origin/main");
2734
2737
  rmSync(stale, { recursive: true, force: true });
2735
- assert.match(git(r.w, "worktree", "list", "--porcelain"), /prunable/, "fixture: the stale registration must still be listed");
2738
+ assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the stale registration must still be listed");
2736
2739
 
2737
2740
  const { code, json, stderr } = release(r, c);
2738
2741
  assert.equal(code, 0, stderr);
@@ -2748,7 +2751,7 @@ test("a prunable sibling that genuinely holds the claim via a stopped rebase sti
2748
2751
  // no longer exists to be entered.
2749
2752
  const { r, c, sib } = heldSibling(t, stopAtEditRebase);
2750
2753
  rmSync(sib, { recursive: true, force: true });
2751
- assert.match(git(r.w, "worktree", "list", "--porcelain"), /prunable/, "fixture: the sibling's directory must be gone, its registration still there");
2754
+ assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the sibling's directory must be gone, its registration still there");
2752
2755
 
2753
2756
  const { code, json, stderr } = release(r, c);
2754
2757
  assert.equal(code, 2, stderr);
@@ -3605,9 +3608,8 @@ test("a worktree with no surviving ancestor below / still releases, never a perm
3605
3608
  // died BEFORE it, so a fix that merely downgraded that `die` to a warning
3606
3609
  // would exit 0 here too, with the stale registration still standing and the
3607
3610
  // claim reported released — the same false success from the other side.
3608
- assert.doesNotMatch(
3609
- git(r.w, "worktree", "list", "--porcelain"),
3610
- /nonexistent-top-level-178/,
3611
+ assert.ok(
3612
+ !git(r.w, "worktree", "list", "--porcelain").split("\n").includes(`worktree ${dest}`),
3611
3613
  "the registration must actually be gone, not merely un-refused",
3612
3614
  );
3613
3615
  });
@@ -0,0 +1,35 @@
1
+ // What a test guard may ask of a `git worktree list --porcelain` listing.
2
+ //
3
+ // Every `worktree` line carries the full path, and a fixture's path starts
4
+ // with the operator's TMPDIR, so a guard that greps the whole listing for a
5
+ // fixture's name or for an attribute answers for TMPDIR as well: one holding
6
+ // `locked`, `stale-wt` or `79-brief` failed the guard that looks for its
7
+ // absence (#2531). These read only the parts a fixture controls.
8
+
9
+ /**
10
+ * The names the listing gives its worktrees: each `worktree` line's last path
11
+ * component, and each `branch refs/heads/` line's branch. The directories
12
+ * above a worktree are never a name.
13
+ */
14
+ export const worktreeNames = (porcelain) =>
15
+ porcelain.split("\n").flatMap((l) =>
16
+ l.startsWith("worktree ") ? [l.slice("worktree ".length).replace(/.*\//, "")]
17
+ : l.startsWith("branch refs/heads/") ? [l.slice("branch refs/heads/".length)]
18
+ : []);
19
+
20
+ const ATTRIBUTES = new Set(["locked", "prunable", "detached"]);
21
+
22
+ /**
23
+ * Whether any worktree in the listing carries the attribute `attr`
24
+ * (`locked`, `prunable`, `detached`). git prints an attribute on a line of its
25
+ * own, the bare word or the word, a space and a reason, so the line is matched
26
+ * whole and a path containing the word never is. Any other `attr` throws: a
27
+ * misspelling would answer false for ever, and a guard that asserts the
28
+ * attribute's absence would pass on it.
29
+ */
30
+ export const hasAttribute = (porcelain, attr) => {
31
+ if (!ATTRIBUTES.has(attr)) {
32
+ throw new Error(`hasAttribute: unknown attribute ${JSON.stringify(attr)}; one of ${[...ATTRIBUTES].join(", ")}`);
33
+ }
34
+ return porcelain.split("\n").some((l) => l === attr || l.startsWith(`${attr} `));
35
+ };