dsh-git-badge 0.5.5 → 0.7.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,42 +3,106 @@
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
- * --porcelain=v2 --branch` yields branch, ahead/behind, and the
12
- * changed / untracked counts in a single call (`--no-optional-locks`
13
- * guarantees the read never contends with the user's own git
14
- * operations on the index).
15
- * - ahead/behind compare against the LOCAL remote-tracking ref, which
16
- * only moves on fetch — so a TTL-bounded background fetch (60s per
17
- * toplevel) runs before status when the ref can be stale. `GIT_TERMINAL_PROMPT=0`
18
- * and the shared timeout keep an offline/slow remote from stalling the
19
- * route; on fetch failure the stale-ref answer is served as-is.
20
- * - `detail=1` adds one `log -1` for the hover card; row refresh never
21
- * pays for it.
17
+ * --porcelain=v2 --branch --untracked-files=all` yields branch,
18
+ * ahead/behind, and the changed / untracked counts in a single call
19
+ * (`--no-optional-locks` guarantees the read never contends with the
20
+ * user's own git operations on the index). `--untracked-files=all`
21
+ * counts untracked FILES; git's default collapses a new directory into
22
+ * one entry, which under-reported `✎n` for anyone who adds a folder.
23
+ * If that walk blows the budget the sample is retried collapsed and the
24
+ * response reports which mode answered (`untrackedMode`).
25
+ * - one `git rev-parse --show-toplevel --absolute-git-dir` resolves both
26
+ * the repository root (a subdirectory workspace reports the whole repo)
27
+ * and the per-worktree git dir, where the eight in-progress operation
28
+ * markers (`MERGE_HEAD`, `rebase-merge`, …) are stat'd — surfaced as
29
+ * `operation`, so a paused rebase/cherry-pick is distinguishable from
30
+ * ordinary dirty work even when its conflicts are already staged.
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. 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.
22
50
  *
23
51
  * GET /api/git-badge/events (text/event-stream)
24
52
  * - pushes `{ path }` dirty notifications the moment a workspace's git
25
53
  * state can have changed. Freshness is EVENT-DRIVEN: each registered
26
- * workspace root is watched recursively (.git included), so a commit /
27
- * checkout / stage / worktree edit pushes one SSE message and every
28
- * mounted row refetches immediately. There is no fixed polling
29
- * interval anywhere in the pipeline.
54
+ * workspace is watched recursively, so a commit / checkout / stage /
55
+ * worktree edit pushes one SSE message and every mounted row refetches
56
+ * immediately. There is no fixed polling interval anywhere in the
57
+ * pipeline. A MAIN worktree's git dir is `<root>/.git` and so is covered
58
+ * by the root watch; a LINKED worktree's is not, so its git dir gets a
59
+ * second watcher (see the watcher region).
30
60
  *
31
61
  * When upstream lands a workspace-metadata service, this half is deleted and
32
62
  * the client half re-points at it — the seam contract does not change.
33
63
  */
34
64
  import { execFile } from "node:child_process";
35
- import { realpath } from "node:fs/promises";
36
- import { watch, existsSync } from "node:fs";
37
- import { join } from "node:path";
65
+ import { watch, existsSync, readFileSync } from "node:fs";
66
+ import { basename, isAbsolute, join, resolve } from "node:path";
38
67
 
39
68
  const inject = ["webServer", "workspaceRegistry"];
40
69
  const name = "dsh-git-badge";
41
70
 
71
+ /**
72
+ * Runtime tunables. Production code never writes these; the test suite mutates
73
+ * them to collapse the debounce / backoff / heartbeat windows and to force the
74
+ * untracked-walk timeout deterministically instead of waiting on a pathological
75
+ * repository. One exported object rather than scattered consts so tests have a
76
+ * single documented seam.
77
+ */
78
+ const config = {
79
+ /** budget for every git invocation; an expired call is killed */
80
+ gitTimeoutMs: 3000,
81
+ /** network fetch budget; worst-case route latency, paid once per TTL */
82
+ fetchTimeoutMs: 8000,
83
+ /** minimum interval between background fetches per toplevel */
84
+ fetchTtlMs: 60000,
85
+ /** fs-event burst collapse window */
86
+ debounceMs: 200,
87
+ /** backoff before re-attempting a failed watcher (or a missing .git) */
88
+ watchRetryMs: 60000,
89
+ /**
90
+ * Degraded-mode poll interval for a workspace whose watcher failed. Only used
91
+ * where a real watcher could not be established or later errored — a healthy
92
+ * workspace is event-driven and never polls.
93
+ */
94
+ pollFallbackMs: 5000,
95
+ /** SSE comment heartbeat interval */
96
+ heartbeatMs: 25000,
97
+ /**
98
+ * Invoker for the status sample; null means the real runGit. The suite
99
+ * injects one that reports the `-uall` call as timed out, which exercises the
100
+ * collapsed-fallback branch deterministically — a real sub-millisecond budget
101
+ * would just race git's own startup.
102
+ */
103
+ gitRunner: null
104
+ };
105
+
42
106
  /**
43
107
  * Run git in dir. Resolves { stdout } on success; on failure { stdout: null }
44
108
  * plus exactly one of: timeout (execFile timeout kill), missing (no git
@@ -47,7 +111,7 @@ const name = "dsh-git-badge";
47
111
  * from a transient one (degraded).
48
112
  */
49
113
  function runGit(dir, args, opts) {
50
- const { env, timeout = 3000 } = opts ?? {};
114
+ const { env, timeout = config.gitTimeoutMs } = opts ?? {};
51
115
  // env is a full replacement for execFile; overlay onto process.env so
52
116
  // PATH/HOME (credential helpers, global config) survive
53
117
  const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
@@ -70,12 +134,11 @@ const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)"
70
134
  * `git fetch` — without one, a remote update (push from elsewhere, GitHub
71
135
  * edit) is invisible to the badge until the user happens to fetch. A
72
136
  * TTL-bounded fetch closes that gap: at most one network round trip per
73
- * FETCH_TTL_MS per repository, regardless of how often the route is hit
137
+ * `config.fetchTtlMs` per repository, regardless of how often the route is hit
74
138
  * (watcher bursts included). Mirrors what IDEs do (VS Code's throttled
75
139
  * auto-fetch), with a TTL rather than a timer: fetch only happens when
76
140
  * someone is actually looking at the badge.
77
141
  */
78
- const FETCH_TTL_MS = 60000;
79
142
  /** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
80
143
  const fetchState = new Map();
81
144
 
@@ -91,7 +154,7 @@ async function maybeFetch(toplevel) {
91
154
  const state = fetchState.get(toplevel);
92
155
  if (state !== void 0) {
93
156
  if (state.inFlight !== null) return state.inFlight;
94
- if (now - state.lastAttemptAt < FETCH_TTL_MS) return false;
157
+ if (now - state.lastAttemptAt < config.fetchTtlMs) return false;
95
158
  }
96
159
  const inFlight = (async () => {
97
160
  // --no-tags --prune: refs-only refresh, cheapest correct form.
@@ -102,8 +165,8 @@ async function maybeFetch(toplevel) {
102
165
  env: { GIT_TERMINAL_PROMPT: "0" },
103
166
  // measured real-world fetch is ~3s on SSH; the status-call 3s budget
104
167
  // would kill healthy fetches on a slow link. 8s is the worst-case
105
- // route latency, paid at most once per FETCH_TTL_MS per workspace.
106
- timeout: 8000
168
+ // route latency, paid at most once per fetchTtlMs per workspace.
169
+ timeout: config.fetchTimeoutMs
107
170
  });
108
171
  const ok = out.stdout !== null;
109
172
  fetchState.set(toplevel, { lastAttemptAt: Date.now(), inFlight: null });
@@ -151,6 +214,37 @@ function parseStatusV2(out) {
151
214
  return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
152
215
  }
153
216
 
217
+ /**
218
+ * In-progress operation markers, in precedence order. Git drops one of these in
219
+ * the per-worktree git dir while an operation is paused; the first match names
220
+ * the operation. `SQUASH_MSG` is listed in its own right because a squash merge
221
+ * records no `MERGE_HEAD`.
222
+ */
223
+ const OPERATION_MARKERS = [
224
+ ["MERGE_HEAD", "merge"],
225
+ ["SQUASH_MSG", "squash"],
226
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
227
+ ["REVERT_HEAD", "revert"],
228
+ ["BISECT_LOG", "bisect"],
229
+ ["rebase-merge", "rebase"],
230
+ ["rebase-apply", "rebase"],
231
+ ["sequencer", "sequencer"]
232
+ ];
233
+
234
+ /**
235
+ * Name of the git operation currently paused in gitDir, or null. Markers live in
236
+ * the per-worktree git dir (not the shared one), so the caller passes
237
+ * `rev-parse --absolute-git-dir`. Stat'ing the paths beats eight
238
+ * `rev-parse --git-path` invocations — these are plain existence checks.
239
+ */
240
+ function operationMarker(gitDir) {
241
+ if (gitDir === "") return null;
242
+ for (const [marker, operation] of OPERATION_MARKERS) {
243
+ if (existsSync(join(gitDir, marker))) return operation;
244
+ }
245
+ return null;
246
+ }
247
+
154
248
  /**
155
249
  * Git status for dir; { git: false } when dir is not a repository,
156
250
  * GIT_DEGRADED on transient git failure.
@@ -159,31 +253,76 @@ function parseStatusV2(out) {
159
253
  * workspace pointing INSIDE a larger repository reports that repository's
160
254
  * branch, dirty state, and ahead/behind counts (what git itself considers
161
255
  * dirty), not the subdirectory in isolation. Intentional.
256
+ *
257
+ * Worktree identity is reported, not assumed: `isWorktree` marks a linked
258
+ * worktree and `worktreeName` names the checkout. A subdirectory of a linked
259
+ * worktree reports that worktree (the toplevel walk starts above it), which is
260
+ * the same whole-repository rule as above.
261
+ *
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.
162
265
  */
163
266
  async function gitStatus(dir, wantDetail) {
164
- const top = await runGit(dir, ["rev-parse", "--show-toplevel"]);
267
+ // one invocation answers both questions: the repository root (so a
268
+ // subdirectory workspace reports the whole repo) and the per-worktree git
269
+ // dir (where the operation markers above live)
270
+ const top = await runGit(dir, ["rev-parse", "--show-toplevel", "--absolute-git-dir"]);
165
271
  if (top.stdout === null) {
166
272
  // git ran and rejected ("not a repository") is a definitive answer;
167
273
  // timeout / missing binary is transient
168
274
  return top.exitCode !== void 0 ? { git: false } : GIT_DEGRADED;
169
275
  }
170
- const toplevel = top.stdout.trim();
276
+ const topLines = top.stdout.trim().split("\n");
277
+ const toplevel = (topLines[0] ?? "").trim();
171
278
  if (toplevel === "") return { git: false };
172
- const sample = () => runGit(toplevel, ["--no-optional-locks", "status", "--porcelain=v2", "--branch"]);
173
- let statusOut = await sample();
279
+ const gitDir = (topLines[1] ?? "").trim();
280
+ // A linked worktree's git dir carries a `commondir` file pointing back at
281
+ // the shared git dir; a main worktree's does not. Stat'ing it costs nothing
282
+ // extra — `gitDir` came from the invocation above. A submodule also has an
283
+ // out-of-tree git dir but no `commondir`, so it is correctly NOT a worktree.
284
+ const isWorktree = existsSync(join(gitDir, "commondir"));
285
+ // -uall counts untracked FILES; git's default collapses a new directory into
286
+ // a single entry, which is why `✎n` under-reported anyone who added a folder.
287
+ // Routed through config.gitRunner so the suite can force the timeout branch.
288
+ const sample = (untrackedAll) => (config.gitRunner ?? runGit)(toplevel, [
289
+ "--no-optional-locks",
290
+ "status",
291
+ "--porcelain=v2",
292
+ "--branch",
293
+ untrackedAll ? "--untracked-files=all" : "--untracked-files=normal"
294
+ ]);
295
+ let untrackedMode = "all";
296
+ let statusOut = await sample(true);
297
+ if (statusOut.timeout) {
298
+ // A pathological tree (a huge unignored directory) must not cost the whole
299
+ // badge: retry collapsed and report which answer was served, rather than
300
+ // degrading — or worse, silently reporting a count the client cannot
301
+ // interpret.
302
+ untrackedMode = "collapsed";
303
+ statusOut = await sample(false);
304
+ }
174
305
  if (statusOut.stdout === null) return GIT_DEGRADED;
175
- let parsed = parseStatusV2(statusOut.stdout);
176
- // The upstream header is the cheap gate: no upstream configured → the
177
- // fetch would be a no-op probe, skip it entirely. When it fires and the
178
- // fetch succeeds, re-sample so this very response already carries the
179
- // fresh ahead/behind instead of lagging one cycle behind.
306
+ const parsed = parseStatusV2(statusOut.stdout);
307
+ // The upstream header is the cheap gate: with no upstream the fetch would be a
308
+ // no-op probe, so skip it. With one, refresh the remote-tracking refs OUT OF
309
+ // BAND rather than before the answer.
310
+ //
311
+ // Blocking here cost ~3.3s per request (measured: `git fetch` over SSH) on every
312
+ // request whose TTL had lapsed — and that is the first refresh after each 60s
313
+ // window, i.e. constantly while someone is editing, which made an edit-triggered
314
+ // badge update take seconds. So: answer now from the refs we already have, and
315
+ // when the background fetch succeeds, notify subscribers — the client refetches,
316
+ // finds the TTL fresh, and picks up the corrected ahead/behind in ~50ms. The
317
+ // cost of the trade is that ahead/behind can lag by up to one fetch.
180
318
  if (parsed.upstream !== void 0) {
181
- const fetched = await maybeFetch(toplevel);
182
- if (fetched) {
183
- statusOut = await sample();
184
- if (statusOut.stdout === null) return GIT_DEGRADED;
185
- parsed = parseStatusV2(statusOut.stdout);
186
- }
319
+ void maybeFetch(toplevel)
320
+ .then((fetched) => {
321
+ if (fetched) notifyChange(toplevel);
322
+ })
323
+ .catch(() => {
324
+ /* a failed fetch just means the stale-ref answer stands */
325
+ });
187
326
  }
188
327
  const dirtyFiles = parsed.staged + parsed.unstaged + parsed.unmerged + parsed.untracked;
189
328
  const info = {
@@ -196,11 +335,17 @@ async function gitStatus(dir, wantDetail) {
196
335
  unstagedFiles: parsed.unstaged,
197
336
  unmergedFiles: parsed.unmerged,
198
337
  untrackedFiles: parsed.untracked,
338
+ untrackedMode,
339
+ operation: operationMarker(gitDir),
340
+ isWorktree,
341
+ // the checkout's own directory name (of the worktree root, so a
342
+ // subdirectory workspace still names the checkout it belongs to)
343
+ worktreeName: basename(toplevel),
199
344
  ahead: parsed.ahead,
200
345
  behind: parsed.behind
201
346
  };
202
347
  if (wantDetail) {
203
- // last 3 commits (subject + relative age) for the hover card
348
+ // last 3 commits (subject + relative age) for the planned hover card
204
349
  const logOut = await runGit(toplevel, ["log", "-3", "--format=%h%x09%s%x09%cr"]);
205
350
  if (logOut.stdout !== null && logOut.stdout.trim() !== "") {
206
351
  info.lastCommits = logOut.stdout.trim().split("\n").map((line) => {
@@ -220,65 +365,234 @@ async function gitStatus(dir, wantDetail) {
220
365
 
221
366
  //#region git-state watcher
222
367
  /**
223
- * One recursive watcher per workspace `.git` directory. Any event under it
224
- * (HEAD swap, index write, ref update) marks the workspace dirty; a 200ms
225
- * debounce collapses burst events (a single `git commit` touches index,
226
- * refs, COMMIT_EDITMSG, objects…) into one notification.
368
+ * One recursive watcher per workspace, plus a second one when the git dir lives
369
+ * outside the workspace (a linked worktree). Any event under either (HEAD swap,
370
+ * index write, ref update, worktree save) marks the workspace dirty; a 200ms
371
+ * debounce collapses burst events (a single `git commit` touches index, refs,
372
+ * COMMIT_EDITMSG, objects…) into one notification.
227
373
  */
228
374
  const watchers = new Map();
229
375
  const changeListeners = new Set();
230
- const WATCH_RETRY_MS = 60000;
231
- const DEBOUNCE_MS = 200;
376
+ /** Open SSE responses, so plugin unload can end them instead of leaking them. */
377
+ const openStreams = new Set();
232
378
 
233
379
  function notifyChange(key) {
380
+ // `workspace` lets a client that asked by id match the event without ever
381
+ // knowing a filesystem path; `path` stays for debugging and other consumers.
382
+ const payload = { path: key, workspace: watchers.get(key)?.workspaceId };
234
383
  for (const fn of changeListeners) {
235
384
  try {
236
- fn(key);
385
+ fn(payload);
237
386
  } catch {
238
387
  /* a dead SSE subscriber must never break the others */
239
388
  }
240
389
  }
241
390
  }
242
391
 
392
+ /**
393
+ * Tear down one SSE stream. Idempotent: `req` and `res` both fire `close` and
394
+ * the plugin disposer can race them, so membership in openStreams is the single
395
+ * source of truth for "still open".
396
+ */
397
+ function closeStream(stream) {
398
+ if (!openStreams.delete(stream)) return;
399
+ changeListeners.delete(stream.send);
400
+ clearInterval(stream.heartbeat);
401
+ try {
402
+ stream.res.end();
403
+ } catch {
404
+ /* already gone */
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Close every watcher attached to a record, leaving the record itself in place
410
+ * — the retry backoff and the degraded poll live on it. Tolerates a record
411
+ * whose watchers were never established, so every record shape is safe.
412
+ */
413
+ function closeWatchers(record) {
414
+ for (const watcher of [record.watcher, record.extra]) {
415
+ if (watcher === null || watcher === void 0) continue;
416
+ try {
417
+ watcher.close();
418
+ } catch {
419
+ /* already closed */
420
+ }
421
+ }
422
+ record.watcher = null;
423
+ if ("extra" in record) record.extra = null;
424
+ }
425
+
243
426
  function unwatchWorkspace(key) {
244
427
  const record = watchers.get(key);
245
428
  if (record === void 0) return;
246
429
  watchers.delete(key);
247
- if (record.watcher !== null) record.watcher.close();
430
+ closeWatchers(record);
248
431
  if (record.timer !== void 0) clearTimeout(record.timer);
432
+ if (record.poll !== void 0) clearInterval(record.poll);
433
+ }
434
+
435
+ /**
436
+ * Change-detection key for a workspace whose watcher failed. Deliberately cheap:
437
+ * it runs every `config.pollFallbackMs`, so the `-uall` walk is off the table.
438
+ * `status` covers worktree + index, the refs fingerprint covers what a fetch or
439
+ * an external checkout moves, and the marker stat covers an in-progress
440
+ * operation (which porcelain status does not report).
441
+ *
442
+ * The price of the cheap status: a new file inside an ALREADY untracked
443
+ * directory does not change the key, so on a broken-watcher workspace that case
444
+ * waits for the client's 60s poll. Recorded in TESTING.md rather than papered
445
+ * over with a `-uall` walk every few seconds. The git dir is resolved once and
446
+ * cached on the record.
447
+ *
448
+ * Resolves null when git could not run this tick; the caller keeps the previous
449
+ * key and tries again.
450
+ */
451
+ async function fallbackStateKey(root, record) {
452
+ const status = await runGit(root, [
453
+ "--no-optional-locks",
454
+ "status",
455
+ "--porcelain=v2",
456
+ "--branch",
457
+ "--untracked-files=normal"
458
+ ]);
459
+ if (status.stdout === null) return null;
460
+ if (record.gitDir === void 0) {
461
+ const dir = await runGit(root, ["rev-parse", "--absolute-git-dir"]);
462
+ record.gitDir = dir.stdout === null ? "" : dir.stdout.trim();
463
+ }
464
+ const refs = await runGit(root, ["for-each-ref", "--format=%(refname)%(objectname)", "refs/heads", "refs/remotes"]);
465
+ return [status.stdout, refs.stdout ?? "", operationMarker(record.gitDir) ?? ""].join("\u0000");
249
466
  }
250
467
 
251
- function watchWorkspace(root, key) {
468
+ /**
469
+ * Start the degraded-mode poll for a workspace whose watcher could not be
470
+ * established, or later errored. Only ever called for a workspace that HAS a
471
+ * .git — a directory without one is not a repository, so polling it would be
472
+ * waste; the retry backoff keeps re-checking that case instead.
473
+ *
474
+ * The interval is unref'd so a forgotten one can never hold the process open
475
+ * (test runs included); `unwatchWorkspace` clears it properly.
476
+ */
477
+ function startFallbackPoll(key, root) {
478
+ const record = watchers.get(key);
479
+ if (record === void 0 || record.poll !== void 0) return;
480
+ const tick = () => {
481
+ void (async () => {
482
+ const current = watchers.get(key);
483
+ if (current === void 0) return;
484
+ const next = await fallbackStateKey(root, current).catch(() => null);
485
+ if (next === null) return;
486
+ // the first tick only sets a baseline: a fresh subscriber fetches on
487
+ // mount, so there is nothing to announce yet
488
+ if (current.pollKey !== void 0 && current.pollKey !== next) notifyChange(key);
489
+ current.pollKey = next;
490
+ })();
491
+ };
492
+ record.poll = setInterval(tick, config.pollFallbackMs);
493
+ if (typeof record.poll.unref === "function") record.poll.unref();
494
+ // Baseline immediately rather than one interval from now, so a change landing
495
+ // right after the watcher died is still noticed on the following tick instead
496
+ // of being baked into the baseline and never announced.
497
+ tick();
498
+ }
499
+
500
+ /**
501
+ * The out-of-tree git dir for a workspace whose `.git` is a FILE rather than a
502
+ * directory — a linked worktree (`<main>/.git/worktrees/<name>`) or a submodule
503
+ * (`<parent>/.git/modules/<name>`). Git writes the index, HEAD and reflogs
504
+ * THERE, so a watcher on the worktree root alone never sees a stage, commit or
505
+ * checkout in a linked worktree; it only ever catches file edits. (Symptom
506
+ * before this fix: a worktree's badge moved only on a save or the client's 60s
507
+ * poll, never on commit.) Reading the gitfile mirrors git's own resolution,
508
+ * costs one small read at watch setup rather than another `rev-parse`, and stays
509
+ * correct for submodules. Returns null for a main worktree, whose `.git` is a
510
+ * directory already covered by the root watch.
511
+ */
512
+ function outerGitDir(root) {
513
+ let content;
514
+ try {
515
+ // Reading the DIRECTORY `.git` of a main worktree raises EISDIR, which is
516
+ // the "no out-of-tree git dir" answer; a `.git` symlink to a gitfile
517
+ // still reads, where an isFile() stat would have followed it instead.
518
+ content = readFileSync(join(root, ".git"), "utf8");
519
+ } catch {
520
+ return null;
521
+ }
522
+ const match = /^gitdir:\s*(.+?)\s*$/m.exec(content);
523
+ if (match === null) return null;
524
+ const target = match[1];
525
+ return isAbsolute(target) ? target : resolve(root, target);
526
+ }
527
+
528
+ function watchWorkspace(root, key, workspaceId) {
252
529
  const existing = watchers.get(key);
253
530
  if (existing !== void 0) {
254
531
  // live watcher, or a failed attempt still inside its retry backoff
255
- if (existing.watcher !== null || Date.now() - existing.failedAt < WATCH_RETRY_MS) return;
532
+ if (existing.watcher !== null || Date.now() - existing.failedAt < config.watchRetryMs) {
533
+ existing.workspaceId = workspaceId;
534
+ return;
535
+ }
536
+ // retrying: the degraded poll is replaced by a real watcher, or by a
537
+ // fresh one created below
538
+ if (existing.poll !== void 0) clearInterval(existing.poll);
256
539
  watchers.delete(key);
257
540
  }
258
541
  // only git workspaces need a badge; a missing .git skips the watcher but
259
542
  // retries on the normal backoff (cheap existsSync) so a later `git init`
260
- // in the workspace is picked up
543
+ // in the workspace is picked up. No poll here: a non-repository has nothing
544
+ // to report.
261
545
  if (!existsSync(join(root, ".git"))) {
262
- watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
546
+ watchers.set(key, { watcher: null, extra: null, failedAt: Date.now(), timer: void 0, workspaceId });
263
547
  return;
264
548
  }
549
+ // Both watchers share one debounce and one failure path. Either dying means
550
+ // this workspace can no longer be trusted to be event-driven, so both are
551
+ // dropped and the degraded poll covers it until the retry backoff
552
+ // re-establishes them.
553
+ const onEvent = () => {
554
+ const record = watchers.get(key);
555
+ if (record === void 0) return;
556
+ clearTimeout(record.timer);
557
+ record.timer = setTimeout(() => notifyChange(key), config.debounceMs);
558
+ };
559
+ const onError = () => {
560
+ // The watcher died after being established (inotify budget, tree
561
+ // replaced, permissions). Drop it, but KEEP the record so the retry
562
+ // backoff and the degraded poll both have somewhere to live — and
563
+ // start polling so this workspace stays fresh in the meantime.
564
+ const current = watchers.get(key);
565
+ if (current === void 0) return;
566
+ closeWatchers(current);
567
+ current.failedAt = Date.now();
568
+ startFallbackPoll(key, root);
569
+ };
265
570
  try {
266
- // Watch the whole worktree, .git included: worktree edits (the most
267
- // common dirty signal) and metadata ops (commit / checkout / stage)
268
- // all surface here. The 200ms debounce collapses save bursts; if the
269
- // tree is too large for the inotify budget the error handler drops
270
- // back to the 60s client poll.
271
- const watcher = watch(root, { recursive: true }, () => {
272
- const record = watchers.get(key);
273
- if (record === void 0) return;
274
- clearTimeout(record.timer);
275
- record.timer = setTimeout(() => notifyChange(key), DEBOUNCE_MS);
276
- });
277
- watcher.on("error", () => unwatchWorkspace(key));
278
- watchers.set(key, { watcher, timer: void 0 });
571
+ // Watch the whole worktree: worktree edits (the most common dirty signal)
572
+ // surface here and the debounce collapses save bursts. In a MAIN worktree
573
+ // this covers git metadata too, because its git dir IS `<root>/.git`.
574
+ const watcher = watch(root, { recursive: true }, onEvent);
575
+ watcher.on("error", onError);
576
+ // A linked worktree's git dir is OUTSIDE root, so staging, committing and
577
+ // checking out there write nothing this watch can see. Watch it too; if it
578
+ // cannot be watched the root watch still covers file edits, and the
579
+ // degraded poll is not started for a merely partial loss.
580
+ const gitDir = outerGitDir(root);
581
+ let extra = null;
582
+ if (gitDir !== null) {
583
+ try {
584
+ extra = watch(gitDir, { recursive: true }, onEvent);
585
+ extra.on("error", onError);
586
+ } catch {
587
+ extra = null;
588
+ }
589
+ }
590
+ watchers.set(key, { watcher, extra, timer: void 0, workspaceId });
279
591
  } catch {
280
- // watch refused (permissions, watch budget) — remember and back off
281
- watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
592
+ // watch refused (permissions, watch budget) — back off AND poll: this
593
+ // repository is real, just unwatchable
594
+ watchers.set(key, { watcher: null, extra: null, failedAt: Date.now(), timer: void 0, workspaceId });
595
+ startFallbackPoll(key, root);
282
596
  }
283
597
  }
284
598
 
@@ -286,15 +600,66 @@ function watchWorkspace(root, key) {
286
600
  function syncWatchers(ctx) {
287
601
  const wanted = new Set();
288
602
  for (const entity of ctx.workspaceRegistry.list()) {
289
- const p = entity?.record?.path ?? entity?.path;
290
- if (typeof p !== "string") continue;
291
- wanted.add(p);
292
- watchWorkspace(p, p);
603
+ // `path` and `id` are the entity's public surface; its `record` is private
604
+ if (typeof entity?.path !== "string") continue;
605
+ wanted.add(entity.path);
606
+ watchWorkspace(entity.path, entity.path, entity.id === void 0 ? void 0 : String(entity.id));
293
607
  }
294
608
  for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
295
609
  }
296
610
  //#endregion
297
611
 
612
+ /**
613
+ * Resolve a request to a registered workspace directory. The caller says WHO it
614
+ * is, never WHERE to look — no client-supplied path is trusted or probed.
615
+ *
616
+ * 1. `?workspace=<id>` — the entity's own id, which is the same id the sidebar
617
+ * seam hands a row (the client builds `workspaceId` from `workspace.id`).
618
+ * 2. `?session=<id>` — registry membership first, because `sessionIds` is
619
+ * header-validated and durable, so a conversation that is no longer live
620
+ * still resolves; then the live session's cwd validated through the
621
+ * registry's `resolveByPath`, which covers the window where a brand-new
622
+ * session is not yet attached to its workspace.
623
+ *
624
+ * @returns `{ id, path }` when resolved, else `{ status, error: { code, message } }`.
625
+ * The id is returned because the route echoes it: a client that asked by
626
+ * SESSION needs the workspace id to attribute that workspace's SSE events,
627
+ * since an event carries a workspace id and a session id can never match it.
628
+ */
629
+ async function resolveWorkspace(ctx, params) {
630
+ const workspaceId = params.get("workspace") ?? "";
631
+ const sessionId = params.get("session") ?? "";
632
+ if (workspaceId === "" && sessionId === "") {
633
+ return { status: 400, error: { code: "target-required", message: "pass workspace=<id> or session=<id>" } };
634
+ }
635
+ const entities = [];
636
+ for (const entity of ctx.workspaceRegistry.list()) {
637
+ if (typeof entity?.path === "string") entities.push(entity);
638
+ }
639
+ if (workspaceId !== "") {
640
+ const entity = entities.find((candidate) => String(candidate.id) === workspaceId);
641
+ if (entity === void 0) {
642
+ return { status: 404, error: { code: "workspace-not-found", message: "no workspace with that id" } };
643
+ }
644
+ return { id: String(entity.id), path: entity.path };
645
+ }
646
+ for (const entity of entities) {
647
+ if (Array.isArray(entity.sessionIds) && entity.sessionIds.includes(sessionId)) {
648
+ return { id: String(entity.id), path: entity.path };
649
+ }
650
+ }
651
+ // A brand-new session whose workspace attach has not landed yet. Trust the
652
+ // server-side session header (never the caller), then require the registry to
653
+ // recognise that directory — so the path is still registry-validated.
654
+ const cwd = ctx.get("sessions")?.get?.(sessionId)?.header?.cwd;
655
+ const resolveByPath = ctx.workspaceRegistry.resolveByPath;
656
+ if (typeof cwd === "string" && cwd !== "" && typeof resolveByPath === "function") {
657
+ const entity = await resolveByPath.call(ctx.workspaceRegistry, cwd).catch(() => void 0);
658
+ if (entity !== void 0 && typeof entity?.path === "string") return { id: String(entity.id), path: entity.path };
659
+ }
660
+ return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
661
+ }
662
+
298
663
  /** Host plugin body — register the status route and the SSE change feed. */
299
664
  function apply(ctx) {
300
665
  ctx.effect(() => ctx.webServer.register({
@@ -303,67 +668,103 @@ function apply(ctx) {
303
668
  handler: async (req, res) => {
304
669
  try {
305
670
  const url = new URL(req.url, "http://localhost");
306
- const raw = url.searchParams.get("path") ?? "";
307
- if (raw === "") throw new Error("missing path");
308
- // Allowlist: the requested path must be (or resolve to) a registered
309
- // workspace directory. Anything else is refused without inspection.
310
- const registered = new Set();
311
- for (const entity of ctx.workspaceRegistry.list()) {
312
- const p = entity?.record?.path ?? entity?.path;
313
- if (typeof p === "string") registered.add(p);
314
- }
315
- const resolved = await realpath(raw).catch(() => null);
316
- if (resolved === null || !registered.has(raw) && !registered.has(resolved)) {
317
- res.writeHead(403, { "content-type": "application/json" });
318
- res.end(JSON.stringify({ git: false, error: "path is not a registered workspace" }));
671
+ const target = await resolveWorkspace(ctx, url.searchParams);
672
+ if (target.error !== void 0) {
673
+ res.writeHead(target.status, { "content-type": "application/json" });
674
+ res.end(JSON.stringify({ git: false, error: target.error }));
319
675
  return;
320
676
  }
321
677
  const detail = url.searchParams.get("detail") === "1";
322
- const body = JSON.stringify(await gitStatus(resolved, detail));
678
+ const info = await gitStatus(target.path, detail);
679
+ // Echo the resolved workspace id. SSE events identify the workspace,
680
+ // not the session, so a session-targeted client has no other way to
681
+ // tell whether an event belongs to it — without this the input chip
682
+ // only ever refreshed on remount or the 60s poll.
323
683
  res.writeHead(200, { "content-type": "application/json" });
324
- res.end(body);
684
+ res.end(JSON.stringify({ ...info, workspace: target.id }));
325
685
  } catch (error) {
326
686
  res.writeHead(400, { "content-type": "application/json" });
327
- res.end(JSON.stringify({ git: false, error: String(error?.message ?? error) }));
687
+ res.end(JSON.stringify({ git: false, error: { code: "bad-request", message: String(error?.message ?? error) } }));
328
688
  }
329
689
  }
330
690
  }));
331
- ctx.effect(() => ctx.webServer.register({
332
- kind: "exact",
333
- path: "/api/git-badge/events",
334
- handler: async (req, res) => {
335
- // keep the watcher set aligned with the live registry on every connect
336
- syncWatchers(ctx);
337
- res.writeHead(200, {
338
- "content-type": "text/event-stream",
339
- "cache-control": "no-cache",
340
- connection: "keep-alive"
341
- });
342
- // flush immediately: staged headers only hit the wire on first write,
343
- // and the first real event may be minutes away
344
- res.write(": connected\n\n");
345
- const send = (key) => {
346
- try {
347
- res.write(`data: ${JSON.stringify({ path: key })}\n\n`);
348
- } catch {
349
- /* socket gone; the close handler cleans up */
350
- }
351
- };
352
- changeListeners.add(send);
353
- // comment-only heartbeat keeps proxies from idling the stream out
354
- const heartbeat = setInterval(() => {
355
- try {
356
- res.write(": hb\n\n");
357
- } catch {
358
- /* ignore */
359
- }
360
- }, 25000);
361
- req.on("close", () => {
362
- changeListeners.delete(send);
363
- clearInterval(heartbeat);
364
- });
365
- }
366
- }));
691
+ ctx.effect(() => {
692
+ const dispose = ctx.webServer.register({
693
+ kind: "exact",
694
+ path: "/api/git-badge/events",
695
+ handler: async (req, res) => {
696
+ // keep the watcher set aligned with the live registry on every connect
697
+ syncWatchers(ctx);
698
+ res.writeHead(200, {
699
+ "content-type": "text/event-stream",
700
+ "cache-control": "no-cache",
701
+ connection: "keep-alive",
702
+ // proxies (nginx et al) buffer responses by default, which holds
703
+ // event frames back until the buffer fills
704
+ "x-accel-buffering": "no"
705
+ });
706
+ // flush immediately: staged headers only hit the wire on first write,
707
+ // and the first real event may be minutes away
708
+ res.write(": connected\n\n");
709
+ const stream = {
710
+ res,
711
+ heartbeat: null,
712
+ send: (payload) => {
713
+ try {
714
+ // NAMED frame: the client subscribes with
715
+ // addEventListener("change", …), which never sees an
716
+ // unnamed message
717
+ res.write(`event: change\ndata: ${JSON.stringify(payload)}\n\n`);
718
+ } catch {
719
+ /* socket gone; the close handler cleans up */
720
+ }
721
+ }
722
+ };
723
+ changeListeners.add(stream.send);
724
+ // comment-only heartbeat keeps proxies from idling the stream out
725
+ stream.heartbeat = setInterval(() => {
726
+ try {
727
+ res.write(": hb\n\n");
728
+ } catch {
729
+ /* ignore */
730
+ }
731
+ }, config.heartbeatMs);
732
+ openStreams.add(stream);
733
+ // both fire on a client disconnect; closeStream is idempotent
734
+ req.on("close", () => closeStream(stream));
735
+ res.on("close", () => closeStream(stream));
736
+ }
737
+ });
738
+ // unloading the plugin must not leave subscribers holding dead streams,
739
+ // nor keep process-wide watchers alive
740
+ return () => {
741
+ dispose();
742
+ for (const stream of [...openStreams]) closeStream(stream);
743
+ for (const key of [...watchers.keys()]) unwatchWorkspace(key);
744
+ };
745
+ });
367
746
  }
368
747
 
369
748
  export { apply, inject, name };
749
+
750
+ // ---------- test-only exports ----------
751
+ // Additive named exports; cordis reads apply/inject/name and ignores the rest.
752
+ // The suite in ./test drives these directly instead of booting DSH, which is
753
+ // what lets a node-half change be verified without a restart.
754
+ export {
755
+ config,
756
+ OPERATION_MARKERS,
757
+ operationMarker,
758
+ outerGitDir,
759
+ parseStatusV2,
760
+ runGit,
761
+ gitStatus,
762
+ resolveWorkspace,
763
+ watchWorkspace,
764
+ unwatchWorkspace,
765
+ syncWatchers,
766
+ watchers,
767
+ changeListeners,
768
+ openStreams,
769
+ closeStream
770
+ };