dsh-git-badge 0.5.5 → 0.6.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.
Files changed (3) hide show
  1. package/lib/client.js +52 -9
  2. package/lib/index.js +212 -58
  3. package/package.json +4 -1
package/lib/client.js CHANGED
@@ -2,8 +2,9 @@
2
2
  * dsh-git-badge — client half.
3
3
  *
4
4
  * Surfaces:
5
- * - sidebar.workspaces.row (seam): full row badge — emoji, branch, sync, ✎files
6
- * - conversation.input.left (upstream): compact chip — emoji + branch only
5
+ * - sidebar.workspaces.row (seam): row badge — dot + branch
6
+ * - conversation.input.left (upstream): chip — dot + branch + in-progress
7
+ * operation token + sync/dirty suffix
7
8
  * The hover card is intentionally untouched.
8
9
  */
9
10
  window.__ModuleLoader__.load({
@@ -22,6 +23,19 @@ window.__ModuleLoader__.load({
22
23
  /** Slow safety-net poll: refreshes even if the SSE stream is silently dead. */
23
24
  const FALLBACK_POLL_MS = 60000;
24
25
 
26
+ /**
27
+ * Path equality for SSE notifications. The node half notifies with the
28
+ * registered workspace path, while a surface may hold a variant spelling
29
+ * (a trailing slash). A bare === there would silently kill that row's
30
+ * freshness, so normalise the one difference we can cheaply normalise.
31
+ */
32
+ function samePath(a, b) {
33
+ if (a === b) return true;
34
+ if (typeof a !== "string" || typeof b !== "string") return false;
35
+ const trim = (value) => value.length > 1 ? value.replace(/\/+$/, "") : value;
36
+ return trim(a) === trim(b);
37
+ }
38
+
25
39
  //#region SSE change feed (one EventSource per page, ref-counted)
26
40
  const eventListeners = new Set();
27
41
  let eventSource = null;
@@ -32,7 +46,9 @@ window.__ModuleLoader__.load({
32
46
  eventRefs += 1;
33
47
  if (eventSource === null) {
34
48
  eventSource = new EventSource("/api/git-badge/events");
35
- eventSource.onmessage = (message) => {
49
+ // NAMED frames: the node half writes `event: change`, and onmessage
50
+ // never fires for a named event
51
+ eventSource.addEventListener("change", (message) => {
36
52
  let path;
37
53
  try {
38
54
  path = JSON.parse(message.data).path;
@@ -41,7 +57,7 @@ window.__ModuleLoader__.load({
41
57
  }
42
58
  // invalidate every cache entry for this workspace, then refetch
43
59
  for (const key of [...GIT_CACHE.keys()]) {
44
- if (key === path) GIT_CACHE.delete(key);
60
+ if (samePath(key, path)) GIT_CACHE.delete(key);
45
61
  }
46
62
  for (const fn of [...eventListeners]) {
47
63
  try {
@@ -50,7 +66,7 @@ window.__ModuleLoader__.load({
50
66
  /* one bad subscriber must not starve the rest */
51
67
  }
52
68
  }
53
- };
69
+ });
54
70
  }
55
71
  return () => {
56
72
  eventListeners.delete(onChange);
@@ -98,7 +114,7 @@ window.__ModuleLoader__.load({
98
114
  load();
99
115
  // this workspace's watcher events → refetch
100
116
  const unsubscribe = subscribeGitEvents((path) => {
101
- if (path === cwd) load();
117
+ if (samePath(path, cwd)) load();
102
118
  });
103
119
  const fallback = setInterval(load, FALLBACK_POLL_MS);
104
120
  return () => {
@@ -154,6 +170,29 @@ window.__ModuleLoader__.load({
154
170
  return parts.length > 0 ? " " + parts.join(" ") : "";
155
171
  }
156
172
 
173
+ /**
174
+ * In-progress operation token — INPUT CHIP ONLY (the row badge stays
175
+ * dot + branch). A paused rebase or cherry-pick whose conflicts are all
176
+ * already staged has no unmerged files, so the three-state dot cannot
177
+ * distinguish it from ordinary dirty work; this says which
178
+ * history-rewriting operation git is waiting on.
179
+ */
180
+ const OPERATION_LABELS = {
181
+ merge: "\u2694merge",
182
+ squash: "\u2694squash",
183
+ "cherry-pick": "\u2694cherry-pick",
184
+ revert: "\u2694revert",
185
+ bisect: "\u2694bisect",
186
+ rebase: "\u2694rebase",
187
+ sequencer: "\u2694sequencer"
188
+ };
189
+
190
+ /** ` ⚔rebase` when an operation is paused, else "". */
191
+ function formatOperationToken(info) {
192
+ const label = OPERATION_LABELS[info.operation];
193
+ return label === void 0 ? "" : " " + label;
194
+ }
195
+
157
196
  const META_STYLE = {
158
197
  color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
159
198
  fontSize: "12px",
@@ -172,7 +211,9 @@ window.__ModuleLoader__.load({
172
211
  * appends the muted `| emoji branch` part only for git workspaces.
173
212
  */
174
213
  function WorkspaceGitBadge({ label, cwd }) {
175
- const info = useGitStatus(cwd, false);
214
+ // no detail=1: the row renders dot + branch only, and the extra log /
215
+ // stash calls have no consumer yet
216
+ const info = useGitStatus(cwd);
176
217
  const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
177
218
  if (cwd !== void 0 && info !== void 0 && info.git === true) {
178
219
  children.push(
@@ -220,9 +261,11 @@ window.__ModuleLoader__.load({
220
261
  */
221
262
  function ComposerGitChip({ sessionId }) {
222
263
  const cwd = useSessionWorkspaceCwd(sessionId);
223
- const info = useGitStatus(cwd, true);
264
+ // no detail=1 either: nothing renders lastCommits / stashCount yet, and
265
+ // asking for them would add a log -3 plus a stash list to every refresh
266
+ const info = useGitStatus(cwd);
224
267
  if (cwd === void 0 || info === void 0 || info.git !== true) return null;
225
- const text = badgeDot(info) + " " + info.branch + formatGitSuffix(info);
268
+ const text = badgeDot(info) + " " + info.branch + formatOperationToken(info) + formatGitSuffix(info);
226
269
  return react_jsx_runtime.jsx("span", {
227
270
  style: {
228
271
  display: "inline-flex",
package/lib/index.js CHANGED
@@ -8,17 +8,28 @@
8
8
  * (WorkspaceRegistry records) are ever queried — no filesystem probing
9
9
  * of arbitrary paths.
10
10
  * - 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).
11
+ * --porcelain=v2 --branch --untracked-files=all` yields branch,
12
+ * ahead/behind, and the changed / untracked counts in a single call
13
+ * (`--no-optional-locks` guarantees the read never contends with the
14
+ * user's own git operations on the index). `--untracked-files=all`
15
+ * counts untracked FILES; git's default collapses a new directory into
16
+ * one entry, which under-reported `✎n` for anyone who adds a folder.
17
+ * If that walk blows the budget the sample is retried collapsed and the
18
+ * response reports which mode answered (`untrackedMode`).
19
+ * - one `git rev-parse --show-toplevel --absolute-git-dir` resolves both
20
+ * the repository root (a subdirectory workspace reports the whole repo)
21
+ * and the per-worktree git dir, where the eight in-progress operation
22
+ * markers (`MERGE_HEAD`, `rebase-merge`, …) are stat'd — surfaced as
23
+ * `operation`, so a paused rebase/cherry-pick is distinguishable from
24
+ * ordinary dirty work even when its conflicts are already staged.
15
25
  * - ahead/behind compare against the LOCAL remote-tracking ref, which
16
26
  * only moves on fetch — so a TTL-bounded background fetch (60s per
17
27
  * toplevel) runs before status when the ref can be stale. `GIT_TERMINAL_PROMPT=0`
18
28
  * and the shared timeout keep an offline/slow remote from stalling the
19
29
  * 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.
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.
22
33
  *
23
34
  * GET /api/git-badge/events (text/event-stream)
24
35
  * - pushes `{ path }` dirty notifications the moment a workspace's git
@@ -39,6 +50,35 @@ import { join } from "node:path";
39
50
  const inject = ["webServer", "workspaceRegistry"];
40
51
  const name = "dsh-git-badge";
41
52
 
53
+ /**
54
+ * Runtime tunables. Production code never writes these; the test suite mutates
55
+ * them to collapse the debounce / backoff / heartbeat windows and to force the
56
+ * untracked-walk timeout deterministically instead of waiting on a pathological
57
+ * repository. One exported object rather than scattered consts so tests have a
58
+ * single documented seam.
59
+ */
60
+ const config = {
61
+ /** budget for every git invocation; an expired call is killed */
62
+ gitTimeoutMs: 3000,
63
+ /** network fetch budget; worst-case route latency, paid once per TTL */
64
+ fetchTimeoutMs: 8000,
65
+ /** minimum interval between background fetches per toplevel */
66
+ fetchTtlMs: 60000,
67
+ /** fs-event burst collapse window */
68
+ debounceMs: 200,
69
+ /** backoff before re-attempting a failed watcher (or a missing .git) */
70
+ watchRetryMs: 60000,
71
+ /** SSE comment heartbeat interval */
72
+ heartbeatMs: 25000,
73
+ /**
74
+ * Invoker for the status sample; null means the real runGit. The suite
75
+ * injects one that reports the `-uall` call as timed out, which exercises the
76
+ * collapsed-fallback branch deterministically — a real sub-millisecond budget
77
+ * would just race git's own startup.
78
+ */
79
+ gitRunner: null
80
+ };
81
+
42
82
  /**
43
83
  * Run git in dir. Resolves { stdout } on success; on failure { stdout: null }
44
84
  * plus exactly one of: timeout (execFile timeout kill), missing (no git
@@ -47,7 +87,7 @@ const name = "dsh-git-badge";
47
87
  * from a transient one (degraded).
48
88
  */
49
89
  function runGit(dir, args, opts) {
50
- const { env, timeout = 3000 } = opts ?? {};
90
+ const { env, timeout = config.gitTimeoutMs } = opts ?? {};
51
91
  // env is a full replacement for execFile; overlay onto process.env so
52
92
  // PATH/HOME (credential helpers, global config) survive
53
93
  const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
@@ -70,12 +110,11 @@ const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)"
70
110
  * `git fetch` — without one, a remote update (push from elsewhere, GitHub
71
111
  * edit) is invisible to the badge until the user happens to fetch. A
72
112
  * 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
113
+ * `config.fetchTtlMs` per repository, regardless of how often the route is hit
74
114
  * (watcher bursts included). Mirrors what IDEs do (VS Code's throttled
75
115
  * auto-fetch), with a TTL rather than a timer: fetch only happens when
76
116
  * someone is actually looking at the badge.
77
117
  */
78
- const FETCH_TTL_MS = 60000;
79
118
  /** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
80
119
  const fetchState = new Map();
81
120
 
@@ -91,7 +130,7 @@ async function maybeFetch(toplevel) {
91
130
  const state = fetchState.get(toplevel);
92
131
  if (state !== void 0) {
93
132
  if (state.inFlight !== null) return state.inFlight;
94
- if (now - state.lastAttemptAt < FETCH_TTL_MS) return false;
133
+ if (now - state.lastAttemptAt < config.fetchTtlMs) return false;
95
134
  }
96
135
  const inFlight = (async () => {
97
136
  // --no-tags --prune: refs-only refresh, cheapest correct form.
@@ -102,8 +141,8 @@ async function maybeFetch(toplevel) {
102
141
  env: { GIT_TERMINAL_PROMPT: "0" },
103
142
  // measured real-world fetch is ~3s on SSH; the status-call 3s budget
104
143
  // 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
144
+ // route latency, paid at most once per fetchTtlMs per workspace.
145
+ timeout: config.fetchTimeoutMs
107
146
  });
108
147
  const ok = out.stdout !== null;
109
148
  fetchState.set(toplevel, { lastAttemptAt: Date.now(), inFlight: null });
@@ -151,6 +190,37 @@ function parseStatusV2(out) {
151
190
  return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
152
191
  }
153
192
 
193
+ /**
194
+ * In-progress operation markers, in precedence order. Git drops one of these in
195
+ * the per-worktree git dir while an operation is paused; the first match names
196
+ * the operation. `SQUASH_MSG` is listed in its own right because a squash merge
197
+ * records no `MERGE_HEAD`.
198
+ */
199
+ const OPERATION_MARKERS = [
200
+ ["MERGE_HEAD", "merge"],
201
+ ["SQUASH_MSG", "squash"],
202
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
203
+ ["REVERT_HEAD", "revert"],
204
+ ["BISECT_LOG", "bisect"],
205
+ ["rebase-merge", "rebase"],
206
+ ["rebase-apply", "rebase"],
207
+ ["sequencer", "sequencer"]
208
+ ];
209
+
210
+ /**
211
+ * Name of the git operation currently paused in gitDir, or null. Markers live in
212
+ * the per-worktree git dir (not the shared one), so the caller passes
213
+ * `rev-parse --absolute-git-dir`. Stat'ing the paths beats eight
214
+ * `rev-parse --git-path` invocations — these are plain existence checks.
215
+ */
216
+ function operationMarker(gitDir) {
217
+ if (gitDir === "") return null;
218
+ for (const [marker, operation] of OPERATION_MARKERS) {
219
+ if (existsSync(join(gitDir, marker))) return operation;
220
+ }
221
+ return null;
222
+ }
223
+
154
224
  /**
155
225
  * Git status for dir; { git: false } when dir is not a repository,
156
226
  * GIT_DEGRADED on transient git failure.
@@ -159,18 +229,45 @@ function parseStatusV2(out) {
159
229
  * workspace pointing INSIDE a larger repository reports that repository's
160
230
  * branch, dirty state, and ahead/behind counts (what git itself considers
161
231
  * dirty), not the subdirectory in isolation. Intentional.
232
+ *
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.
162
236
  */
163
237
  async function gitStatus(dir, wantDetail) {
164
- const top = await runGit(dir, ["rev-parse", "--show-toplevel"]);
238
+ // one invocation answers both questions: the repository root (so a
239
+ // subdirectory workspace reports the whole repo) and the per-worktree git
240
+ // dir (where the operation markers above live)
241
+ const top = await runGit(dir, ["rev-parse", "--show-toplevel", "--absolute-git-dir"]);
165
242
  if (top.stdout === null) {
166
243
  // git ran and rejected ("not a repository") is a definitive answer;
167
244
  // timeout / missing binary is transient
168
245
  return top.exitCode !== void 0 ? { git: false } : GIT_DEGRADED;
169
246
  }
170
- const toplevel = top.stdout.trim();
247
+ const topLines = top.stdout.trim().split("\n");
248
+ const toplevel = (topLines[0] ?? "").trim();
171
249
  if (toplevel === "") return { git: false };
172
- const sample = () => runGit(toplevel, ["--no-optional-locks", "status", "--porcelain=v2", "--branch"]);
173
- let statusOut = await sample();
250
+ const gitDir = (topLines[1] ?? "").trim();
251
+ // -uall counts untracked FILES; git's default collapses a new directory into
252
+ // a single entry, which is why `✎n` under-reported anyone who added a folder.
253
+ // Routed through config.gitRunner so the suite can force the timeout branch.
254
+ const sample = (untrackedAll) => (config.gitRunner ?? runGit)(toplevel, [
255
+ "--no-optional-locks",
256
+ "status",
257
+ "--porcelain=v2",
258
+ "--branch",
259
+ untrackedAll ? "--untracked-files=all" : "--untracked-files=normal"
260
+ ]);
261
+ let untrackedMode = "all";
262
+ let statusOut = await sample(true);
263
+ if (statusOut.timeout) {
264
+ // A pathological tree (a huge unignored directory) must not cost the whole
265
+ // badge: retry collapsed and report which answer was served, rather than
266
+ // degrading — or worse, silently reporting a count the client cannot
267
+ // interpret.
268
+ untrackedMode = "collapsed";
269
+ statusOut = await sample(false);
270
+ }
174
271
  if (statusOut.stdout === null) return GIT_DEGRADED;
175
272
  let parsed = parseStatusV2(statusOut.stdout);
176
273
  // The upstream header is the cheap gate: no upstream configured → the
@@ -180,7 +277,7 @@ async function gitStatus(dir, wantDetail) {
180
277
  if (parsed.upstream !== void 0) {
181
278
  const fetched = await maybeFetch(toplevel);
182
279
  if (fetched) {
183
- statusOut = await sample();
280
+ statusOut = await sample(untrackedMode === "all");
184
281
  if (statusOut.stdout === null) return GIT_DEGRADED;
185
282
  parsed = parseStatusV2(statusOut.stdout);
186
283
  }
@@ -196,11 +293,13 @@ async function gitStatus(dir, wantDetail) {
196
293
  unstagedFiles: parsed.unstaged,
197
294
  unmergedFiles: parsed.unmerged,
198
295
  untrackedFiles: parsed.untracked,
296
+ untrackedMode,
297
+ operation: operationMarker(gitDir),
199
298
  ahead: parsed.ahead,
200
299
  behind: parsed.behind
201
300
  };
202
301
  if (wantDetail) {
203
- // last 3 commits (subject + relative age) for the hover card
302
+ // last 3 commits (subject + relative age) for the planned hover card
204
303
  const logOut = await runGit(toplevel, ["log", "-3", "--format=%h%x09%s%x09%cr"]);
205
304
  if (logOut.stdout !== null && logOut.stdout.trim() !== "") {
206
305
  info.lastCommits = logOut.stdout.trim().split("\n").map((line) => {
@@ -227,8 +326,8 @@ async function gitStatus(dir, wantDetail) {
227
326
  */
228
327
  const watchers = new Map();
229
328
  const changeListeners = new Set();
230
- const WATCH_RETRY_MS = 60000;
231
- const DEBOUNCE_MS = 200;
329
+ /** Open SSE responses, so plugin unload can end them instead of leaking them. */
330
+ const openStreams = new Set();
232
331
 
233
332
  function notifyChange(key) {
234
333
  for (const fn of changeListeners) {
@@ -240,6 +339,22 @@ function notifyChange(key) {
240
339
  }
241
340
  }
242
341
 
342
+ /**
343
+ * Tear down one SSE stream. Idempotent: `req` and `res` both fire `close` and
344
+ * the plugin disposer can race them, so membership in openStreams is the single
345
+ * source of truth for "still open".
346
+ */
347
+ function closeStream(stream) {
348
+ if (!openStreams.delete(stream)) return;
349
+ changeListeners.delete(stream.send);
350
+ clearInterval(stream.heartbeat);
351
+ try {
352
+ stream.res.end();
353
+ } catch {
354
+ /* already gone */
355
+ }
356
+ }
357
+
243
358
  function unwatchWorkspace(key) {
244
359
  const record = watchers.get(key);
245
360
  if (record === void 0) return;
@@ -252,7 +367,7 @@ function watchWorkspace(root, key) {
252
367
  const existing = watchers.get(key);
253
368
  if (existing !== void 0) {
254
369
  // live watcher, or a failed attempt still inside its retry backoff
255
- if (existing.watcher !== null || Date.now() - existing.failedAt < WATCH_RETRY_MS) return;
370
+ if (existing.watcher !== null || Date.now() - existing.failedAt < config.watchRetryMs) return;
256
371
  watchers.delete(key);
257
372
  }
258
373
  // only git workspaces need a badge; a missing .git skips the watcher but
@@ -272,7 +387,7 @@ function watchWorkspace(root, key) {
272
387
  const record = watchers.get(key);
273
388
  if (record === void 0) return;
274
389
  clearTimeout(record.timer);
275
- record.timer = setTimeout(() => notifyChange(key), DEBOUNCE_MS);
390
+ record.timer = setTimeout(() => notifyChange(key), config.debounceMs);
276
391
  });
277
392
  watcher.on("error", () => unwatchWorkspace(key));
278
393
  watchers.set(key, { watcher, timer: void 0 });
@@ -328,42 +443,81 @@ function apply(ctx) {
328
443
  }
329
444
  }
330
445
  }));
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
- }));
446
+ ctx.effect(() => {
447
+ const dispose = ctx.webServer.register({
448
+ kind: "exact",
449
+ path: "/api/git-badge/events",
450
+ handler: async (req, res) => {
451
+ // keep the watcher set aligned with the live registry on every connect
452
+ syncWatchers(ctx);
453
+ res.writeHead(200, {
454
+ "content-type": "text/event-stream",
455
+ "cache-control": "no-cache",
456
+ connection: "keep-alive",
457
+ // proxies (nginx et al) buffer responses by default, which holds
458
+ // event frames back until the buffer fills
459
+ "x-accel-buffering": "no"
460
+ });
461
+ // flush immediately: staged headers only hit the wire on first write,
462
+ // and the first real event may be minutes away
463
+ res.write(": connected\n\n");
464
+ const stream = {
465
+ res,
466
+ heartbeat: null,
467
+ send: (key) => {
468
+ try {
469
+ // NAMED frame: the client subscribes with
470
+ // addEventListener("change", …), which never sees an
471
+ // unnamed message
472
+ res.write(`event: change\ndata: ${JSON.stringify({ path: key })}\n\n`);
473
+ } catch {
474
+ /* socket gone; the close handler cleans up */
475
+ }
476
+ }
477
+ };
478
+ changeListeners.add(stream.send);
479
+ // comment-only heartbeat keeps proxies from idling the stream out
480
+ stream.heartbeat = setInterval(() => {
481
+ try {
482
+ res.write(": hb\n\n");
483
+ } catch {
484
+ /* ignore */
485
+ }
486
+ }, config.heartbeatMs);
487
+ openStreams.add(stream);
488
+ // both fire on a client disconnect; closeStream is idempotent
489
+ req.on("close", () => closeStream(stream));
490
+ res.on("close", () => closeStream(stream));
491
+ }
492
+ });
493
+ // unloading the plugin must not leave subscribers holding dead streams,
494
+ // nor keep process-wide watchers alive
495
+ return () => {
496
+ dispose();
497
+ for (const stream of [...openStreams]) closeStream(stream);
498
+ for (const key of [...watchers.keys()]) unwatchWorkspace(key);
499
+ };
500
+ });
367
501
  }
368
502
 
369
503
  export { apply, inject, name };
504
+
505
+ // ---------- test-only exports ----------
506
+ // Additive named exports; cordis reads apply/inject/name and ignores the rest.
507
+ // The suite in ./test drives these directly instead of booting DSH, which is
508
+ // what lets a node-half change be verified without a restart.
509
+ export {
510
+ config,
511
+ OPERATION_MARKERS,
512
+ operationMarker,
513
+ parseStatusV2,
514
+ runGit,
515
+ gitStatus,
516
+ watchWorkspace,
517
+ unwatchWorkspace,
518
+ syncWatchers,
519
+ watchers,
520
+ changeListeners,
521
+ openStreams,
522
+ closeStream
523
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Git status badges for DeepSeek Harness: sidebar workspace rows (via the sidebar.workspaces.row seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -9,6 +9,9 @@
9
9
  "./client": "./lib/client.js",
10
10
  "./package.json": "./package.json"
11
11
  },
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
12
15
  "dsh": {
13
16
  "bundle": {
14
17
  "patch": "./cordis.patch.yml"