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