dsh-git-badge 0.7.0 → 0.9.0

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.
package/lib/index.js CHANGED
@@ -44,9 +44,14 @@
44
44
  * on the first refresh after each TTL window. `GIT_TERMINAL_PROMPT=0`, the
45
45
  * shared timeout and the TTL bound keep an offline or slow remote harmless;
46
46
  * on failure the stale-ref answer simply stands.
47
- * - `detail=1` adds one `log -3` plus a stash count. NOTE: no surface
48
- * consumes this yet (see the annotation on gitStatus) — it is kept for
49
- * the planned hover card, and never paid for by row refresh.
47
+ * - `detail=1` adds one `log -3` plus a stash count, and `pr=1` adds the
48
+ * branch's GitHub PR/CI state read through the user's own `gh` CLI. Both
49
+ * serve the input chip only — the hover card and the PR token — so a
50
+ * sidebar row refresh pays for neither. The PR read is TTL-bounded and
51
+ * out of band, like the fetch; it degrades to an ABSENT field rather than
52
+ * an error whenever `gh` cannot answer (see the PR region).
53
+ * - `pr=1` requires `gh` on PATH and authenticated for a token to appear.
54
+ * The plugin never handles the credential: `gh` owns it.
50
55
  *
51
56
  * GET /api/git-badge/events (text/event-stream)
52
57
  * - pushes `{ path }` dirty notifications the moment a workspace's git
@@ -84,6 +89,15 @@ const config = {
84
89
  fetchTtlMs: 60000,
85
90
  /** fs-event burst collapse window */
86
91
  debounceMs: 200,
92
+ /**
93
+ * Coalescing window for identical status reads. 0 (the default) collapses only
94
+ * CONCURRENT identical reads — which is exactly what a burst of sidebar
95
+ * session rows mounting together produces — and leaves sequential reads
96
+ * honest, so a caller that mutates a repository and asks again always sees the
97
+ * new state. Raise it to also absorb serial bursts, at the cost of serving a
98
+ * change up to that many milliseconds late.
99
+ */
100
+ statusCacheMs: 0,
87
101
  /** backoff before re-attempting a failed watcher (or a missing .git) */
88
102
  watchRetryMs: 60000,
89
103
  /**
@@ -100,23 +114,63 @@ const config = {
100
114
  * collapsed-fallback branch deterministically — a real sub-millisecond budget
101
115
  * would just race git's own startup.
102
116
  */
103
- gitRunner: null
117
+ gitRunner: null,
118
+ /**
119
+ * PR / CI status via the `gh` CLI: "auto" reads it where `gh` can answer and
120
+ * silently shows nothing where it cannot; "off" never invokes `gh` at all.
121
+ * Only the input chip asks for it, so a sidebar-only view spawns no forge
122
+ * process — see the PR region.
123
+ */
124
+ prStatus: "auto",
125
+ /** minimum interval between forge refreshes per repository */
126
+ prTtlMs: 90000,
127
+ /**
128
+ * Forge call budget. Unlike git this is a network round trip to GitHub, so
129
+ * it gets a more generous budget than the status sample — but it is bounded
130
+ * all the same, because nothing here may hang the route.
131
+ */
132
+ prTimeoutMs: 6000,
133
+ /**
134
+ * Invoker for the `gh` CLI; null means the real runCli. The suite substitutes
135
+ * one so every degradation path (no gh, logged out, no PR, garbage output,
136
+ * timeout) is covered with no gh, no network and no forge.
137
+ */
138
+ prRunner: null,
139
+ /**
140
+ * Bound on the repository-wide open-PR read (`gh pr list`). Only one page is
141
+ * read, so a repository with more open PRs than this simply has the excess
142
+ * invisible to worktree selection; the checkout's own PR read is unaffected.
143
+ */
144
+ prListLimit: 50,
145
+ /**
146
+ * Worktree-aware status: "auto" lets a SESSION-targeted badge follow the one
147
+ * linked worktree whose branch has an open PR, so a conversation doing its
148
+ * work in a worktree reports that tree instead of the main checkout its
149
+ * directory names; "off" always reports the session's own directory. A
150
+ * workspace-targeted row never follows a worktree whatever this is set to — a
151
+ * row surveys the checkout the registry owns. See effectiveTarget.
152
+ */
153
+ worktreeStatus: "auto"
104
154
  };
105
155
 
106
156
  /**
107
- * Run git in dir. Resolves { stdout } on success; on failure { stdout: null }
108
- * plus exactly one of: timeout (execFile timeout kill), missing (no git
109
- * binary), or exitCode (git ran and rejected — e.g. "not a repository").
110
- * The exitCode/timeout distinction separates a definitive answer (non-repo)
111
- * from a transient one (degraded).
157
+ * Run an external command in a directory. Resolves { stdout } on success; on
158
+ * failure { stdout: null } plus exactly one of: timeout (execFile timeout kill),
159
+ * missing (no such binary), or exitCode (the command ran and rejected — e.g.
160
+ * "not a repository", or `gh` reporting no pull request).
161
+ *
162
+ * The exitCode/timeout/missing trichotomy is what lets a CALLER tell a
163
+ * definitive answer from a transient one: a missing `gh` and a `gh` that says
164
+ * "no PR" are different facts, and only the caller knows which of them it can
165
+ * act on. Shared by git and `gh` so neither grows its own timeout semantics.
112
166
  */
113
- function runGit(dir, args, opts) {
114
- const { env, timeout = config.gitTimeoutMs } = opts ?? {};
167
+ function runCli(cmd, args, opts) {
168
+ const { cwd, env, timeout = config.gitTimeoutMs } = opts ?? {};
115
169
  // env is a full replacement for execFile; overlay onto process.env so
116
170
  // PATH/HOME (credential helpers, global config) survive
117
171
  const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
118
172
  return new Promise((resolve) => {
119
- execFile("git", args, { cwd: dir, timeout, env: fullEnv }, (error, stdout) => {
173
+ execFile(cmd, args, { cwd, timeout, env: fullEnv }, (error, stdout) => {
120
174
  if (error === void 0 || error === null) resolve({ stdout: String(stdout) });
121
175
  else if (error.killed) resolve({ stdout: null, timeout: true });
122
176
  else if (error.code === "ENOENT") resolve({ stdout: null, missing: true });
@@ -125,6 +179,15 @@ function runGit(dir, args, opts) {
125
179
  });
126
180
  }
127
181
 
182
+ /**
183
+ * Run git in dir. A thin specialization of runCli: the contract above is
184
+ * unchanged, and `config.gitRunner` still substitutes for the whole call so the
185
+ * suite can force the timeout branch.
186
+ */
187
+ function runGit(dir, args, opts) {
188
+ return runCli("git", args, { ...opts, cwd: dir });
189
+ }
190
+
128
191
  /** Marker response for a git invocation that failed or timed out (transient). */
129
192
  const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
130
193
 
@@ -178,6 +241,279 @@ async function maybeFetch(toplevel) {
178
241
  }
179
242
  //#endregion
180
243
 
244
+ //#region PR / CI status (gh)
245
+ /**
246
+ * GitHub pull-request and check state for the current branch, read through the
247
+ * user's own `gh` CLI.
248
+ *
249
+ * Why the CLI and not the REST API: `gh` already owns the user's credentials in
250
+ * its own config, so this plugin never reads, stores, caches or forwards a
251
+ * token. There is no secret here to leak and no OAuth flow to maintain — and a
252
+ * machine that has never authenticated `gh` simply gets no PR token.
253
+ *
254
+ * Degradation is a MISSING FIELD, never an error. A machine without `gh`, a
255
+ * logged-out `gh`, a remote that is not GitHub, an offline network, a forge rate
256
+ * limit, unparseable output and a timed-out call all produce exactly the same
257
+ * response: no `pr` key at all. That is the honest answer, because in every one
258
+ * of those cases the badge has nothing to say — and a caller that could tell
259
+ * "no PR" from "no gh" would still not know what to draw.
260
+ *
261
+ * Like the fetch above this is TTL-bounded and NEVER awaited by the route: the
262
+ * cached value is served immediately and a refresh that CHANGES it notifies
263
+ * subscribers, so the request that lapses the window carries the previous answer
264
+ * and the follow-up carries the new one. A forge round trip must never become
265
+ * badge latency.
266
+ */
267
+ /** toplevel -> { lastAttemptAt, inFlight, value }; `value` undefined = nothing to say. */
268
+ const prState = new Map();
269
+
270
+ /** Test seam: the CLI invoker `gh` calls go through, so no test needs `gh`. */
271
+ function runPrCli(cmd, args, opts) {
272
+ return (config.prRunner ?? runCli)(cmd, args, opts);
273
+ }
274
+
275
+ /**
276
+ * Does `origin` point at github.com? A cheap LOCAL call, so a non-GitHub repo
277
+ * never pays for a `gh` process. Deliberately scoped to origin: that is the
278
+ * remote `gh` itself resolves the repository from, so agreeing with it here is
279
+ * what keeps the two from disagreeing later.
280
+ */
281
+ async function originIsGitHub(toplevel) {
282
+ const out = await runGit(toplevel, ["remote", "get-url", "origin"]);
283
+ if (out.stdout === null) return false;
284
+ return /(^|[/@.])github\.com[:/]/i.test(out.stdout.trim());
285
+ }
286
+
287
+ /**
288
+ * Reduce a `statusCheckRollup` to ONE worst-case state, so the chip needs a
289
+ * single colour rather than a list.
290
+ *
291
+ * Two entry shapes share the array: a CheckRun carries `status` + `conclusion`,
292
+ * a StatusContext carries `state` alone. A single failure outweighs any number
293
+ * of successes and pending checks — "mostly passing" is not a thing a red/green
294
+ * token can express — so failure returns immediately and pending only wins over
295
+ * an otherwise all-successful set.
296
+ */
297
+ function summarizeChecks(checks) {
298
+ let sawAny = false;
299
+ let sawPending = false;
300
+ for (const check of checks) {
301
+ const state = String(check?.state ?? "").toUpperCase();
302
+ const status = String(check?.status ?? "").toUpperCase();
303
+ const conclusion = String(check?.conclusion ?? "").toUpperCase();
304
+ sawAny = true;
305
+ const failed =
306
+ ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(state) ||
307
+ ["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion);
308
+ if (failed) return "failing";
309
+ // unfinished: a CheckRun that has not completed, or a StatusContext PENDING
310
+ if (state === "PENDING" || (status !== "" && status !== "COMPLETED")) sawPending = true;
311
+ }
312
+ if (!sawAny) return "none";
313
+ return sawPending ? "pending" : "passing";
314
+ }
315
+
316
+ /** The compact PR shape the chip renders, or undefined when there is nothing to say. */
317
+ function summarizePr(json) {
318
+ if (json === null || typeof json !== "object") return void 0;
319
+ const number = Number.isFinite(json?.number) ? json.number : void 0;
320
+ if (number === void 0) return void 0;
321
+ const pr = {
322
+ number,
323
+ state: summarizeChecks(Array.isArray(json.statusCheckRollup) ? json.statusCheckRollup : []),
324
+ // draft and review state are separate from CI: a draft with green checks
325
+ // is not ready, and a blocked review is not a failing build
326
+ draft: json.isDraft === true,
327
+ open: String(json.state ?? "OPEN").toUpperCase() === "OPEN"
328
+ };
329
+ // omitted, not present-and-undefined: absence is the contract for "nothing to
330
+ // say" throughout this half, so a consumer tests the key, never the value
331
+ if (typeof json.reviewDecision === "string" && json.reviewDecision !== "") pr.review = json.reviewDecision;
332
+ // GitHub's OWN merge verdict (CLEAN / BLOCKED / BEHIND / DIRTY / DRAFT /
333
+ // UNSTABLE / HAS_HOOKS / UNKNOWN), passed through rather than re-derived here:
334
+ // whether a PR is mergeable depends on branch protection and required reviews,
335
+ // which is the forge's business and not this plugin's to interpret. `gh pr list`
336
+ // does not ask for it, so a worktree-candidate entry simply has no `mergeState`
337
+ // — absence is the contract, as everywhere else in this half.
338
+ if (typeof json.mergeStateStatus === "string" && json.mergeStateStatus !== "") pr.mergeState = json.mergeStateStatus.toUpperCase();
339
+ // The chip links the token to the PR, so `url` is the one field that becomes an
340
+ // `href`: only http(s) is emitted, and anything else is omitted like every
341
+ // other "nothing to say" field, so a payload value cannot reach an anchor as a
342
+ // `javascript:` URL. The client re-checks before it builds the href — this half
343
+ // is the authority on what it vouches for, not the only guard.
344
+ if (typeof json.url === "string" && json.url !== "") {
345
+ try {
346
+ const { protocol } = new URL(json.url);
347
+ if (protocol === "http:" || protocol === "https:") pr.url = json.url;
348
+ } catch {
349
+ // not a URL at all — omitted, exactly like an absent one
350
+ }
351
+ }
352
+ return pr;
353
+ }
354
+
355
+ /**
356
+ * Read PR state for `branch`, or undefined. NEVER throws and never rejects: a
357
+ * forge is an optional luxury, so every failure path is "nothing to say".
358
+ */
359
+ async function readPrStatus(toplevel, branch) {
360
+ // a detached HEAD is not a branch `gh` can resolve a PR for
361
+ if (typeof branch !== "string" || branch === "" || branch.startsWith("HEAD")) return void 0;
362
+ if (!(await originIsGitHub(toplevel))) return void 0;
363
+ const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url,mergeStateStatus"], {
364
+ cwd: toplevel,
365
+ timeout: config.prTimeoutMs,
366
+ // GH_PROMPT_DISABLED: an auth prompt must never hang the refresh (the gh
367
+ // analogue of the git fetch's GIT_TERMINAL_PROMPT=0). The pager vars keep
368
+ // gh from waiting on a pager that will never answer, and NO_COLOR keeps
369
+ // escape codes out of the JSON.
370
+ env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
371
+ });
372
+ if (out.stdout === null) return void 0;
373
+ let json;
374
+ try {
375
+ json = JSON.parse(out.stdout);
376
+ } catch {
377
+ return void 0;
378
+ }
379
+ return summarizePr(json);
380
+ }
381
+
382
+ /** toplevel -> { lastAttemptAt, inFlight, value: Map<branch, pr> }. */
383
+ const prListState = new Map();
384
+
385
+ /**
386
+ * Every OPEN pull request in `toplevel`'s repository, keyed by its head branch.
387
+ * ONE `gh pr list` answers the whole repository, which is what makes worktree
388
+ * selection affordable: N worktrees cost one forge call, not N.
389
+ *
390
+ * Degrades exactly like readPrStatus, and for the same reason: a non-array body,
391
+ * unparseable output, a logged-out `gh`, a non-GitHub `origin` and a timeout all
392
+ * resolve an empty map. "No PRs" and "no forge" are the same answer here — both
393
+ * mean no worktree can be shown to be the one the session is working in.
394
+ */
395
+ async function readOpenPrs(toplevel) {
396
+ if (!(await originIsGitHub(toplevel))) return new Map();
397
+ const out = await runPrCli("gh", ["pr", "list", "--state", "open", "--limit", String(config.prListLimit), "--json", "number,headRefName,state,isDraft,reviewDecision,statusCheckRollup,url"], {
398
+ cwd: toplevel,
399
+ timeout: config.prTimeoutMs,
400
+ env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
401
+ });
402
+ if (out.stdout === null) return new Map();
403
+ let json;
404
+ try {
405
+ json = JSON.parse(out.stdout);
406
+ } catch {
407
+ return new Map();
408
+ }
409
+ if (!Array.isArray(json)) return new Map();
410
+ const byBranch = new Map();
411
+ for (const row of json) {
412
+ const branch = row?.headRefName;
413
+ if (typeof branch !== "string" || branch === "") continue;
414
+ const pr = summarizePr(row);
415
+ if (pr !== void 0) byBranch.set(branch, pr);
416
+ }
417
+ return byBranch;
418
+ }
419
+
420
+ /** Order-insensitive identity for an open-PR set, so "did it change?" is exact. */
421
+ function prListKey(byBranch) {
422
+ if (byBranch === void 0) return "";
423
+ return JSON.stringify([...byBranch].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
424
+ }
425
+
426
+ /**
427
+ * Open PRs for toplevel: the value to serve NOW (possibly undefined), spawning an
428
+ * out-of-band refresh when the TTL has lapsed — the same shape and the same
429
+ * reasoning as prStatusFor, because the forge is never route latency.
430
+ *
431
+ * `notify` fires only when the set CHANGES: a changed set can change which
432
+ * worktree a session badge follows, so the client has to be told to refetch
433
+ * rather than waiting for its 60s safety-net poll.
434
+ */
435
+ function openPrsFor(toplevel, notify) {
436
+ if (config.prStatus === "off") return void 0;
437
+ const now = Date.now();
438
+ const state = prListState.get(toplevel);
439
+ if (state !== void 0) {
440
+ // Fresh, or a refresh is already running: serve what we have either way.
441
+ if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
442
+ }
443
+ const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
444
+ const inFlight = (async () => {
445
+ const value = await readOpenPrs(toplevel).catch(() => void 0);
446
+ if (value !== void 0) {
447
+ const changed = prListKey(record.value) !== prListKey(value);
448
+ record.value = value;
449
+ if (changed) notify(toplevel);
450
+ }
451
+ record.lastAttemptAt = Date.now();
452
+ record.inFlight = null;
453
+ return value;
454
+ })();
455
+ record.inFlight = inFlight;
456
+ prListState.set(toplevel, record);
457
+ return record.value;
458
+ }
459
+
460
+ /**
461
+ * The one linked worktree a session badge may follow: exactly one entry other
462
+ * than the checkout itself that is checked out on a branch with an OPEN PR.
463
+ *
464
+ * Exactly one, because the plugin cannot know which tree a session uses — DSH
465
+ * records no session→worktree link at all — so an ambiguous repository stays on
466
+ * its own checkout rather than guessing. A second open PR is a reason to say
467
+ * nothing, not a reason to pick one. Detached, bare and prunable entries are
468
+ * never candidates: they have no branch a PR could belong to.
469
+ */
470
+ function selectSessionWorktree(worktrees, openPrs, checkoutPath) {
471
+ if (openPrs === void 0 || openPrs.size === 0) return void 0;
472
+ const candidates = worktrees.filter((wt) =>
473
+ wt.path !== checkoutPath &&
474
+ wt.detached !== true &&
475
+ wt.bare !== true &&
476
+ wt.prunable !== true &&
477
+ typeof wt.branch === "string" &&
478
+ openPrs.has(wt.branch)
479
+ );
480
+ return candidates.length === 1 ? candidates[0] : void 0;
481
+ }
482
+
483
+ /**
484
+ * PR state for toplevel: the value to serve NOW (possibly undefined, possibly
485
+ * stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
486
+ * called only when the refreshed value CHANGES, so an unchanged answer costs one
487
+ * forge call per TTL and zero SSE traffic.
488
+ *
489
+ * Keyed by BRANCH as well as toplevel: a checkout inside the TTL window would
490
+ * otherwise be served the PR of the branch it was on when the window opened.
491
+ */
492
+ function prStatusFor(toplevel, branch, notify) {
493
+ if (config.prStatus === "off") return void 0;
494
+ const now = Date.now();
495
+ const key = toplevel + "\u0000" + branch;
496
+ const state = prState.get(key);
497
+ if (state !== void 0) {
498
+ // Fresh, or a refresh is already running: serve what we have either way.
499
+ if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
500
+ }
501
+ const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
502
+ const inFlight = (async () => {
503
+ const value = await readPrStatus(toplevel, branch).catch(() => void 0);
504
+ const changed = JSON.stringify(record.value ?? null) !== JSON.stringify(value ?? null);
505
+ record.lastAttemptAt = Date.now();
506
+ record.inFlight = null;
507
+ record.value = value;
508
+ if (changed) notify(toplevel);
509
+ return value;
510
+ })();
511
+ record.inFlight = inFlight;
512
+ prState.set(key, record);
513
+ return record.value;
514
+ }
515
+ //#endregion
516
+
181
517
  /**
182
518
  * Parse `git status --porcelain=v2 --branch` output.
183
519
  * Headers: `# branch.head <name>` (or `(detached)`), `# branch.upstream
@@ -214,6 +550,86 @@ function parseStatusV2(out) {
214
550
  return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
215
551
  }
216
552
 
553
+ /**
554
+ * Parse `git worktree list --porcelain`. Blocks of `key value` lines separated by
555
+ * a blank line: `worktree <path>`, `HEAD <sha>`, `branch refs/heads/<name>` (the
556
+ * line is ABSENT when the entry is detached), and the flag lines `bare`, `locked`
557
+ * and `prunable`, which may carry a reason after the key.
558
+ *
559
+ * The last field is the checkout's directory NAME, which is all the badge ever
560
+ * reports — never the path (AGENTS.md rule 7). Git prints the main worktree first,
561
+ * but the caller identifies the checkout it is standing in by comparing `path`
562
+ * against its own toplevel rather than by list position.
563
+ */
564
+ function parseWorktreeList(stdout) {
565
+ const entries = [];
566
+ let entry = null;
567
+ const flush = () => {
568
+ if (entry !== null) entries.push({ ...entry, name: basename(entry.path) });
569
+ entry = null;
570
+ };
571
+ for (const raw of String(stdout ?? "").split("\n")) {
572
+ const line = raw.trim();
573
+ if (line === "") {
574
+ flush();
575
+ continue;
576
+ }
577
+ const at = line.indexOf(" ");
578
+ const key = at === -1 ? line : line.slice(0, at);
579
+ const value = at === -1 ? "" : line.slice(at + 1);
580
+ if (key === "worktree") {
581
+ flush();
582
+ entry = { path: value, head: "", branch: void 0, detached: false, bare: false, locked: false, prunable: false };
583
+ continue;
584
+ }
585
+ if (entry === null) continue;
586
+ if (key === "HEAD") entry.head = value;
587
+ else if (key === "branch") entry.branch = value.replace(/^refs\/heads\//, "");
588
+ else if (key === "detached") entry.detached = true;
589
+ else if (key === "bare") entry.bare = true;
590
+ else if (key === "locked") entry.locked = true;
591
+ else if (key === "prunable") entry.prunable = true;
592
+ }
593
+ flush();
594
+ return entries;
595
+ }
596
+
597
+ /**
598
+ * The worktree landscape of the repository `dir` sits in: its toplevel in GIT's
599
+ * own spelling (so it compares exactly against the listed paths, with no
600
+ * spelling guesswork) and every worktree of that repository, main one included.
601
+ *
602
+ * Two cheap local invocations. A failure of either degrades to "no linked
603
+ * worktrees", which leaves the badge on the directory the session actually names
604
+ * rather than substituting a guess.
605
+ */
606
+ async function readWorktrees(dir) {
607
+ const top = await runGit(dir, ["rev-parse", "--show-toplevel"]);
608
+ const toplevel = top.stdout === null ? "" : top.stdout.trim();
609
+ if (toplevel === "") return { toplevel: "", worktrees: [] };
610
+ const list = await runGit(dir, ["worktree", "list", "--porcelain"]);
611
+ return { toplevel, worktrees: list.stdout === null ? [] : parseWorktreeList(list.stdout) };
612
+ }
613
+
614
+ /** In-flight worktree probes, so a burst of callers shares one pair of calls. */
615
+ const worktreeInFlight = new Map();
616
+
617
+ /**
618
+ * `readWorktrees` with identical CONCURRENT calls collapsed, the same way
619
+ * `gitStatus` collapses status reads: every session row of one workspace probes
620
+ * the same directory at the same moment. Deliberately no TTL — the probe is two
621
+ * local git calls, and a worktree created a moment ago must be visible at once.
622
+ */
623
+ function worktreesFor(dir) {
624
+ const pending = worktreeInFlight.get(dir);
625
+ if (pending !== void 0) return pending;
626
+ const run = readWorktrees(dir).finally(() => {
627
+ worktreeInFlight.delete(dir);
628
+ });
629
+ worktreeInFlight.set(dir, run);
630
+ return run;
631
+ }
632
+
217
633
  /**
218
634
  * In-progress operation markers, in precedence order. Git drops one of these in
219
635
  * the per-worktree git dir while an operation is paused; the first match names
@@ -259,11 +675,12 @@ function operationMarker(gitDir) {
259
675
  * worktree reports that worktree (the toplevel walk starts above it), which is
260
676
  * the same whole-repository rule as above.
261
677
  *
262
- * `wantDetail` has no consumer yet: both surfaces fetch without `detail=1`.
263
- * The branch is retained for the planned hover card — nothing renders
264
- * lastCommits / stashCount today.
678
+ * `wantDetail` serves the input chip's HOVER CARD (recent commits + stash) and
679
+ * `wantPr` its PR/CI token. They are asked for separately and by the chip only,
680
+ * so neither is paid for by a sidebar row refresh: a row needs status and
681
+ * identity, nothing more.
265
682
  */
266
- async function gitStatus(dir, wantDetail) {
683
+ async function gitStatusUncached(dir, wantDetail, wantPr) {
267
684
  // one invocation answers both questions: the repository root (so a
268
685
  // subdirectory workspace reports the whole repo) and the per-worktree git
269
686
  // dir (where the operation markers above live)
@@ -360,9 +777,54 @@ async function gitStatus(dir, wantDetail) {
360
777
  if (count > 0) info.stashCount = count;
361
778
  }
362
779
  }
780
+ // PR / CI is the INPUT CHIP's business, so it is asked for explicitly
781
+ // (`?pr=1`) rather than paid for by every sidebar row: a row that surveys
782
+ // twenty workspaces must not spawn twenty forge processes. Served from the
783
+ // TTL cache and refreshed out of band, exactly like the fetch above — the
784
+ // forge round trip is never route latency.
785
+ if (wantPr) {
786
+ const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
787
+ if (pr !== void 0) info.pr = pr;
788
+ }
363
789
  return info;
364
790
  }
365
791
 
792
+ /** In-flight status reads, so N callers asking at once share one `git status`. */
793
+ const statusInFlight = new Map();
794
+ /** Completed reads inside `config.statusCacheMs`; unused at the 0 default. */
795
+ const statusCache = new Map();
796
+
797
+ /**
798
+ * Status for dir, with identical CONCURRENT reads collapsed into one. That is the
799
+ * shape a burst of sidebar session rows produces when they mount together: N rows
800
+ * of one workspace resolve to the same directory, and without this they would run
801
+ * N `git status` walks of the same tree at the same instant.
802
+ *
803
+ * The optional `statusCacheMs` window extends the collapse to serial bursts. It
804
+ * defaults to 0 on purpose: a cache that outlives the call would let a caller
805
+ * that mutates a repository and asks again read a stale answer, and correctness
806
+ * of a *status* badge outranks saving a walk.
807
+ */
808
+ function gitStatus(dir, wantDetail, wantPr) {
809
+ const key = dir + "\u0000" + (wantDetail === true) + "\u0000" + (wantPr === true);
810
+ if (config.statusCacheMs > 0) {
811
+ const hit = statusCache.get(key);
812
+ if (hit !== void 0 && Date.now() - hit.at < config.statusCacheMs) return Promise.resolve(hit.value);
813
+ }
814
+ const pending = statusInFlight.get(key);
815
+ if (pending !== void 0) return pending;
816
+ const run = gitStatusUncached(dir, wantDetail, wantPr)
817
+ .then((value) => {
818
+ if (config.statusCacheMs > 0) statusCache.set(key, { at: Date.now(), value });
819
+ return value;
820
+ })
821
+ .finally(() => {
822
+ statusInFlight.delete(key);
823
+ });
824
+ statusInFlight.set(key, run);
825
+ return run;
826
+ }
827
+
366
828
  //#region git-state watcher
367
829
  /**
368
830
  * One recursive watcher per workspace, plus a second one when the git dir lives
@@ -596,6 +1058,49 @@ function watchWorkspace(root, key, workspaceId) {
596
1058
  }
597
1059
  }
598
1060
 
1061
+ /**
1062
+ * Workspace path -> { worktreePath, workspaceId, owned } for the inferred
1063
+ * worktree a session badge is currently following. One per workspace: selection
1064
+ * is a single choice by construction (selectSessionWorktree), so following a
1065
+ * different tree retires the previous one here.
1066
+ */
1067
+ const selectedWorktrees = new Map();
1068
+
1069
+ /**
1070
+ * Keep the watcher set aligned with the worktree a session badge has been told to
1071
+ * follow.
1072
+ *
1073
+ * A selected worktree is usually NOT a registered workspace, so nothing else
1074
+ * watches it — and freshness would then depend on the owning checkout's recursive
1075
+ * watch happening to cover it (true for a worktree nested inside the repository,
1076
+ * false for a sibling directory). Its events must also carry the OWNING workspace
1077
+ * id, because that is what a session-targeted client matches its events against:
1078
+ * `notifyChange` reads the id off the watcher record for the path it watched.
1079
+ *
1080
+ * `owned` records whether this call created the watcher. A worktree can itself be
1081
+ * a registered workspace (someone may have opened one as a workspace), and
1082
+ * retiring the selection must never tear down a watcher the registry owns.
1083
+ *
1084
+ * Idempotent and cheap: called on every session request, and a no-op while the
1085
+ * selection is unchanged. `chosenPath === undefined` retires whatever was
1086
+ * selected — the "the worktree is gone, or its PR closed" path.
1087
+ */
1088
+ function selectWorktreeWatch(ownerPath, workspaceId, chosenPath) {
1089
+ const previous = selectedWorktrees.get(ownerPath);
1090
+ if (previous !== void 0 && previous.worktreePath === chosenPath) {
1091
+ previous.workspaceId = workspaceId;
1092
+ return;
1093
+ }
1094
+ if (previous !== void 0) {
1095
+ if (previous.owned) unwatchWorkspace(previous.worktreePath);
1096
+ selectedWorktrees.delete(ownerPath);
1097
+ }
1098
+ if (chosenPath === undefined) return;
1099
+ const owned = !watchers.has(chosenPath);
1100
+ watchWorkspace(chosenPath, chosenPath, workspaceId);
1101
+ selectedWorktrees.set(ownerPath, { worktreePath: chosenPath, workspaceId, owned });
1102
+ }
1103
+
599
1104
  /** Reconcile the watcher set with the current workspace registry. */
600
1105
  function syncWatchers(ctx) {
601
1106
  const wanted = new Set();
@@ -605,6 +1110,17 @@ function syncWatchers(ctx) {
605
1110
  wanted.add(entity.path);
606
1111
  watchWorkspace(entity.path, entity.path, entity.id === void 0 ? void 0 : String(entity.id));
607
1112
  }
1113
+ // An inferred worktree is watched because a session request selected it, not
1114
+ // because the registry owns it. Keep the ones whose owner is still registered
1115
+ // and retire the rest, so this reconcile never tears down a live selection.
1116
+ for (const [ownerPath, entry] of selectedWorktrees) {
1117
+ if (!wanted.has(ownerPath)) {
1118
+ if (entry.owned) unwatchWorkspace(entry.worktreePath);
1119
+ selectedWorktrees.delete(ownerPath);
1120
+ continue;
1121
+ }
1122
+ wanted.add(entry.worktreePath);
1123
+ }
608
1124
  for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
609
1125
  }
610
1126
  //#endregion
@@ -660,6 +1176,46 @@ async function resolveWorkspace(ctx, params) {
660
1176
  return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
661
1177
  }
662
1178
 
1179
+ /**
1180
+ * The directory a request is ABOUT.
1181
+ *
1182
+ * A SESSION-targeted chip may follow the one linked worktree its repository can be
1183
+ * shown to be working in (selectSessionWorktree): DSH records no session→worktree
1184
+ * link at all — session cwd is immutable creation metadata and `attachSession`
1185
+ * requires it to equal the workspace path — so a conversation started in the main
1186
+ * checkout would otherwise report `main` while its work, and its pull request,
1187
+ * live in a tree. The tree's watcher is reconciled HERE, because this is the only
1188
+ * place that knows which tree was chosen.
1189
+ *
1190
+ * A WORKSPACE-targeted row never follows a worktree: a row surveys the checkout
1191
+ * the registry owns, and substituting a tree would make the row lie about the
1192
+ * branch it is on.
1193
+ *
1194
+ * Gated three ways so the common case costs nothing: the caller must name a
1195
+ * session, must be asking about PR state at all (selection is defined by an open
1196
+ * PR, so a caller not asking cannot benefit), and the repository must actually
1197
+ * have a linked worktree. Only then is the forge consulted.
1198
+ */
1199
+ async function effectiveTarget(target, bySession, wantPr, notify) {
1200
+ if (bySession !== true || wantPr !== true || config.worktreeStatus === "off") return { id: target.id, path: target.path };
1201
+ const { toplevel, worktrees } = await worktreesFor(target.path);
1202
+ // Git lists the main worktree first, so `main.path === toplevel` is the exact
1203
+ // test for "the session's own directory is the main checkout". A session whose
1204
+ // directory IS a linked worktree is its own answer and is never swapped
1205
+ // sideways onto a sibling.
1206
+ const main = worktrees[0];
1207
+ if (toplevel === "" || main === void 0 || main.path !== toplevel || worktrees.length <= 1) {
1208
+ selectWorktreeWatch(target.path, target.id, void 0);
1209
+ return { id: target.id, path: target.path };
1210
+ }
1211
+ const openPrs = openPrsFor(toplevel, notify);
1212
+ const chosen = selectSessionWorktree(worktrees, openPrs, toplevel);
1213
+ selectWorktreeWatch(target.path, target.id, chosen === void 0 ? void 0 : chosen.path);
1214
+ if (chosen === void 0) return { id: target.id, path: target.path };
1215
+ // The name and branch travel; the PATH never does (AGENTS.md rule 7).
1216
+ return { id: target.id, path: chosen.path, worktree: { name: chosen.name, branch: chosen.branch } };
1217
+ }
1218
+
663
1219
  /** Host plugin body — register the status route and the SSE change feed. */
664
1220
  function apply(ctx) {
665
1221
  ctx.effect(() => ctx.webServer.register({
@@ -675,13 +1231,44 @@ function apply(ctx) {
675
1231
  return;
676
1232
  }
677
1233
  const detail = url.searchParams.get("detail") === "1";
678
- const info = await gitStatus(target.path, detail);
1234
+ const pr = url.searchParams.get("pr") === "1";
1235
+ // WHO asked decides whether a linked worktree may stand in for the
1236
+ // session's own directory — see effectiveTarget. A workspace-targeted
1237
+ // row never leaves the checkout the registry owns.
1238
+ const session = url.searchParams.get("session");
1239
+ const effective = await effectiveTarget(target, session !== null && session !== "", pr, notifyChange);
1240
+ const info = await gitStatus(effective.path, detail, pr);
1241
+ const body = { ...info, workspace: target.id };
1242
+ if (effective.worktree !== void 0 && info.git === true) {
1243
+ // The status describes a worktree the SESSION's directory does not
1244
+ // name. Saying so is the whole honesty of the feature: the chip's
1245
+ // mark says "worktree", its name says which, and this flag tells the
1246
+ // card why the checkout below differs from the branch beside it.
1247
+ body.worktreeInferred = true;
1248
+ if (detail) {
1249
+ // The session's OWN directory, for the card's `checkout` row, so the
1250
+ // main checkout stays visible once the badge follows a tree. Gated
1251
+ // by detail=1 (a pointer resting on the chip), so nothing at rest
1252
+ // pays for the second read.
1253
+ const own = await gitStatus(target.path, false, false);
1254
+ if (own.git === true) {
1255
+ body.checkout = {
1256
+ branch: own.branch,
1257
+ dirty: own.dirty,
1258
+ changedFiles: own.changedFiles,
1259
+ untrackedFiles: own.untrackedFiles,
1260
+ ahead: own.ahead,
1261
+ behind: own.behind
1262
+ };
1263
+ }
1264
+ }
1265
+ }
679
1266
  // Echo the resolved workspace id. SSE events identify the workspace,
680
1267
  // not the session, so a session-targeted client has no other way to
681
1268
  // tell whether an event belongs to it — without this the input chip
682
1269
  // only ever refreshed on remount or the 60s poll.
683
1270
  res.writeHead(200, { "content-type": "application/json" });
684
- res.end(JSON.stringify({ ...info, workspace: target.id }));
1271
+ res.end(JSON.stringify(body));
685
1272
  } catch (error) {
686
1273
  res.writeHead(400, { "content-type": "application/json" });
687
1274
  res.end(JSON.stringify({ git: false, error: { code: "bad-request", message: String(error?.message ?? error) } }));
@@ -757,9 +1344,27 @@ export {
757
1344
  operationMarker,
758
1345
  outerGitDir,
759
1346
  parseStatusV2,
1347
+ parseWorktreeList,
1348
+ readWorktrees,
1349
+ worktreesFor,
1350
+ runCli,
760
1351
  runGit,
761
1352
  gitStatus,
1353
+ gitStatusUncached,
762
1354
  resolveWorkspace,
1355
+ effectiveTarget,
1356
+ readPrStatus,
1357
+ summarizePr,
1358
+ summarizeChecks,
1359
+ prStatusFor,
1360
+ prState,
1361
+ readOpenPrs,
1362
+ openPrsFor,
1363
+ prListState,
1364
+ selectSessionWorktree,
1365
+ selectWorktreeWatch,
1366
+ selectedWorktrees,
1367
+ statusInFlight,
763
1368
  watchWorkspace,
764
1369
  unwatchWorkspace,
765
1370
  syncWatchers,