dsh-git-badge 0.6.0 β†’ 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/CHANGELOG.md ADDED
@@ -0,0 +1,141 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
+ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ Versions before 0.6.0 predate this file; their history is in `git log` and on
9
+ npm. 0.6.0 is the first release recorded here.
10
+
11
+ ## [Unreleased]
12
+
13
+ ## [0.7.0] - 2026-09-12
14
+
15
+ ### Changed
16
+
17
+ - **Both surfaces draw the same status mark.** The input chip no longer leads with
18
+ a πŸ”΄/🟑/🟒 emoji dot: it renders the SAME 12px SVG mark as the sidebar row β€” a
19
+ filled **circle**, or a **tree** for a linked `git worktree` β€” so the shape says
20
+ *worktree or not* and the fill says *status*, identically wherever the badge
21
+ appears. The `β‘‚` worktree glyph is gone with the emoji: the tree shape already
22
+ says "worktree". The mark is drawn rather than typed because 🌳 is a **colour
23
+ emoji** β€” CSS cannot tint its leaves β€” and the fill comes from the app's own
24
+ `--dsw-alias-state-{success,warn,error}-primary` tokens, so the mark follows
25
+ light/dark and custom themes, which the hardcoded emoji could not. Neither colour
26
+ nor shape is the only channel: the mark carries an accessible name.
27
+ - **The sidebar row shows status, not the branch.** The row keeps the workspace
28
+ name on the left and floats the status mark to the right, with a worktree's
29
+ directory name beside it; the `|` separator is gone. The branch, sync counts and
30
+ operation token stay on the input chip, the surface scoped to the current
31
+ conversation.
32
+ - **The status endpoint no longer accepts a filesystem path.** The caller now says
33
+ *who it is* and the server resolves the workspace itself: `?session=<id>` for the
34
+ input chip, `?workspace=<id>` for a sidebar row. `?path=` is refused with
35
+ `target-required`; unresolvable ids return `session-not-found` or
36
+ `workspace-not-found`. The path was previously caller-chosen (allowlist-bounded
37
+ and read-only, but still chosen by the caller) on a route that answers without
38
+ GUI authentication.
39
+ - A session is resolved through the workspace registry first, so a conversation
40
+ that is no longer live still renders a badge. The live session's directory is
41
+ consulted only for the window before a new session is attached to its workspace,
42
+ and the registry still validates it.
43
+ - The client half no longer requires the `workspaces` service β€” declared services
44
+ are now just `["slots"]` β€” and change notifications carry the workspace id, so
45
+ subscribers match on identity rather than on a path.
46
+
47
+ ### Added
48
+
49
+ - **A client-half test suite, plus submodule coverage.** `test/client.test.mjs`
50
+ loads the real `lib/client.js` behind a stub module loader and renders both
51
+ registered surfaces, so the mark's shape/colour rules, the row's "never show the
52
+ branch" rule, the right-float and the worktree-name suppression are asserted in
53
+ CI rather than eyeballed. The node half gains a real-submodule fixture: a
54
+ submodule is asserted **not** to be a worktree (its gitfile alone must not decide
55
+ it β€” worktree-ness is the `commondir` marker) while its out-of-tree git dir is
56
+ still watched, like a linked worktree's.
57
+ - **The input chip now says which working tree a conversation is in.** A linked
58
+ `git worktree`'s directory name is appended after the branch β€”
59
+ `🌳 main hotfix-tree ↑0 ↓2 ✎3` β€” because several worktrees of one repository
60
+ otherwise render identical chips and the chip carries no other workspace
61
+ identity. The name is dropped when the branch already implies it (`repo-feat-x`
62
+ on `feat-x` shows just the tree), a main checkout appends nothing, and the
63
+ response reports `isWorktree` / `worktreeName` from one stat of the git dir the
64
+ existing `rev-parse` already returned β€” no extra git invocation. Naming the
65
+ worktree is chip-only, like the operation token. The value is a directory NAME,
66
+ never a path.
67
+ - **A server-side poll fallback for workspaces whose file watcher failed.** A
68
+ workspace whose recursive `fs.watch` could not be established, or which later
69
+ errored, is polled every 5s on a state key (status + refs fingerprint +
70
+ in-progress operation) instead of depending only on the client's 60s fallback.
71
+ - `seam/apply.sh status` β€” reports patched / pristine / upstream-landed / drifted
72
+ by hash, exit 1 on drift with the exact rebuild commands.
73
+
74
+ ### Fixed
75
+
76
+ - **The input chip now refreshes on change events.** Change events identify the
77
+ *workspace*, and the client matched them against its own target id. A sidebar row
78
+ targets by workspace id so rows kept working, but the chip targets by session id,
79
+ which can never equal a workspace id β€” so its handler never fired and the chip
80
+ only updated when it remounted (switching conversations) or on the 60s fallback
81
+ poll. The status response now echoes the resolved workspace id and the client
82
+ matches on that. This was a regression in the server-side resolution change.
83
+ - **A linked worktree's badge only refreshed on a file edit.** The watcher watched
84
+ the workspace root, but a linked worktree's git dir lives OUTSIDE it
85
+ (`<main>/.git/worktrees/<name>`), so `git add`, `git commit` and `git checkout`
86
+ there wrote nothing the root watch could see: the badge stayed stale until a
87
+ worktree file changed or the client's 60s poll fired. The per-worktree git dir
88
+ now gets its own watcher, resolved from the `.git` gitfile (which covers
89
+ submodules too), and either watcher erroring drops both and hands over to the
90
+ degraded poll. A regression test that stages a file written before the watch
91
+ existed fails without the second watcher.
92
+ - **The TTL fetch no longer delays the status response.** `ahead`/`behind` are
93
+ read from the local remote-tracking ref, and the fetch that refreshes it was
94
+ awaited before answering, so any request arriving after the 60s TTL had lapsed
95
+ paid a full network fetch first (~3.3s measured) β€” which is the first request
96
+ after every TTL window, i.e. constantly while editing. Badge updates triggered
97
+ by an edit took seconds for a repo with a remote. The response is now served
98
+ from the refs at hand and the fetch runs out of band; when it succeeds it
99
+ notifies subscribers, which reconverge in ~40ms. Same call, expired TTL: 27ms.
100
+ The trade is that `ahead`/`behind` can lag by up to one fetch β€” branch, dirty
101
+ state and file counts are never delayed.
102
+ - The plugin's `surfaces:` console line reported the boot-time race instead of the
103
+ outcome: it printed `sidebar rows = off (seam absent)` while row badges were
104
+ visibly rendering, because it sampled the seam declaration before the workspace
105
+ browser published it. It now reports from the registration callback, and the
106
+ boot-time line says it is awaiting the seam rather than declaring it missing.
107
+
108
+ ## [0.6.0] - 2026-09-12
109
+
110
+ ### Fixed
111
+
112
+ - **`✎n` under-counted new files.** Git collapses a new directory into a single
113
+ status entry, so adding a folder of 3 files displayed `✎1`. Untracked files are
114
+ now counted individually. If walking a very large untracked tree exceeds the time
115
+ budget, the badge falls back to the collapsed count and reports which mode
116
+ answered (`untrackedMode`) instead of silently showing a wrong number.
117
+
118
+ ### Added
119
+
120
+ - **An in-progress operation token.** A paused merge, rebase, cherry-pick, revert,
121
+ bisect or sequencer run now appears after the branch name β€”
122
+ `🟑 main βš”rebase ↑0 ↓2 ✎3`. A rebase whose conflicts are already staged has no
123
+ unmerged files, so the three-state dot could not express it. The dot contract
124
+ itself is unchanged, and the token is chip-only.
125
+ - **A 41-test node-half suite** (`npm test`) covering the status parser, git
126
+ plumbing against real repositories, request routing, SSE framing and the
127
+ filesystem watcher β€” runnable without booting DSH.
128
+ - **A CI test workflow** running the suite on Node 20/22/24.
129
+
130
+ ### Changed
131
+
132
+ - Publishing runs through **npm trusted publishing** (OIDC) on a published
133
+ release, gated on the suite, with SLSA provenance β€” no long-lived token.
134
+ - Event stream hardening: `x-accel-buffering: no`, named `change` frames, cleanup
135
+ on disconnect, and an unload disposer that also releases the filesystem watchers.
136
+ - `detail=1` is annotated as having no consumer yet (the hover card remains
137
+ pending). Its comment previously claimed `log -1` while the code ran `log -3`.
138
+
139
+ [Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.7.0...HEAD
140
+ [0.7.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.7.0
141
+ [0.6.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.6.0
package/README.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
4
4
 
5
- - **Input-row chip** β€” `🟒 main` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
6
- - **Sidebar row badges** β€” full Claude-Code-statusline-style badges (`| 🟑 main ↑0 ↓2 ✎3`) on workspace rows. Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
5
+ - **Input-row chip** β€” `● main ↑0 ↓2 ✎3` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
6
+ - **Sidebar row badges** β€” the same status mark floated to the right of each workspace row (`Project 1 ●`), with a linked worktree's name beside it (`Project 2 hotfix-tree 🌳`). Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
7
7
 
8
- Event-driven freshness: working trees are watched and updates push over SSE β€” badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample; the API only answers registered workspace paths.
8
+ The leading **mark** is identical on both surfaces: a circle filled with the status colour, or a **tree** when the workspace is a linked `git worktree`. (`●`/`🌳` stand in for it here β€” it is a drawn SVG so that it can take the theme's status colours.)
9
+
10
+ Event-driven freshness: working trees are watched and updates push over SSE β€” badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample. The status API takes **no path**: the caller passes a session id (chip) or workspace id (row) and the server resolves the directory itself.
9
11
 
10
12
  ## Install
11
13
 
@@ -17,6 +19,10 @@ Restart the web process, refresh the browser. Requires Node β‰₯ 20 and `git` on
17
19
 
18
20
  Seam details: [github.com/Kevin-McIsaac/dsh-workspace-git-badge](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) (SEAM.md / PR.md).
19
21
 
22
+ ## Changelog
23
+
24
+ See [CHANGELOG.md](CHANGELOG.md).
25
+
20
26
  ## License
21
27
 
22
28
  MIT
package/lib/client.js CHANGED
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * dsh-git-badge β€” client half.
3
3
  *
4
+ * Both surfaces show the SAME status mark β€” a circle, or a tree for a linked
5
+ * worktree, filled with the status colour β€” so status and worktree-ness read
6
+ * identically wherever they appear.
7
+ *
4
8
  * Surfaces:
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
9
+ * - sidebar.workspaces.row (seam): row badge β€” workspace name, the status mark
10
+ * floated right, and a worktree's name beside it. Deliberately shows NO
11
+ * branch: the chip is the surface that names it.
12
+ * - conversation.input.left (upstream): chip β€” status mark + branch + a
13
+ * worktree's name + in-progress operation token + sync/dirty suffix
8
14
  * The hover card is intentionally untouched.
9
15
  */
10
16
  window.__ModuleLoader__.load({
@@ -24,16 +30,16 @@ window.__ModuleLoader__.load({
24
30
  const FALLBACK_POLL_MS = 60000;
25
31
 
26
32
  /**
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.
33
+ * A request target names WHO is asking, never a filesystem path β€” the node
34
+ * half resolves the directory itself, so a surface never needs to know
35
+ * one. The chip targets its conversation (`session`); a sidebar row
36
+ * targets the workspace id the seam hands it. The query string doubles as
37
+ * the cache key.
31
38
  */
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);
39
+ function targetQuery(target) {
40
+ if (target === void 0 || target.id === void 0) return void 0;
41
+ const param = target.kind === "workspace" ? "workspace" : "session";
42
+ return param + "=" + encodeURIComponent(target.id);
37
43
  }
38
44
 
39
45
  //#region SSE change feed (one EventSource per page, ref-counted)
@@ -49,19 +55,19 @@ window.__ModuleLoader__.load({
49
55
  // NAMED frames: the node half writes `event: change`, and onmessage
50
56
  // never fires for a named event
51
57
  eventSource.addEventListener("change", (message) => {
52
- let path;
58
+ let payload;
53
59
  try {
54
- path = JSON.parse(message.data).path;
60
+ payload = JSON.parse(message.data);
55
61
  } catch {
56
62
  return;
57
63
  }
58
- // invalidate every cache entry for this workspace, then refetch
59
- for (const key of [...GIT_CACHE.keys()]) {
60
- if (samePath(key, path)) GIT_CACHE.delete(key);
61
- }
64
+ // The server names the workspace it watched, so a subscriber that
65
+ // asked by id matches without ever knowing a path. Each
66
+ // subscriber refetches itself, and the per-key in-flight map
67
+ // collapses the resulting burst into one request.
62
68
  for (const fn of [...eventListeners]) {
63
69
  try {
64
- fn(path);
70
+ fn(payload);
65
71
  } catch {
66
72
  /* one bad subscriber must not starve the rest */
67
73
  }
@@ -81,76 +87,122 @@ window.__ModuleLoader__.load({
81
87
 
82
88
  /**
83
89
  * Shared git-status hook. Fetches once on mount and then only when the
84
- * node half's watcher reports a change for this workspace (SSE), plus a
85
- * slow safety-net poll in case the stream dies silently. Returns
86
- * undefined while loading and for non-git paths.
90
+ * node half's watcher reports a change for this target (SSE), plus a slow
91
+ * safety-net poll in case the stream dies silently. Returns undefined
92
+ * while loading and for non-git workspaces.
87
93
  */
88
- function useGitStatus(cwd) {
89
- const cacheKey = cwd;
94
+ function useGitStatus(target) {
95
+ const cacheKey = targetQuery(target);
90
96
  const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
91
- const [info, setInfo] = react.useState(hit !== void 0 ? hit.data : void 0);
97
+ // state carries the key it belongs to: switching conversations changes
98
+ // the key, and showing the previous session's badge until the new fetch
99
+ // lands would be wrong
100
+ const [state, setState] = react.useState({ key: cacheKey, data: hit === void 0 ? void 0 : hit.data });
92
101
  react.useEffect(() => {
93
- if (cacheKey === void 0) return;
94
- let alive = true;
95
- const apply = (data) => {
96
- // A degraded response (git timeout/failure) must never clobber a
97
- // good cached badge β€” keep the last-known state until a real
98
- // event or the fallback poll succeeds.
99
- if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
100
- GIT_CACHE.set(cacheKey, { at: Date.now(), data });
101
- if (alive) setInfo(data);
102
- };
103
- const load = () => {
104
- // dedupe: an in-flight fetch for this key serves all callers
105
- let pending = GIT_INFLIGHT.get(cacheKey);
106
- if (pending === void 0) {
107
- pending = fetch("/api/git-badge?path=" + encodeURIComponent(cwd))
108
- .then((r) => r.json())
109
- .finally(() => GIT_INFLIGHT.delete(cacheKey));
110
- GIT_INFLIGHT.set(cacheKey, pending);
102
+ if (cacheKey === void 0) return;
103
+ let alive = true;
104
+ // The workspace this target resolved to, taken from the response (the
105
+ // server echoes it). THIS is what events are matched against: an event
106
+ // carries a workspace id, and a session-targeted chip has only a
107
+ // session id, so comparing the two directly never matches.
108
+ let resolvedWorkspace;
109
+ // Re-seed from cache when the key changes (switching conversations):
110
+ // render the last-known badge immediately instead of blanking until the
111
+ // fetch lands, and take the workspace id from it so events still match.
112
+ const cached = GIT_CACHE.get(cacheKey);
113
+ if (cached !== void 0) {
114
+ if (typeof cached.data?.workspace === "string") resolvedWorkspace = cached.data.workspace;
115
+ setState({ key: cacheKey, data: cached.data });
111
116
  }
112
- pending.then(apply).catch(() => {});
113
- };
114
- load();
115
- // this workspace's watcher events β†’ refetch
116
- const unsubscribe = subscribeGitEvents((path) => {
117
- if (samePath(path, cwd)) load();
118
- });
119
- const fallback = setInterval(load, FALLBACK_POLL_MS);
120
- return () => {
121
- alive = false;
122
- unsubscribe();
123
- clearInterval(fallback);
124
- };
125
- }, [cacheKey]);
126
- return info;
117
+ const apply = (data) => {
118
+ // A degraded response (git timeout/failure) must never clobber a
119
+ // good cached badge β€” keep the last-known state until a real
120
+ // event or the fallback poll succeeds.
121
+ if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
122
+ if (data !== null && typeof data.workspace === "string") resolvedWorkspace = data.workspace;
123
+ GIT_CACHE.set(cacheKey, { at: Date.now(), data });
124
+ if (alive) setState({ key: cacheKey, data });
125
+ };
126
+ const load = () => {
127
+ // dedupe: an in-flight fetch for this key serves all callers
128
+ let pending = GIT_INFLIGHT.get(cacheKey);
129
+ if (pending === void 0) {
130
+ pending = fetch("/api/git-badge?" + cacheKey)
131
+ .then((r) => r.json())
132
+ .finally(() => GIT_INFLIGHT.delete(cacheKey));
133
+ GIT_INFLIGHT.set(cacheKey, pending);
134
+ }
135
+ pending.then(apply).catch(() => {});
136
+ };
137
+ load();
138
+ const unsubscribe = subscribeGitEvents((payload) => {
139
+ // An event with no workspace id (no watcher record for that path)
140
+ // cannot be attributed, and neither can one arriving before we have
141
+ // resolved our own target β€” refetch rather than guess, since a
142
+ // missed refresh is the bug this guards and an extra request is cheap
143
+ // (the in-flight map collapses bursts).
144
+ if (payload.workspace === void 0 || resolvedWorkspace === void 0 || payload.workspace === resolvedWorkspace) load();
145
+ });
146
+ const fallback = setInterval(load, FALLBACK_POLL_MS);
147
+ return () => {
148
+ alive = false;
149
+ unsubscribe();
150
+ clearInterval(fallback);
151
+ };
152
+ }, [cacheKey]);
153
+ return state.key === cacheKey ? state.data : void 0;
127
154
  }
128
155
 
129
156
  /**
130
- * Three-state dot color, shared by the sidebar row badge and the
131
- * composer chip. Checked top-down, first match wins:
132
- * - RED: unmerged files (mid-conflict, git is blocked) β€” or a dirty
157
+ * Three-state status, checked top-down, first match wins:
158
+ * - "error": unmerged files (mid-conflict, git is blocked) β€” or a dirty
133
159
  * tree that is ALSO behind upstream (unsaved edits on an outdated
134
160
  * base: commit-then-pull friction ahead).
135
- * - YELLOW: dirty files, or any ahead/behind (routine work or sync
161
+ * - "warn": dirty files, or any ahead/behind (routine work or sync
136
162
  * pending β€” including the previously "quiet" green-but-↓1 case).
137
- * - GREEN: clean and in sync.
163
+ * - "ok": clean and in sync.
138
164
  * Red never means merely "behind": a clean tree one commit behind is
139
165
  * normal between pulls, not an alarm.
166
+ *
167
+ * The single source of truth for that summary. BOTH surfaces render it the
168
+ * same way (see StatusMark): a circle, or a tree in a linked worktree,
169
+ * filled with the colour below.
140
170
  */
141
- function badgeDot(info) {
171
+ function badgeStatus(info) {
142
172
  const dirty = info.dirty === true;
143
173
  const behind = (info.behind || 0) > 0;
144
174
  const ahead = (info.ahead || 0) > 0;
145
175
  const conflict = (info.unmergedFiles || 0) > 0;
146
- if (conflict || (dirty && behind)) return "\uD83D\uDD34";
147
- if (dirty || ahead || behind) return "\uD83D\uDFE1";
148
- return "\uD83D\uDFE2";
176
+ if (conflict || (dirty && behind)) return "error";
177
+ if (dirty || ahead || behind) return "warn";
178
+ return "ok";
149
179
  }
150
180
 
151
181
  /**
152
- * Sync/dirty suffix β€” INPUT CHIP ONLY. The sidebar row badge shows
153
- * status + branch alone; the chip is the detailed surface. Rule set:
182
+ * Mark fill per status, from the app's own semantic tokens so BOTH surfaces
183
+ * follow light/dark and custom themes β€” which a hardcoded emoji cannot do.
184
+ * The literals are only fallbacks for use outside DSH.
185
+ */
186
+ const MARK_FILL = {
187
+ error: "var(--dsw-alias-state-error-primary, #e5484d)",
188
+ warn: "var(--dsw-alias-state-warn-primary, #d29922)",
189
+ ok: "var(--dsw-alias-state-success-primary, #30a46c)"
190
+ };
191
+
192
+ /**
193
+ * Accessible name per status, WITHOUT the "git" prefix β€” the mark adds
194
+ * "git: " or "git worktree: ", so shape and colour are never the only
195
+ * channel for either fact.
196
+ */
197
+ const STATUS_LABEL = {
198
+ error: "conflict, or uncommitted changes on an outdated base",
199
+ warn: "uncommitted changes, or out of sync with upstream",
200
+ ok: "clean and in sync"
201
+ };
202
+
203
+ /**
204
+ * Sync/dirty suffix β€” INPUT CHIP ONLY. The sidebar row shows status alone;
205
+ * the chip is the surface that carries the numbers. Rule set:
154
206
  * - dirty files render as ✎n (pencil = worktree files, distinct from
155
207
  * the ↑/↓ commit-sync axis)
156
208
  * - while dirty, BOTH sync counts render including zeros (↑0 ↓2 ✎3),
@@ -171,9 +223,9 @@ window.__ModuleLoader__.load({
171
223
  }
172
224
 
173
225
  /**
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
226
+ * In-progress operation token β€” INPUT CHIP ONLY (the row shows the status
227
+ * tree alone). A paused rebase or cherry-pick whose conflicts are all
228
+ * already staged has no unmerged files, so the three-state summary cannot
177
229
  * distinguish it from ordinary dirty work; this says which
178
230
  * history-rewriting operation git is waiting on.
179
231
  */
@@ -193,6 +245,45 @@ window.__ModuleLoader__.load({
193
245
  return label === void 0 ? "" : " " + label;
194
246
  }
195
247
 
248
+ /**
249
+ * Linked-worktree NAME token β€” INPUT CHIP ONLY, and only the name: worktree-
250
+ * ness itself is carried by the status mark's shape (a tree instead of a
251
+ * circle) on both surfaces. The chip has no other workspace identity, so
252
+ * several worktrees of one repository would otherwise render identical chips
253
+ * with no way to tell which checkout a conversation is in. The node half
254
+ * reports whether this is a linked worktree plus its directory NAME (never a
255
+ * path).
256
+ *
257
+ * The name is appended only when it says something the branch does not: the
258
+ * usual `repo-feat-x` directory sitting on branch `feat-x` would otherwise
259
+ * read as stutter. (The ROW needs no such rule β€” it shows the name instead
260
+ * of the branch, so there is nothing to stutter against.)
261
+ */
262
+
263
+ /**
264
+ * True when the worktree name is already implied by the branch name, so
265
+ * showing both is pure repetition. Compared on a normalized form β€”
266
+ * case-folded, with `/` and `_` folded to `-` β€” because directory and
267
+ * branch conventions differ without changing the meaning (`feat/x` in a
268
+ * `repo-feat-x` directory).
269
+ */
270
+ function worktreeNameIsRedundant(name, branch) {
271
+ if (typeof name !== "string" || name === "" || typeof branch !== "string") return true;
272
+ const normalize = (value) => value.toLowerCase().replace(/[/_]/g, "-");
273
+ const normalizedName = normalize(name);
274
+ const normalizedBranch = normalize(branch);
275
+ return normalizedName === normalizedBranch
276
+ || normalizedName.endsWith("-" + normalizedBranch)
277
+ || normalizedBranch.endsWith("-" + normalizedName);
278
+ }
279
+
280
+ /** ` name` for a worktree whose name the branch does not already say, else "". */
281
+ function formatWorktreeToken(info) {
282
+ if (info.isWorktree !== true) return "";
283
+ const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
284
+ return worktreeNameIsRedundant(name, info.branch) ? "" : " " + name;
285
+ }
286
+
196
287
  const META_STYLE = {
197
288
  color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
198
289
  fontSize: "12px",
@@ -202,70 +293,122 @@ window.__ModuleLoader__.load({
202
293
  };
203
294
 
204
295
  /**
205
- * Row badge β€” three-state dot + branch (sync/dirty detail lives on
206
- * the input chip):
207
- * the_paragliding_app | 🟒 main
208
- * Dot color via badgeDot(): green clean+synced, yellow dirty or
209
- * out-of-sync, red conflict or dirty-and-behind.
210
- * Always renders the workspace name (so the row keeps its identity);
211
- * appends the muted `| emoji branch` part only for git workspaces.
296
+ * Status mark, SHARED BY BOTH SURFACES: the same 12px shape in the same fill
297
+ * wherever it appears, so the sidebar row and the input chip read
298
+ * identically. The FILL is the status colour; the SHAPE carries
299
+ * worktree-ness β€” a circle for a main checkout, a tree for a linked
300
+ * worktree β€” so the two facts never compete for the same channel.
301
+ *
302
+ * Drawn rather than typed because 🌳 is a COLOUR EMOJI: CSS cannot tint
303
+ * its leaves, and a mark that carries the status has to be paintable.
304
+ * Three overlapping crown lobes plus a trunk stay legible at this size.
305
+ *
306
+ * Neither colour nor shape is the only channel: the accessible name
307
+ * states both.
212
308
  */
213
- function WorkspaceGitBadge({ label, cwd }) {
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);
217
- const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
218
- if (cwd !== void 0 && info !== void 0 && info.git === true) {
219
- children.push(
220
- react_jsx_runtime.jsx("span", { style: { ...META_STYLE, margin: "0 7px" }, children: "|" }),
221
- react_jsx_runtime.jsx("span", { style: { ...META_STYLE, marginRight: "4px" }, children: badgeDot(info) }),
222
- react_jsx_runtime.jsx("span", { style: META_STYLE, children: info.branch })
223
- );
309
+ function StatusMark({ info }) {
310
+ const status = badgeStatus(info);
311
+ const fill = MARK_FILL[status];
312
+ const isWorktree = info.isWorktree === true;
313
+ const svg = {
314
+ // 14px, NOT 12px: a colour emoji draws well above its nominal size β€” the
315
+ // πŸ”΄/🟑/🟒 dot this replaces put down ~13-14px of ink at the surfaces'
316
+ // 12px font-size, so a 12px box read as a shrunken dot.
317
+ width: 14,
318
+ height: 14,
319
+ viewBox: "0 0 12 12",
320
+ role: "img",
321
+ "aria-label": (isWorktree ? "git worktree: " : "git: ") + STATUS_LABEL[status],
322
+ focusable: "false",
323
+ style: { flex: "none", display: "block", color: META_STYLE.color }
324
+ };
325
+ if (!isWorktree) {
326
+ // a plain filled disc: this is a main checkout. It fills its box so the
327
+ // ink lands at the size the emoji dot used to.
328
+ return react_jsx_runtime.jsx("svg", { ...svg, children: react_jsx_runtime.jsx("circle", { cx: 6, cy: 6, r: 5.5, fill }) });
224
329
  }
225
- return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0 }, children });
330
+ return react_jsx_runtime.jsxs("svg", {
331
+ ...svg,
332
+ children: [
333
+ // trunk first, so the crown's lobes overlap and join it. These are the
334
+ // 12px-era coordinates scaled 1.15 about the tree's centre, so its ink
335
+ // stays comparable to the disc's instead of reading small beside it.
336
+ react_jsx_runtime.jsx("rect", { key: "trunk", x: 5.25, y: 7.3, width: 1.5, height: 4.14, rx: 0.63, fill: "currentColor" }),
337
+ react_jsx_runtime.jsx("circle", { key: "crown-1", cx: 6, cy: 3.62, r: 3.57, fill }),
338
+ react_jsx_runtime.jsx("circle", { key: "crown-2", cx: 3.24, cy: 5.46, r: 2.53, fill }),
339
+ react_jsx_runtime.jsx("circle", { key: "crown-3", cx: 8.76, cy: 5.46, r: 2.53, fill })
340
+ ]
341
+ });
226
342
  }
227
343
 
228
- //#region composer chip (fallback + companion surface: conversation.composer.dock)
229
- /** Client root ctx, captured at apply() for the chip's service lookups. */
230
- let clientCtx = null;
344
+ /**
345
+ * A linked worktree's directory name, or null when there is nothing to
346
+ * show. A main checkout shows NO text: the row surveys status and identity,
347
+ * and the branch lives on the input chip β€” which is why the branch is never
348
+ * rendered here. The name is the one thing the row's own label (the
349
+ * worktree directory by default) cannot be trusted to say once a workspace
350
+ * has been renamed.
351
+ */
352
+ function rowName(info) {
353
+ if (info.isWorktree !== true) return null;
354
+ const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
355
+ return name === "" ? null : name;
356
+ }
231
357
 
232
358
  /**
233
- * Resolve the workspace cwd attached to a session: projects the
234
- * workspaces service's snapshot store (items carry path + sessionIds)
235
- * and re-resolves on change. Returns undefined while unknown.
359
+ * Row badge β€” workspace name on the left, the status mark floated right:
360
+ * the_paragliding_app ●
361
+ * worktree_thing hotfix-tree 🌳
362
+ * Fill colour via badgeStatus(): green clean+synced, amber dirty or
363
+ * out-of-sync, red conflict or dirty-and-behind. Shape via the node half's
364
+ * isWorktree: circle = main checkout, tree = linked worktree.
365
+ * Always renders the workspace name (so the row keeps its identity) and
366
+ * appends the right-hand mark only for git workspaces. No branch, and no
367
+ * `|` separator.
368
+ *
369
+ * Targets the row's `workspaceId`. The seam also passes `cwd`, which is
370
+ * deliberately ignored: the node half resolves the directory itself, so
371
+ * the client never has to name one. A row with no workspaceId (the
372
+ * ungrouped bucket) has no workspace to report on, so it renders
373
+ * name-only.
236
374
  */
237
- function useSessionWorkspaceCwd(sessionId) {
238
- const workspaces = clientCtx === null ? null : clientCtx.get("workspaces");
239
- const resolve = () => {
240
- if (sessionId === void 0 || workspaces === void 0) return void 0;
241
- const item = workspaces.list.getSnapshot().items.find((w) => w.sessionIds !== void 0 && w.sessionIds.includes(sessionId));
242
- return item === void 0 ? void 0 : item.path;
243
- };
244
- const [cwd, setCwd] = react.useState(resolve);
245
- react.useEffect(() => {
246
- if (sessionId === void 0 || workspaces === void 0) return;
247
- setCwd(resolve());
248
- return workspaces.list.subscribe(() => {
249
- setCwd(resolve());
250
- });
251
- }, [sessionId, workspaces]);
252
- return cwd;
375
+ function WorkspaceGitBadge({ label, workspaceId }) {
376
+ // no detail=1: the row needs status and identity only, and the extra
377
+ // log / stash calls have no consumer yet
378
+ const info = useGitStatus(workspaceId === void 0 ? void 0 : { kind: "workspace", id: workspaceId });
379
+ const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
380
+ if (workspaceId !== void 0 && info !== void 0 && info.git === true) {
381
+ // marginLeft:auto floats the mark to the right edge, so the status
382
+ // shape holds a fixed right-hand column and a worktree's name grows
383
+ // leftwards from it instead of shoving the shape around.
384
+ const meta = [];
385
+ const name = rowName(info);
386
+ if (name !== null) meta.push(react_jsx_runtime.jsx("span", { key: "name", style: META_STYLE, children: name }));
387
+ meta.push(react_jsx_runtime.jsx(StatusMark, { key: "mark", info }));
388
+ children.push(react_jsx_runtime.jsx("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flex: "none", marginLeft: "auto", paddingLeft: "8px" }, children: meta }));
389
+ }
390
+ return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0, width: "100%" }, children });
253
391
  }
254
392
 
393
+ //#region composer chip (upstream additive surface: conversation.input.left)
255
394
  /**
256
- * Chip line docked at the composer: git state of the workspace the
257
- * CURRENT conversation is attached to. Works on unpatched installs
258
- * (conversation.composer.dock is an upstream additive slot), and stays
259
- * useful next to the sidebar rows on patched/upstream-seam installs
260
- * because it is context-anchored ("where am I") rather than surveying.
395
+ * Chip line in the input row: git state of the workspace the CURRENT
396
+ * conversation is attached to. It targets the session id and lets the node
397
+ * half resolve the workspace, so this surface needs no `workspaces`
398
+ * service, no cwd and no path plumbing at all. Works on unpatched installs
399
+ * (conversation.input.left is an upstream additive slot), and stays useful
400
+ * next to the sidebar rows because it is context-anchored ("where am I")
401
+ * rather than surveying.
261
402
  */
262
403
  function ComposerGitChip({ sessionId }) {
263
- const cwd = useSessionWorkspaceCwd(sessionId);
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);
267
- if (cwd === void 0 || info === void 0 || info.git !== true) return null;
268
- const text = badgeDot(info) + " " + info.branch + formatOperationToken(info) + formatGitSuffix(info);
404
+ // no detail=1: nothing renders lastCommits / stashCount yet, and asking
405
+ // for them would add a log -3 plus a stash list to every refresh
406
+ const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId });
407
+ if (info === void 0 || info.git !== true) return null;
408
+ // the mark is an element now rather than a leading glyph in the string, so
409
+ // the SAME StatusMark the sidebar row draws carries the status here too;
410
+ // the container's 4px gap supplies the space the emoji's own did
411
+ const text = info.branch + formatWorktreeToken(info) + formatOperationToken(info) + formatGitSuffix(info);
269
412
  return react_jsx_runtime.jsx("span", {
270
413
  style: {
271
414
  display: "inline-flex",
@@ -278,28 +421,46 @@ window.__ModuleLoader__.load({
278
421
  whiteSpace: "nowrap",
279
422
  cursor: "default"
280
423
  },
281
- children: text
424
+ children: [
425
+ react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
426
+ react_jsx_runtime.jsx("span", { key: "text", children: text })
427
+ ]
282
428
  });
283
429
  }
284
430
  //#endregion
285
431
 
286
- const inject = ["slots", "workspaces"];
432
+ // `workspaces` is no longer required: both surfaces target an id and the
433
+ // node half resolves the workspace, so the client never needs a service
434
+ // lookup. Fewer declared services also means fewer ways to fail to load.
435
+ const inject = ["slots"];
287
436
 
288
437
  /**
289
438
  * Register the badge into both seams. The seam owner hands each entry the
290
- * row owner share as props; the badge destructures { workspaceId, cwd,
291
- * label } from it.
439
+ * row owner share as props; the badge destructures { workspaceId, label }
440
+ * and deliberately ignores the cwd it is also given.
292
441
  */
293
442
  function apply(ctx) {
294
- clientCtx = ctx;
295
443
  // inject() re-evaluates when a seam's declaration appears, so boot
296
444
  // order relative to the workspace browser does not matter. On an
297
- // unpatched install the row seams never get declared, so those two
298
- // registrations simply never render anything.
299
- ctx.slots.inject("sidebar.workspaces.row", () => ctx.slots.register({
300
- name: "sidebar.workspaces.row",
301
- id: "git-badge"
302
- }, WorkspaceGitBadge));
445
+ // unpatched install the row seam is never declared, so this callback
446
+ // never fires and the row badge simply never renders.
447
+ //
448
+ // The registration is reported from INSIDE the callback on purpose: a
449
+ // `spec()` check here at apply() time runs before the workspace browser
450
+ // declares the seam, so it reports the boot race rather than the
451
+ // outcome. That mistake made the old one-shot line claim "sidebar rows
452
+ // = off" while five row badges were rendering.
453
+ let rowsReported = false;
454
+ ctx.slots.inject("sidebar.workspaces.row", () => {
455
+ if (!rowsReported) {
456
+ rowsReported = true;
457
+ console.info("[dsh-git-badge] surfaces: sidebar rows = on (seam present).");
458
+ }
459
+ return ctx.slots.register({
460
+ name: "sidebar.workspaces.row",
461
+ id: "git-badge"
462
+ }, WorkspaceGitBadge);
463
+ });
303
464
  // Input-row chip: upstream additive slot rendered in the input bar's
304
465
  // leading cluster, right after the access picker β€” the git state sits
305
466
  // with the controls that govern the conversation. Present on every
@@ -312,7 +473,7 @@ window.__ModuleLoader__.load({
312
473
  inject: (sessionId) => ({ sessionId })
313
474
  }, ComposerGitChip));
314
475
  const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
315
- console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)" : "off (seam absent β€” sidebar badges need the sidebar.workspaces.row seam)") + ".");
476
+ console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)." : "awaiting the sidebar.workspaces.row seam β€” the line above reports it if it appears."));
316
477
  }
317
478
 
318
479
  exports.apply = apply;
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,11 +28,22 @@
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.
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.
30
47
  * - `detail=1` adds one `log -3` plus a stash count. NOTE: no surface
31
48
  * consumes this yet (see the annotation on gitStatus) β€” it is kept for
32
49
  * the planned hover card, and never paid for by row refresh.
@@ -34,18 +51,19 @@
34
51
  * GET /api/git-badge/events (text/event-stream)
35
52
  * - pushes `{ path }` dirty notifications the moment a workspace's git
36
53
  * 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.
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).
41
60
  *
42
61
  * When upstream lands a workspace-metadata service, this half is deleted and
43
62
  * the client half re-points at it β€” the seam contract does not change.
44
63
  */
45
64
  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";
65
+ import { watch, existsSync, readFileSync } from "node:fs";
66
+ import { basename, isAbsolute, join, resolve } from "node:path";
49
67
 
50
68
  const inject = ["webServer", "workspaceRegistry"];
51
69
  const name = "dsh-git-badge";
@@ -68,6 +86,12 @@ const config = {
68
86
  debounceMs: 200,
69
87
  /** backoff before re-attempting a failed watcher (or a missing .git) */
70
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,
71
95
  /** SSE comment heartbeat interval */
72
96
  heartbeatMs: 25000,
73
97
  /**
@@ -230,6 +254,11 @@ function operationMarker(gitDir) {
230
254
  * branch, dirty state, and ahead/behind counts (what git itself considers
231
255
  * dirty), not the subdirectory in isolation. Intentional.
232
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
+ *
233
262
  * `wantDetail` has no consumer yet: both surfaces fetch without `detail=1`.
234
263
  * The branch is retained for the planned hover card β€” nothing renders
235
264
  * lastCommits / stashCount today.
@@ -248,6 +277,11 @@ async function gitStatus(dir, wantDetail) {
248
277
  const toplevel = (topLines[0] ?? "").trim();
249
278
  if (toplevel === "") return { git: false };
250
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"));
251
285
  // -uall counts untracked FILES; git's default collapses a new directory into
252
286
  // a single entry, which is why `✎n` under-reported anyone who added a folder.
253
287
  // Routed through config.gitRunner so the suite can force the timeout branch.
@@ -269,18 +303,26 @@ async function gitStatus(dir, wantDetail) {
269
303
  statusOut = await sample(false);
270
304
  }
271
305
  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.
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.
277
318
  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
- }
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
+ });
284
326
  }
285
327
  const dirtyFiles = parsed.staged + parsed.unstaged + parsed.unmerged + parsed.untracked;
286
328
  const info = {
@@ -295,6 +337,10 @@ async function gitStatus(dir, wantDetail) {
295
337
  untrackedFiles: parsed.untracked,
296
338
  untrackedMode,
297
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),
298
344
  ahead: parsed.ahead,
299
345
  behind: parsed.behind
300
346
  };
@@ -319,10 +365,11 @@ async function gitStatus(dir, wantDetail) {
319
365
 
320
366
  //#region git-state watcher
321
367
  /**
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.
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.
326
373
  */
327
374
  const watchers = new Map();
328
375
  const changeListeners = new Set();
@@ -330,9 +377,12 @@ const changeListeners = new Set();
330
377
  const openStreams = new Set();
331
378
 
332
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 };
333
383
  for (const fn of changeListeners) {
334
384
  try {
335
- fn(key);
385
+ fn(payload);
336
386
  } catch {
337
387
  /* a dead SSE subscriber must never break the others */
338
388
  }
@@ -355,45 +405,194 @@ function closeStream(stream) {
355
405
  }
356
406
  }
357
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
+
358
426
  function unwatchWorkspace(key) {
359
427
  const record = watchers.get(key);
360
428
  if (record === void 0) return;
361
429
  watchers.delete(key);
362
- if (record.watcher !== null) record.watcher.close();
430
+ closeWatchers(record);
363
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");
466
+ }
467
+
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);
364
526
  }
365
527
 
366
- function watchWorkspace(root, key) {
528
+ function watchWorkspace(root, key, workspaceId) {
367
529
  const existing = watchers.get(key);
368
530
  if (existing !== void 0) {
369
531
  // live watcher, or a failed attempt still inside its retry backoff
370
- if (existing.watcher !== null || Date.now() - existing.failedAt < config.watchRetryMs) 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);
371
539
  watchers.delete(key);
372
540
  }
373
541
  // only git workspaces need a badge; a missing .git skips the watcher but
374
542
  // retries on the normal backoff (cheap existsSync) so a later `git init`
375
- // in the workspace is picked up
543
+ // in the workspace is picked up. No poll here: a non-repository has nothing
544
+ // to report.
376
545
  if (!existsSync(join(root, ".git"))) {
377
- 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 });
378
547
  return;
379
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
+ };
380
570
  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 });
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 });
394
591
  } catch {
395
- // watch refused (permissions, watch budget) β€” remember and back off
396
- 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);
397
596
  }
398
597
  }
399
598
 
@@ -401,15 +600,66 @@ function watchWorkspace(root, key) {
401
600
  function syncWatchers(ctx) {
402
601
  const wanted = new Set();
403
602
  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);
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));
408
607
  }
409
608
  for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
410
609
  }
411
610
  //#endregion
412
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
+
413
663
  /** Host plugin body β€” register the status route and the SSE change feed. */
414
664
  function apply(ctx) {
415
665
  ctx.effect(() => ctx.webServer.register({
@@ -418,28 +668,23 @@ function apply(ctx) {
418
668
  handler: async (req, res) => {
419
669
  try {
420
670
  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" }));
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 }));
434
675
  return;
435
676
  }
436
677
  const detail = url.searchParams.get("detail") === "1";
437
- 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.
438
683
  res.writeHead(200, { "content-type": "application/json" });
439
- res.end(body);
684
+ res.end(JSON.stringify({ ...info, workspace: target.id }));
440
685
  } catch (error) {
441
686
  res.writeHead(400, { "content-type": "application/json" });
442
- 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) } }));
443
688
  }
444
689
  }
445
690
  }));
@@ -464,12 +709,12 @@ function apply(ctx) {
464
709
  const stream = {
465
710
  res,
466
711
  heartbeat: null,
467
- send: (key) => {
712
+ send: (payload) => {
468
713
  try {
469
714
  // NAMED frame: the client subscribes with
470
715
  // addEventListener("change", …), which never sees an
471
716
  // unnamed message
472
- res.write(`event: change\ndata: ${JSON.stringify({ path: key })}\n\n`);
717
+ res.write(`event: change\ndata: ${JSON.stringify(payload)}\n\n`);
473
718
  } catch {
474
719
  /* socket gone; the close handler cleans up */
475
720
  }
@@ -510,9 +755,11 @@ export {
510
755
  config,
511
756
  OPERATION_MARKERS,
512
757
  operationMarker,
758
+ outerGitDir,
513
759
  parseStatusV2,
514
760
  runGit,
515
761
  gitStatus,
762
+ resolveWorkspace,
516
763
  watchWorkspace,
517
764
  unwatchWorkspace,
518
765
  syncWatchers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -48,6 +48,7 @@
48
48
  "cordis.patch.yml",
49
49
  "package.json",
50
50
  "README.md",
51
+ "CHANGELOG.md",
51
52
  "LICENSE"
52
53
  ],
53
54
  "peerDependencies": {