dsh-git-badge 0.6.0 → 0.8.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
@@ -3,10 +3,16 @@
3
3
  *
4
4
  * Hardened git-status service behind two routes:
5
5
  *
6
- * GET /api/git-badge?path=<workspace path>[&detail=1]
7
- * - path allowlist: only directories registered as DSH workspaces
8
- * (WorkspaceRegistry records) are ever queried — no filesystem probing
9
- * of arbitrary paths.
6
+ * GET /api/git-badge?workspace=<workspaceId> (sidebar rows)
7
+ * GET /api/git-badge?session=<sessionId> (the input chip)
8
+ * - NO client-supplied path is accepted. The caller names a workspace id or
9
+ * a session id; the server resolves the directory itself. `?path=` was
10
+ * removed precisely so a caller cannot aim the route at a directory of
11
+ * its choosing, even one inside the registry.
12
+ * - the route is intentionally unauthenticated (it is a local, read-only
13
+ * status feed), so resolution must never assume the caller is honest.
14
+ * Workspace ids are generated uuids and session ids are opaque, so the
15
+ * surface no longer offers anything to enumerate.
10
16
  * - one git invocation per sample: `git --no-optional-locks status
11
17
  * --porcelain=v2 --branch --untracked-files=all` yields branch,
12
18
  * ahead/behind, and the changed / untracked counts in a single call
@@ -22,30 +28,47 @@
22
28
  * markers (`MERGE_HEAD`, `rebase-merge`, …) are stat'd — surfaced as
23
29
  * `operation`, so a paused rebase/cherry-pick is distinguishable from
24
30
  * ordinary dirty work even when its conflicts are already staged.
25
- * - ahead/behind compare against the LOCAL remote-tracking ref, which
26
- * only moves on fetch — so a TTL-bounded background fetch (60s per
27
- * toplevel) runs before status when the ref can be stale. `GIT_TERMINAL_PROMPT=0`
28
- * and the shared timeout keep an offline/slow remote from stalling the
29
- * route; on fetch failure the stale-ref answer is served as-is.
30
- * - `detail=1` adds one `log -3` plus a stash count. NOTE: no surface
31
- * consumes this yet (see the annotation on gitStatus) — it is kept for
32
- * the planned hover card, and never paid for by row refresh.
31
+ * - the same invocation identifies a LINKED WORKTREE: its git dir is
32
+ * `<main>/.git/worktrees/<name>` instead of `<root>/.git`, and unlike the
33
+ * main worktree's it carries a `commondir` file. One stat of the git dir
34
+ * the call already returned yields `isWorktree`, plus the checkout's own
35
+ * directory name as `worktreeName` — so the chip can say WHICH working
36
+ * tree a conversation is in. Without it, several worktrees of one
37
+ * repository render identical `🟡 main` badges. The name is a NAME, never
38
+ * a path: the route takes no path and the client still needs to know none.
39
+ * - ahead/behind compare against the LOCAL remote-tracking ref, which only
40
+ * moves on fetch. A TTL-bounded fetch (60s per toplevel) keeps that ref
41
+ * fresh, but it runs OUT OF BAND: the answer is served from the refs at
42
+ * hand and a successful fetch notifies subscribers so the next refresh
43
+ * carries corrected ahead/behind. Awaiting it here cost ~3.3s per request
44
+ * on the first refresh after each TTL window. `GIT_TERMINAL_PROMPT=0`, the
45
+ * shared timeout and the TTL bound keep an offline or slow remote harmless;
46
+ * on failure the stale-ref answer simply stands.
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.
33
55
  *
34
56
  * GET /api/git-badge/events (text/event-stream)
35
57
  * - pushes `{ path }` dirty notifications the moment a workspace's git
36
58
  * state can have changed. Freshness is EVENT-DRIVEN: each registered
37
- * workspace root is watched recursively (.git included), so a commit /
38
- * checkout / stage / worktree edit pushes one SSE message and every
39
- * mounted row refetches immediately. There is no fixed polling
40
- * interval anywhere in the pipeline.
59
+ * workspace is watched recursively, so a commit / checkout / stage /
60
+ * worktree edit pushes one SSE message and every mounted row refetches
61
+ * immediately. There is no fixed polling interval anywhere in the
62
+ * pipeline. A MAIN worktree's git dir is `<root>/.git` and so is covered
63
+ * by the root watch; a LINKED worktree's is not, so its git dir gets a
64
+ * second watcher (see the watcher region).
41
65
  *
42
66
  * When upstream lands a workspace-metadata service, this half is deleted and
43
67
  * the client half re-points at it — the seam contract does not change.
44
68
  */
45
69
  import { execFile } from "node:child_process";
46
- import { realpath } from "node:fs/promises";
47
- import { watch, existsSync } from "node:fs";
48
- import { join } from "node:path";
70
+ import { watch, existsSync, readFileSync } from "node:fs";
71
+ import { basename, isAbsolute, join, resolve } from "node:path";
49
72
 
50
73
  const inject = ["webServer", "workspaceRegistry"];
51
74
  const name = "dsh-git-badge";
@@ -68,6 +91,12 @@ const config = {
68
91
  debounceMs: 200,
69
92
  /** backoff before re-attempting a failed watcher (or a missing .git) */
70
93
  watchRetryMs: 60000,
94
+ /**
95
+ * Degraded-mode poll interval for a workspace whose watcher failed. Only used
96
+ * where a real watcher could not be established or later errored — a healthy
97
+ * workspace is event-driven and never polls.
98
+ */
99
+ pollFallbackMs: 5000,
71
100
  /** SSE comment heartbeat interval */
72
101
  heartbeatMs: 25000,
73
102
  /**
@@ -76,23 +105,48 @@ const config = {
76
105
  * collapsed-fallback branch deterministically — a real sub-millisecond budget
77
106
  * would just race git's own startup.
78
107
  */
79
- gitRunner: null
108
+ gitRunner: null,
109
+ /**
110
+ * PR / CI status via the `gh` CLI: "auto" reads it where `gh` can answer and
111
+ * silently shows nothing where it cannot; "off" never invokes `gh` at all.
112
+ * Only the input chip asks for it, so a sidebar-only view spawns no forge
113
+ * process — see the PR region.
114
+ */
115
+ prStatus: "auto",
116
+ /** minimum interval between forge refreshes per repository */
117
+ prTtlMs: 90000,
118
+ /**
119
+ * Forge call budget. Unlike git this is a network round trip to GitHub, so
120
+ * it gets a more generous budget than the status sample — but it is bounded
121
+ * all the same, because nothing here may hang the route.
122
+ */
123
+ prTimeoutMs: 6000,
124
+ /**
125
+ * Invoker for the `gh` CLI; null means the real runCli. The suite substitutes
126
+ * one so every degradation path (no gh, logged out, no PR, garbage output,
127
+ * timeout) is covered with no gh, no network and no forge.
128
+ */
129
+ prRunner: null
80
130
  };
81
131
 
82
132
  /**
83
- * Run git in dir. Resolves { stdout } on success; on failure { stdout: null }
84
- * plus exactly one of: timeout (execFile timeout kill), missing (no git
85
- * binary), or exitCode (git ran and rejected — e.g. "not a repository").
86
- * The exitCode/timeout distinction separates a definitive answer (non-repo)
87
- * from a transient one (degraded).
133
+ * Run an external command in a directory. Resolves { stdout } on success; on
134
+ * failure { stdout: null } plus exactly one of: timeout (execFile timeout kill),
135
+ * missing (no such binary), or exitCode (the command ran and rejected — e.g.
136
+ * "not a repository", or `gh` reporting no pull request).
137
+ *
138
+ * The exitCode/timeout/missing trichotomy is what lets a CALLER tell a
139
+ * definitive answer from a transient one: a missing `gh` and a `gh` that says
140
+ * "no PR" are different facts, and only the caller knows which of them it can
141
+ * act on. Shared by git and `gh` so neither grows its own timeout semantics.
88
142
  */
89
- function runGit(dir, args, opts) {
90
- const { env, timeout = config.gitTimeoutMs } = opts ?? {};
143
+ function runCli(cmd, args, opts) {
144
+ const { cwd, env, timeout = config.gitTimeoutMs } = opts ?? {};
91
145
  // env is a full replacement for execFile; overlay onto process.env so
92
146
  // PATH/HOME (credential helpers, global config) survive
93
147
  const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
94
148
  return new Promise((resolve) => {
95
- execFile("git", args, { cwd: dir, timeout, env: fullEnv }, (error, stdout) => {
149
+ execFile(cmd, args, { cwd, timeout, env: fullEnv }, (error, stdout) => {
96
150
  if (error === void 0 || error === null) resolve({ stdout: String(stdout) });
97
151
  else if (error.killed) resolve({ stdout: null, timeout: true });
98
152
  else if (error.code === "ENOENT") resolve({ stdout: null, missing: true });
@@ -101,6 +155,15 @@ function runGit(dir, args, opts) {
101
155
  });
102
156
  }
103
157
 
158
+ /**
159
+ * Run git in dir. A thin specialization of runCli: the contract above is
160
+ * unchanged, and `config.gitRunner` still substitutes for the whole call so the
161
+ * suite can force the timeout branch.
162
+ */
163
+ function runGit(dir, args, opts) {
164
+ return runCli("git", args, { ...opts, cwd: dir });
165
+ }
166
+
104
167
  /** Marker response for a git invocation that failed or timed out (transient). */
105
168
  const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
106
169
 
@@ -154,6 +217,167 @@ async function maybeFetch(toplevel) {
154
217
  }
155
218
  //#endregion
156
219
 
220
+ //#region PR / CI status (gh)
221
+ /**
222
+ * GitHub pull-request and check state for the current branch, read through the
223
+ * user's own `gh` CLI.
224
+ *
225
+ * Why the CLI and not the REST API: `gh` already owns the user's credentials in
226
+ * its own config, so this plugin never reads, stores, caches or forwards a
227
+ * token. There is no secret here to leak and no OAuth flow to maintain — and a
228
+ * machine that has never authenticated `gh` simply gets no PR token.
229
+ *
230
+ * Degradation is a MISSING FIELD, never an error. A machine without `gh`, a
231
+ * logged-out `gh`, a remote that is not GitHub, an offline network, a forge rate
232
+ * limit, unparseable output and a timed-out call all produce exactly the same
233
+ * response: no `pr` key at all. That is the honest answer, because in every one
234
+ * of those cases the badge has nothing to say — and a caller that could tell
235
+ * "no PR" from "no gh" would still not know what to draw.
236
+ *
237
+ * Like the fetch above this is TTL-bounded and NEVER awaited by the route: the
238
+ * cached value is served immediately and a refresh that CHANGES it notifies
239
+ * subscribers, so the request that lapses the window carries the previous answer
240
+ * and the follow-up carries the new one. A forge round trip must never become
241
+ * badge latency.
242
+ */
243
+ /** toplevel -> { lastAttemptAt, inFlight, value }; `value` undefined = nothing to say. */
244
+ const prState = new Map();
245
+
246
+ /** Test seam: the CLI invoker `gh` calls go through, so no test needs `gh`. */
247
+ function runPrCli(cmd, args, opts) {
248
+ return (config.prRunner ?? runCli)(cmd, args, opts);
249
+ }
250
+
251
+ /**
252
+ * Does `origin` point at github.com? A cheap LOCAL call, so a non-GitHub repo
253
+ * never pays for a `gh` process. Deliberately scoped to origin: that is the
254
+ * remote `gh` itself resolves the repository from, so agreeing with it here is
255
+ * what keeps the two from disagreeing later.
256
+ */
257
+ async function originIsGitHub(toplevel) {
258
+ const out = await runGit(toplevel, ["remote", "get-url", "origin"]);
259
+ if (out.stdout === null) return false;
260
+ return /(^|[/@.])github\.com[:/]/i.test(out.stdout.trim());
261
+ }
262
+
263
+ /**
264
+ * Reduce a `statusCheckRollup` to ONE worst-case state, so the chip needs a
265
+ * single colour rather than a list.
266
+ *
267
+ * Two entry shapes share the array: a CheckRun carries `status` + `conclusion`,
268
+ * a StatusContext carries `state` alone. A single failure outweighs any number
269
+ * of successes and pending checks — "mostly passing" is not a thing a red/green
270
+ * token can express — so failure returns immediately and pending only wins over
271
+ * an otherwise all-successful set.
272
+ */
273
+ function summarizeChecks(checks) {
274
+ let sawAny = false;
275
+ let sawPending = false;
276
+ for (const check of checks) {
277
+ const state = String(check?.state ?? "").toUpperCase();
278
+ const status = String(check?.status ?? "").toUpperCase();
279
+ const conclusion = String(check?.conclusion ?? "").toUpperCase();
280
+ sawAny = true;
281
+ const failed =
282
+ ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(state) ||
283
+ ["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion);
284
+ if (failed) return "failing";
285
+ // unfinished: a CheckRun that has not completed, or a StatusContext PENDING
286
+ if (state === "PENDING" || (status !== "" && status !== "COMPLETED")) sawPending = true;
287
+ }
288
+ if (!sawAny) return "none";
289
+ return sawPending ? "pending" : "passing";
290
+ }
291
+
292
+ /** The compact PR shape the chip renders, or undefined when there is nothing to say. */
293
+ function summarizePr(json) {
294
+ if (json === null || typeof json !== "object") return void 0;
295
+ const number = Number.isFinite(json?.number) ? json.number : void 0;
296
+ if (number === void 0) return void 0;
297
+ const pr = {
298
+ number,
299
+ state: summarizeChecks(Array.isArray(json.statusCheckRollup) ? json.statusCheckRollup : []),
300
+ // draft and review state are separate from CI: a draft with green checks
301
+ // is not ready, and a blocked review is not a failing build
302
+ draft: json.isDraft === true,
303
+ open: String(json.state ?? "OPEN").toUpperCase() === "OPEN"
304
+ };
305
+ // omitted, not present-and-undefined: absence is the contract for "nothing to
306
+ // say" throughout this half, so a consumer tests the key, never the value
307
+ if (typeof json.reviewDecision === "string" && json.reviewDecision !== "") pr.review = json.reviewDecision;
308
+ // The chip links the token to the PR, so `url` is the one field that becomes an
309
+ // `href`: only http(s) is emitted, and anything else is omitted like every
310
+ // other "nothing to say" field, so a payload value cannot reach an anchor as a
311
+ // `javascript:` URL. The client re-checks before it builds the href — this half
312
+ // is the authority on what it vouches for, not the only guard.
313
+ if (typeof json.url === "string" && json.url !== "") {
314
+ try {
315
+ const { protocol } = new URL(json.url);
316
+ if (protocol === "http:" || protocol === "https:") pr.url = json.url;
317
+ } catch {
318
+ // not a URL at all — omitted, exactly like an absent one
319
+ }
320
+ }
321
+ return pr;
322
+ }
323
+
324
+ /**
325
+ * Read PR state for `branch`, or undefined. NEVER throws and never rejects: a
326
+ * forge is an optional luxury, so every failure path is "nothing to say".
327
+ */
328
+ async function readPrStatus(toplevel, branch) {
329
+ // a detached HEAD is not a branch `gh` can resolve a PR for
330
+ if (typeof branch !== "string" || branch === "" || branch.startsWith("HEAD")) return void 0;
331
+ if (!(await originIsGitHub(toplevel))) return void 0;
332
+ const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url"], {
333
+ cwd: toplevel,
334
+ timeout: config.prTimeoutMs,
335
+ // GH_PROMPT_DISABLED: an auth prompt must never hang the refresh (the gh
336
+ // analogue of the git fetch's GIT_TERMINAL_PROMPT=0). The pager vars keep
337
+ // gh from waiting on a pager that will never answer, and NO_COLOR keeps
338
+ // escape codes out of the JSON.
339
+ env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
340
+ });
341
+ if (out.stdout === null) return void 0;
342
+ let json;
343
+ try {
344
+ json = JSON.parse(out.stdout);
345
+ } catch {
346
+ return void 0;
347
+ }
348
+ return summarizePr(json);
349
+ }
350
+
351
+ /**
352
+ * PR state for toplevel: the value to serve NOW (possibly undefined, possibly
353
+ * stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
354
+ * called only when the refreshed value CHANGES, so an unchanged answer costs one
355
+ * forge call per TTL and zero SSE traffic.
356
+ */
357
+ function prStatusFor(toplevel, branch, notify) {
358
+ if (config.prStatus === "off") return void 0;
359
+ const now = Date.now();
360
+ const state = prState.get(toplevel);
361
+ if (state !== void 0) {
362
+ // Fresh, or a refresh is already running: serve what we have either way.
363
+ if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
364
+ }
365
+ const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
366
+ const inFlight = (async () => {
367
+ const value = await readPrStatus(toplevel, branch).catch(() => void 0);
368
+ const changed = JSON.stringify(record.value ?? null) !== JSON.stringify(value ?? null);
369
+ record.lastAttemptAt = Date.now();
370
+ record.inFlight = null;
371
+ record.value = value;
372
+ if (changed) notify(toplevel);
373
+ return value;
374
+ })();
375
+ record.inFlight = inFlight;
376
+ prState.set(toplevel, record);
377
+ return record.value;
378
+ }
379
+ //#endregion
380
+
157
381
  /**
158
382
  * Parse `git status --porcelain=v2 --branch` output.
159
383
  * Headers: `# branch.head <name>` (or `(detached)`), `# branch.upstream
@@ -230,11 +454,17 @@ function operationMarker(gitDir) {
230
454
  * branch, dirty state, and ahead/behind counts (what git itself considers
231
455
  * dirty), not the subdirectory in isolation. Intentional.
232
456
  *
233
- * `wantDetail` has no consumer yet: both surfaces fetch without `detail=1`.
234
- * The branch is retained for the planned hover card — nothing renders
235
- * lastCommits / stashCount today.
457
+ * Worktree identity is reported, not assumed: `isWorktree` marks a linked
458
+ * worktree and `worktreeName` names the checkout. A subdirectory of a linked
459
+ * worktree reports that worktree (the toplevel walk starts above it), which is
460
+ * the same whole-repository rule as above.
461
+ *
462
+ * `wantDetail` serves the input chip's HOVER CARD (recent commits + stash) and
463
+ * `wantPr` its PR/CI token. They are asked for separately and by the chip only,
464
+ * so neither is paid for by a sidebar row refresh: a row needs status and
465
+ * identity, nothing more.
236
466
  */
237
- async function gitStatus(dir, wantDetail) {
467
+ async function gitStatus(dir, wantDetail, wantPr) {
238
468
  // one invocation answers both questions: the repository root (so a
239
469
  // subdirectory workspace reports the whole repo) and the per-worktree git
240
470
  // dir (where the operation markers above live)
@@ -248,6 +478,11 @@ async function gitStatus(dir, wantDetail) {
248
478
  const toplevel = (topLines[0] ?? "").trim();
249
479
  if (toplevel === "") return { git: false };
250
480
  const gitDir = (topLines[1] ?? "").trim();
481
+ // A linked worktree's git dir carries a `commondir` file pointing back at
482
+ // the shared git dir; a main worktree's does not. Stat'ing it costs nothing
483
+ // extra — `gitDir` came from the invocation above. A submodule also has an
484
+ // out-of-tree git dir but no `commondir`, so it is correctly NOT a worktree.
485
+ const isWorktree = existsSync(join(gitDir, "commondir"));
251
486
  // -uall counts untracked FILES; git's default collapses a new directory into
252
487
  // a single entry, which is why `✎n` under-reported anyone who added a folder.
253
488
  // Routed through config.gitRunner so the suite can force the timeout branch.
@@ -269,18 +504,26 @@ async function gitStatus(dir, wantDetail) {
269
504
  statusOut = await sample(false);
270
505
  }
271
506
  if (statusOut.stdout === null) return GIT_DEGRADED;
272
- let parsed = parseStatusV2(statusOut.stdout);
273
- // The upstream header is the cheap gate: no upstream configured → the
274
- // fetch would be a no-op probe, skip it entirely. When it fires and the
275
- // fetch succeeds, re-sample so this very response already carries the
276
- // fresh ahead/behind instead of lagging one cycle behind.
507
+ const parsed = parseStatusV2(statusOut.stdout);
508
+ // The upstream header is the cheap gate: with no upstream the fetch would be a
509
+ // no-op probe, so skip it. With one, refresh the remote-tracking refs OUT OF
510
+ // BAND rather than before the answer.
511
+ //
512
+ // Blocking here cost ~3.3s per request (measured: `git fetch` over SSH) on every
513
+ // request whose TTL had lapsed — and that is the first refresh after each 60s
514
+ // window, i.e. constantly while someone is editing, which made an edit-triggered
515
+ // badge update take seconds. So: answer now from the refs we already have, and
516
+ // when the background fetch succeeds, notify subscribers — the client refetches,
517
+ // finds the TTL fresh, and picks up the corrected ahead/behind in ~50ms. The
518
+ // cost of the trade is that ahead/behind can lag by up to one fetch.
277
519
  if (parsed.upstream !== void 0) {
278
- const fetched = await maybeFetch(toplevel);
279
- if (fetched) {
280
- statusOut = await sample(untrackedMode === "all");
281
- if (statusOut.stdout === null) return GIT_DEGRADED;
282
- parsed = parseStatusV2(statusOut.stdout);
283
- }
520
+ void maybeFetch(toplevel)
521
+ .then((fetched) => {
522
+ if (fetched) notifyChange(toplevel);
523
+ })
524
+ .catch(() => {
525
+ /* a failed fetch just means the stale-ref answer stands */
526
+ });
284
527
  }
285
528
  const dirtyFiles = parsed.staged + parsed.unstaged + parsed.unmerged + parsed.untracked;
286
529
  const info = {
@@ -295,6 +538,10 @@ async function gitStatus(dir, wantDetail) {
295
538
  untrackedFiles: parsed.untracked,
296
539
  untrackedMode,
297
540
  operation: operationMarker(gitDir),
541
+ isWorktree,
542
+ // the checkout's own directory name (of the worktree root, so a
543
+ // subdirectory workspace still names the checkout it belongs to)
544
+ worktreeName: basename(toplevel),
298
545
  ahead: parsed.ahead,
299
546
  behind: parsed.behind
300
547
  };
@@ -314,15 +561,25 @@ async function gitStatus(dir, wantDetail) {
314
561
  if (count > 0) info.stashCount = count;
315
562
  }
316
563
  }
564
+ // PR / CI is the INPUT CHIP's business, so it is asked for explicitly
565
+ // (`?pr=1`) rather than paid for by every sidebar row: a row that surveys
566
+ // twenty workspaces must not spawn twenty forge processes. Served from the
567
+ // TTL cache and refreshed out of band, exactly like the fetch above — the
568
+ // forge round trip is never route latency.
569
+ if (wantPr) {
570
+ const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
571
+ if (pr !== void 0) info.pr = pr;
572
+ }
317
573
  return info;
318
574
  }
319
575
 
320
576
  //#region git-state watcher
321
577
  /**
322
- * One recursive watcher per workspace `.git` directory. Any event under it
323
- * (HEAD swap, index write, ref update) marks the workspace dirty; a 200ms
324
- * debounce collapses burst events (a single `git commit` touches index,
325
- * refs, COMMIT_EDITMSG, objects…) into one notification.
578
+ * One recursive watcher per workspace, plus a second one when the git dir lives
579
+ * outside the workspace (a linked worktree). Any event under either (HEAD swap,
580
+ * index write, ref update, worktree save) marks the workspace dirty; a 200ms
581
+ * debounce collapses burst events (a single `git commit` touches index, refs,
582
+ * COMMIT_EDITMSG, objects…) into one notification.
326
583
  */
327
584
  const watchers = new Map();
328
585
  const changeListeners = new Set();
@@ -330,9 +587,12 @@ const changeListeners = new Set();
330
587
  const openStreams = new Set();
331
588
 
332
589
  function notifyChange(key) {
590
+ // `workspace` lets a client that asked by id match the event without ever
591
+ // knowing a filesystem path; `path` stays for debugging and other consumers.
592
+ const payload = { path: key, workspace: watchers.get(key)?.workspaceId };
333
593
  for (const fn of changeListeners) {
334
594
  try {
335
- fn(key);
595
+ fn(payload);
336
596
  } catch {
337
597
  /* a dead SSE subscriber must never break the others */
338
598
  }
@@ -355,45 +615,194 @@ function closeStream(stream) {
355
615
  }
356
616
  }
357
617
 
618
+ /**
619
+ * Close every watcher attached to a record, leaving the record itself in place
620
+ * — the retry backoff and the degraded poll live on it. Tolerates a record
621
+ * whose watchers were never established, so every record shape is safe.
622
+ */
623
+ function closeWatchers(record) {
624
+ for (const watcher of [record.watcher, record.extra]) {
625
+ if (watcher === null || watcher === void 0) continue;
626
+ try {
627
+ watcher.close();
628
+ } catch {
629
+ /* already closed */
630
+ }
631
+ }
632
+ record.watcher = null;
633
+ if ("extra" in record) record.extra = null;
634
+ }
635
+
358
636
  function unwatchWorkspace(key) {
359
637
  const record = watchers.get(key);
360
638
  if (record === void 0) return;
361
639
  watchers.delete(key);
362
- if (record.watcher !== null) record.watcher.close();
640
+ closeWatchers(record);
363
641
  if (record.timer !== void 0) clearTimeout(record.timer);
642
+ if (record.poll !== void 0) clearInterval(record.poll);
643
+ }
644
+
645
+ /**
646
+ * Change-detection key for a workspace whose watcher failed. Deliberately cheap:
647
+ * it runs every `config.pollFallbackMs`, so the `-uall` walk is off the table.
648
+ * `status` covers worktree + index, the refs fingerprint covers what a fetch or
649
+ * an external checkout moves, and the marker stat covers an in-progress
650
+ * operation (which porcelain status does not report).
651
+ *
652
+ * The price of the cheap status: a new file inside an ALREADY untracked
653
+ * directory does not change the key, so on a broken-watcher workspace that case
654
+ * waits for the client's 60s poll. Recorded in TESTING.md rather than papered
655
+ * over with a `-uall` walk every few seconds. The git dir is resolved once and
656
+ * cached on the record.
657
+ *
658
+ * Resolves null when git could not run this tick; the caller keeps the previous
659
+ * key and tries again.
660
+ */
661
+ async function fallbackStateKey(root, record) {
662
+ const status = await runGit(root, [
663
+ "--no-optional-locks",
664
+ "status",
665
+ "--porcelain=v2",
666
+ "--branch",
667
+ "--untracked-files=normal"
668
+ ]);
669
+ if (status.stdout === null) return null;
670
+ if (record.gitDir === void 0) {
671
+ const dir = await runGit(root, ["rev-parse", "--absolute-git-dir"]);
672
+ record.gitDir = dir.stdout === null ? "" : dir.stdout.trim();
673
+ }
674
+ const refs = await runGit(root, ["for-each-ref", "--format=%(refname)%(objectname)", "refs/heads", "refs/remotes"]);
675
+ return [status.stdout, refs.stdout ?? "", operationMarker(record.gitDir) ?? ""].join("\u0000");
676
+ }
677
+
678
+ /**
679
+ * Start the degraded-mode poll for a workspace whose watcher could not be
680
+ * established, or later errored. Only ever called for a workspace that HAS a
681
+ * .git — a directory without one is not a repository, so polling it would be
682
+ * waste; the retry backoff keeps re-checking that case instead.
683
+ *
684
+ * The interval is unref'd so a forgotten one can never hold the process open
685
+ * (test runs included); `unwatchWorkspace` clears it properly.
686
+ */
687
+ function startFallbackPoll(key, root) {
688
+ const record = watchers.get(key);
689
+ if (record === void 0 || record.poll !== void 0) return;
690
+ const tick = () => {
691
+ void (async () => {
692
+ const current = watchers.get(key);
693
+ if (current === void 0) return;
694
+ const next = await fallbackStateKey(root, current).catch(() => null);
695
+ if (next === null) return;
696
+ // the first tick only sets a baseline: a fresh subscriber fetches on
697
+ // mount, so there is nothing to announce yet
698
+ if (current.pollKey !== void 0 && current.pollKey !== next) notifyChange(key);
699
+ current.pollKey = next;
700
+ })();
701
+ };
702
+ record.poll = setInterval(tick, config.pollFallbackMs);
703
+ if (typeof record.poll.unref === "function") record.poll.unref();
704
+ // Baseline immediately rather than one interval from now, so a change landing
705
+ // right after the watcher died is still noticed on the following tick instead
706
+ // of being baked into the baseline and never announced.
707
+ tick();
364
708
  }
365
709
 
366
- function watchWorkspace(root, key) {
710
+ /**
711
+ * The out-of-tree git dir for a workspace whose `.git` is a FILE rather than a
712
+ * directory — a linked worktree (`<main>/.git/worktrees/<name>`) or a submodule
713
+ * (`<parent>/.git/modules/<name>`). Git writes the index, HEAD and reflogs
714
+ * THERE, so a watcher on the worktree root alone never sees a stage, commit or
715
+ * checkout in a linked worktree; it only ever catches file edits. (Symptom
716
+ * before this fix: a worktree's badge moved only on a save or the client's 60s
717
+ * poll, never on commit.) Reading the gitfile mirrors git's own resolution,
718
+ * costs one small read at watch setup rather than another `rev-parse`, and stays
719
+ * correct for submodules. Returns null for a main worktree, whose `.git` is a
720
+ * directory already covered by the root watch.
721
+ */
722
+ function outerGitDir(root) {
723
+ let content;
724
+ try {
725
+ // Reading the DIRECTORY `.git` of a main worktree raises EISDIR, which is
726
+ // the "no out-of-tree git dir" answer; a `.git` symlink to a gitfile
727
+ // still reads, where an isFile() stat would have followed it instead.
728
+ content = readFileSync(join(root, ".git"), "utf8");
729
+ } catch {
730
+ return null;
731
+ }
732
+ const match = /^gitdir:\s*(.+?)\s*$/m.exec(content);
733
+ if (match === null) return null;
734
+ const target = match[1];
735
+ return isAbsolute(target) ? target : resolve(root, target);
736
+ }
737
+
738
+ function watchWorkspace(root, key, workspaceId) {
367
739
  const existing = watchers.get(key);
368
740
  if (existing !== void 0) {
369
741
  // live watcher, or a failed attempt still inside its retry backoff
370
- if (existing.watcher !== null || Date.now() - existing.failedAt < config.watchRetryMs) return;
742
+ if (existing.watcher !== null || Date.now() - existing.failedAt < config.watchRetryMs) {
743
+ existing.workspaceId = workspaceId;
744
+ return;
745
+ }
746
+ // retrying: the degraded poll is replaced by a real watcher, or by a
747
+ // fresh one created below
748
+ if (existing.poll !== void 0) clearInterval(existing.poll);
371
749
  watchers.delete(key);
372
750
  }
373
751
  // only git workspaces need a badge; a missing .git skips the watcher but
374
752
  // retries on the normal backoff (cheap existsSync) so a later `git init`
375
- // in the workspace is picked up
753
+ // in the workspace is picked up. No poll here: a non-repository has nothing
754
+ // to report.
376
755
  if (!existsSync(join(root, ".git"))) {
377
- watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
756
+ watchers.set(key, { watcher: null, extra: null, failedAt: Date.now(), timer: void 0, workspaceId });
378
757
  return;
379
758
  }
759
+ // Both watchers share one debounce and one failure path. Either dying means
760
+ // this workspace can no longer be trusted to be event-driven, so both are
761
+ // dropped and the degraded poll covers it until the retry backoff
762
+ // re-establishes them.
763
+ const onEvent = () => {
764
+ const record = watchers.get(key);
765
+ if (record === void 0) return;
766
+ clearTimeout(record.timer);
767
+ record.timer = setTimeout(() => notifyChange(key), config.debounceMs);
768
+ };
769
+ const onError = () => {
770
+ // The watcher died after being established (inotify budget, tree
771
+ // replaced, permissions). Drop it, but KEEP the record so the retry
772
+ // backoff and the degraded poll both have somewhere to live — and
773
+ // start polling so this workspace stays fresh in the meantime.
774
+ const current = watchers.get(key);
775
+ if (current === void 0) return;
776
+ closeWatchers(current);
777
+ current.failedAt = Date.now();
778
+ startFallbackPoll(key, root);
779
+ };
380
780
  try {
381
- // Watch the whole worktree, .git included: worktree edits (the most
382
- // common dirty signal) and metadata ops (commit / checkout / stage)
383
- // all surface here. The 200ms debounce collapses save bursts; if the
384
- // tree is too large for the inotify budget the error handler drops
385
- // back to the 60s client poll.
386
- const watcher = watch(root, { recursive: true }, () => {
387
- const record = watchers.get(key);
388
- if (record === void 0) return;
389
- clearTimeout(record.timer);
390
- record.timer = setTimeout(() => notifyChange(key), config.debounceMs);
391
- });
392
- watcher.on("error", () => unwatchWorkspace(key));
393
- watchers.set(key, { watcher, timer: void 0 });
781
+ // Watch the whole worktree: worktree edits (the most common dirty signal)
782
+ // surface here and the debounce collapses save bursts. In a MAIN worktree
783
+ // this covers git metadata too, because its git dir IS `<root>/.git`.
784
+ const watcher = watch(root, { recursive: true }, onEvent);
785
+ watcher.on("error", onError);
786
+ // A linked worktree's git dir is OUTSIDE root, so staging, committing and
787
+ // checking out there write nothing this watch can see. Watch it too; if it
788
+ // cannot be watched the root watch still covers file edits, and the
789
+ // degraded poll is not started for a merely partial loss.
790
+ const gitDir = outerGitDir(root);
791
+ let extra = null;
792
+ if (gitDir !== null) {
793
+ try {
794
+ extra = watch(gitDir, { recursive: true }, onEvent);
795
+ extra.on("error", onError);
796
+ } catch {
797
+ extra = null;
798
+ }
799
+ }
800
+ watchers.set(key, { watcher, extra, timer: void 0, workspaceId });
394
801
  } catch {
395
- // watch refused (permissions, watch budget) — remember and back off
396
- watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
802
+ // watch refused (permissions, watch budget) — back off AND poll: this
803
+ // repository is real, just unwatchable
804
+ watchers.set(key, { watcher: null, extra: null, failedAt: Date.now(), timer: void 0, workspaceId });
805
+ startFallbackPoll(key, root);
397
806
  }
398
807
  }
399
808
 
@@ -401,15 +810,66 @@ function watchWorkspace(root, key) {
401
810
  function syncWatchers(ctx) {
402
811
  const wanted = new Set();
403
812
  for (const entity of ctx.workspaceRegistry.list()) {
404
- const p = entity?.record?.path ?? entity?.path;
405
- if (typeof p !== "string") continue;
406
- wanted.add(p);
407
- watchWorkspace(p, p);
813
+ // `path` and `id` are the entity's public surface; its `record` is private
814
+ if (typeof entity?.path !== "string") continue;
815
+ wanted.add(entity.path);
816
+ watchWorkspace(entity.path, entity.path, entity.id === void 0 ? void 0 : String(entity.id));
408
817
  }
409
818
  for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
410
819
  }
411
820
  //#endregion
412
821
 
822
+ /**
823
+ * Resolve a request to a registered workspace directory. The caller says WHO it
824
+ * is, never WHERE to look — no client-supplied path is trusted or probed.
825
+ *
826
+ * 1. `?workspace=<id>` — the entity's own id, which is the same id the sidebar
827
+ * seam hands a row (the client builds `workspaceId` from `workspace.id`).
828
+ * 2. `?session=<id>` — registry membership first, because `sessionIds` is
829
+ * header-validated and durable, so a conversation that is no longer live
830
+ * still resolves; then the live session's cwd validated through the
831
+ * registry's `resolveByPath`, which covers the window where a brand-new
832
+ * session is not yet attached to its workspace.
833
+ *
834
+ * @returns `{ id, path }` when resolved, else `{ status, error: { code, message } }`.
835
+ * The id is returned because the route echoes it: a client that asked by
836
+ * SESSION needs the workspace id to attribute that workspace's SSE events,
837
+ * since an event carries a workspace id and a session id can never match it.
838
+ */
839
+ async function resolveWorkspace(ctx, params) {
840
+ const workspaceId = params.get("workspace") ?? "";
841
+ const sessionId = params.get("session") ?? "";
842
+ if (workspaceId === "" && sessionId === "") {
843
+ return { status: 400, error: { code: "target-required", message: "pass workspace=<id> or session=<id>" } };
844
+ }
845
+ const entities = [];
846
+ for (const entity of ctx.workspaceRegistry.list()) {
847
+ if (typeof entity?.path === "string") entities.push(entity);
848
+ }
849
+ if (workspaceId !== "") {
850
+ const entity = entities.find((candidate) => String(candidate.id) === workspaceId);
851
+ if (entity === void 0) {
852
+ return { status: 404, error: { code: "workspace-not-found", message: "no workspace with that id" } };
853
+ }
854
+ return { id: String(entity.id), path: entity.path };
855
+ }
856
+ for (const entity of entities) {
857
+ if (Array.isArray(entity.sessionIds) && entity.sessionIds.includes(sessionId)) {
858
+ return { id: String(entity.id), path: entity.path };
859
+ }
860
+ }
861
+ // A brand-new session whose workspace attach has not landed yet. Trust the
862
+ // server-side session header (never the caller), then require the registry to
863
+ // recognise that directory — so the path is still registry-validated.
864
+ const cwd = ctx.get("sessions")?.get?.(sessionId)?.header?.cwd;
865
+ const resolveByPath = ctx.workspaceRegistry.resolveByPath;
866
+ if (typeof cwd === "string" && cwd !== "" && typeof resolveByPath === "function") {
867
+ const entity = await resolveByPath.call(ctx.workspaceRegistry, cwd).catch(() => void 0);
868
+ if (entity !== void 0 && typeof entity?.path === "string") return { id: String(entity.id), path: entity.path };
869
+ }
870
+ return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
871
+ }
872
+
413
873
  /** Host plugin body — register the status route and the SSE change feed. */
414
874
  function apply(ctx) {
415
875
  ctx.effect(() => ctx.webServer.register({
@@ -418,28 +878,24 @@ function apply(ctx) {
418
878
  handler: async (req, res) => {
419
879
  try {
420
880
  const url = new URL(req.url, "http://localhost");
421
- const raw = url.searchParams.get("path") ?? "";
422
- if (raw === "") throw new Error("missing path");
423
- // Allowlist: the requested path must be (or resolve to) a registered
424
- // workspace directory. Anything else is refused without inspection.
425
- const registered = new Set();
426
- for (const entity of ctx.workspaceRegistry.list()) {
427
- const p = entity?.record?.path ?? entity?.path;
428
- if (typeof p === "string") registered.add(p);
429
- }
430
- const resolved = await realpath(raw).catch(() => null);
431
- if (resolved === null || !registered.has(raw) && !registered.has(resolved)) {
432
- res.writeHead(403, { "content-type": "application/json" });
433
- res.end(JSON.stringify({ git: false, error: "path is not a registered workspace" }));
881
+ const target = await resolveWorkspace(ctx, url.searchParams);
882
+ if (target.error !== void 0) {
883
+ res.writeHead(target.status, { "content-type": "application/json" });
884
+ res.end(JSON.stringify({ git: false, error: target.error }));
434
885
  return;
435
886
  }
436
887
  const detail = url.searchParams.get("detail") === "1";
437
- const body = JSON.stringify(await gitStatus(resolved, detail));
888
+ const pr = url.searchParams.get("pr") === "1";
889
+ const info = await gitStatus(target.path, detail, pr);
890
+ // Echo the resolved workspace id. SSE events identify the workspace,
891
+ // not the session, so a session-targeted client has no other way to
892
+ // tell whether an event belongs to it — without this the input chip
893
+ // only ever refreshed on remount or the 60s poll.
438
894
  res.writeHead(200, { "content-type": "application/json" });
439
- res.end(body);
895
+ res.end(JSON.stringify({ ...info, workspace: target.id }));
440
896
  } catch (error) {
441
897
  res.writeHead(400, { "content-type": "application/json" });
442
- res.end(JSON.stringify({ git: false, error: String(error?.message ?? error) }));
898
+ res.end(JSON.stringify({ git: false, error: { code: "bad-request", message: String(error?.message ?? error) } }));
443
899
  }
444
900
  }
445
901
  }));
@@ -464,12 +920,12 @@ function apply(ctx) {
464
920
  const stream = {
465
921
  res,
466
922
  heartbeat: null,
467
- send: (key) => {
923
+ send: (payload) => {
468
924
  try {
469
925
  // NAMED frame: the client subscribes with
470
926
  // addEventListener("change", …), which never sees an
471
927
  // unnamed message
472
- res.write(`event: change\ndata: ${JSON.stringify({ path: key })}\n\n`);
928
+ res.write(`event: change\ndata: ${JSON.stringify(payload)}\n\n`);
473
929
  } catch {
474
930
  /* socket gone; the close handler cleans up */
475
931
  }
@@ -510,9 +966,17 @@ export {
510
966
  config,
511
967
  OPERATION_MARKERS,
512
968
  operationMarker,
969
+ outerGitDir,
513
970
  parseStatusV2,
971
+ runCli,
514
972
  runGit,
515
973
  gitStatus,
974
+ resolveWorkspace,
975
+ readPrStatus,
976
+ summarizePr,
977
+ summarizeChecks,
978
+ prStatusFor,
979
+ prState,
516
980
  watchWorkspace,
517
981
  unwatchWorkspace,
518
982
  syncWatchers,