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