dsh-git-badge 0.7.0 → 0.9.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 +148 -3
- package/README.md +12 -4
- package/lib/client.js +499 -107
- package/lib/index.js +623 -18
- package/package.json +8 -2
- package/seam/anchors.js +138 -0
- package/seam/apply.js +304 -0
- package/seam/stub-index.js +11 -0
package/lib/index.js
CHANGED
|
@@ -44,9 +44,14 @@
|
|
|
44
44
|
* on the first refresh after each TTL window. `GIT_TERMINAL_PROMPT=0`, the
|
|
45
45
|
* shared timeout and the TTL bound keep an offline or slow remote harmless;
|
|
46
46
|
* on failure the stale-ref answer simply stands.
|
|
47
|
-
* - `detail=1` adds one `log -3` plus a stash count
|
|
48
|
-
*
|
|
49
|
-
* the
|
|
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.
|
|
50
55
|
*
|
|
51
56
|
* GET /api/git-badge/events (text/event-stream)
|
|
52
57
|
* - pushes `{ path }` dirty notifications the moment a workspace's git
|
|
@@ -84,6 +89,15 @@ const config = {
|
|
|
84
89
|
fetchTtlMs: 60000,
|
|
85
90
|
/** fs-event burst collapse window */
|
|
86
91
|
debounceMs: 200,
|
|
92
|
+
/**
|
|
93
|
+
* Coalescing window for identical status reads. 0 (the default) collapses only
|
|
94
|
+
* CONCURRENT identical reads — which is exactly what a burst of sidebar
|
|
95
|
+
* session rows mounting together produces — and leaves sequential reads
|
|
96
|
+
* honest, so a caller that mutates a repository and asks again always sees the
|
|
97
|
+
* new state. Raise it to also absorb serial bursts, at the cost of serving a
|
|
98
|
+
* change up to that many milliseconds late.
|
|
99
|
+
*/
|
|
100
|
+
statusCacheMs: 0,
|
|
87
101
|
/** backoff before re-attempting a failed watcher (or a missing .git) */
|
|
88
102
|
watchRetryMs: 60000,
|
|
89
103
|
/**
|
|
@@ -100,23 +114,63 @@ const config = {
|
|
|
100
114
|
* collapsed-fallback branch deterministically — a real sub-millisecond budget
|
|
101
115
|
* would just race git's own startup.
|
|
102
116
|
*/
|
|
103
|
-
gitRunner: null
|
|
117
|
+
gitRunner: null,
|
|
118
|
+
/**
|
|
119
|
+
* PR / CI status via the `gh` CLI: "auto" reads it where `gh` can answer and
|
|
120
|
+
* silently shows nothing where it cannot; "off" never invokes `gh` at all.
|
|
121
|
+
* Only the input chip asks for it, so a sidebar-only view spawns no forge
|
|
122
|
+
* process — see the PR region.
|
|
123
|
+
*/
|
|
124
|
+
prStatus: "auto",
|
|
125
|
+
/** minimum interval between forge refreshes per repository */
|
|
126
|
+
prTtlMs: 90000,
|
|
127
|
+
/**
|
|
128
|
+
* Forge call budget. Unlike git this is a network round trip to GitHub, so
|
|
129
|
+
* it gets a more generous budget than the status sample — but it is bounded
|
|
130
|
+
* all the same, because nothing here may hang the route.
|
|
131
|
+
*/
|
|
132
|
+
prTimeoutMs: 6000,
|
|
133
|
+
/**
|
|
134
|
+
* Invoker for the `gh` CLI; null means the real runCli. The suite substitutes
|
|
135
|
+
* one so every degradation path (no gh, logged out, no PR, garbage output,
|
|
136
|
+
* timeout) is covered with no gh, no network and no forge.
|
|
137
|
+
*/
|
|
138
|
+
prRunner: null,
|
|
139
|
+
/**
|
|
140
|
+
* Bound on the repository-wide open-PR read (`gh pr list`). Only one page is
|
|
141
|
+
* read, so a repository with more open PRs than this simply has the excess
|
|
142
|
+
* invisible to worktree selection; the checkout's own PR read is unaffected.
|
|
143
|
+
*/
|
|
144
|
+
prListLimit: 50,
|
|
145
|
+
/**
|
|
146
|
+
* Worktree-aware status: "auto" lets a SESSION-targeted badge follow the one
|
|
147
|
+
* linked worktree whose branch has an open PR, so a conversation doing its
|
|
148
|
+
* work in a worktree reports that tree instead of the main checkout its
|
|
149
|
+
* directory names; "off" always reports the session's own directory. A
|
|
150
|
+
* workspace-targeted row never follows a worktree whatever this is set to — a
|
|
151
|
+
* row surveys the checkout the registry owns. See effectiveTarget.
|
|
152
|
+
*/
|
|
153
|
+
worktreeStatus: "auto"
|
|
104
154
|
};
|
|
105
155
|
|
|
106
156
|
/**
|
|
107
|
-
* Run
|
|
108
|
-
* plus exactly one of: timeout (execFile timeout kill),
|
|
109
|
-
* binary), or exitCode (
|
|
110
|
-
*
|
|
111
|
-
*
|
|
157
|
+
* Run an external command in a directory. Resolves { stdout } on success; on
|
|
158
|
+
* failure { stdout: null } plus exactly one of: timeout (execFile timeout kill),
|
|
159
|
+
* missing (no such binary), or exitCode (the command ran and rejected — e.g.
|
|
160
|
+
* "not a repository", or `gh` reporting no pull request).
|
|
161
|
+
*
|
|
162
|
+
* The exitCode/timeout/missing trichotomy is what lets a CALLER tell a
|
|
163
|
+
* definitive answer from a transient one: a missing `gh` and a `gh` that says
|
|
164
|
+
* "no PR" are different facts, and only the caller knows which of them it can
|
|
165
|
+
* act on. Shared by git and `gh` so neither grows its own timeout semantics.
|
|
112
166
|
*/
|
|
113
|
-
function
|
|
114
|
-
const { env, timeout = config.gitTimeoutMs } = opts ?? {};
|
|
167
|
+
function runCli(cmd, args, opts) {
|
|
168
|
+
const { cwd, env, timeout = config.gitTimeoutMs } = opts ?? {};
|
|
115
169
|
// env is a full replacement for execFile; overlay onto process.env so
|
|
116
170
|
// PATH/HOME (credential helpers, global config) survive
|
|
117
171
|
const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
|
|
118
172
|
return new Promise((resolve) => {
|
|
119
|
-
execFile(
|
|
173
|
+
execFile(cmd, args, { cwd, timeout, env: fullEnv }, (error, stdout) => {
|
|
120
174
|
if (error === void 0 || error === null) resolve({ stdout: String(stdout) });
|
|
121
175
|
else if (error.killed) resolve({ stdout: null, timeout: true });
|
|
122
176
|
else if (error.code === "ENOENT") resolve({ stdout: null, missing: true });
|
|
@@ -125,6 +179,15 @@ function runGit(dir, args, opts) {
|
|
|
125
179
|
});
|
|
126
180
|
}
|
|
127
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Run git in dir. A thin specialization of runCli: the contract above is
|
|
184
|
+
* unchanged, and `config.gitRunner` still substitutes for the whole call so the
|
|
185
|
+
* suite can force the timeout branch.
|
|
186
|
+
*/
|
|
187
|
+
function runGit(dir, args, opts) {
|
|
188
|
+
return runCli("git", args, { ...opts, cwd: dir });
|
|
189
|
+
}
|
|
190
|
+
|
|
128
191
|
/** Marker response for a git invocation that failed or timed out (transient). */
|
|
129
192
|
const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
|
|
130
193
|
|
|
@@ -178,6 +241,279 @@ async function maybeFetch(toplevel) {
|
|
|
178
241
|
}
|
|
179
242
|
//#endregion
|
|
180
243
|
|
|
244
|
+
//#region PR / CI status (gh)
|
|
245
|
+
/**
|
|
246
|
+
* GitHub pull-request and check state for the current branch, read through the
|
|
247
|
+
* user's own `gh` CLI.
|
|
248
|
+
*
|
|
249
|
+
* Why the CLI and not the REST API: `gh` already owns the user's credentials in
|
|
250
|
+
* its own config, so this plugin never reads, stores, caches or forwards a
|
|
251
|
+
* token. There is no secret here to leak and no OAuth flow to maintain — and a
|
|
252
|
+
* machine that has never authenticated `gh` simply gets no PR token.
|
|
253
|
+
*
|
|
254
|
+
* Degradation is a MISSING FIELD, never an error. A machine without `gh`, a
|
|
255
|
+
* logged-out `gh`, a remote that is not GitHub, an offline network, a forge rate
|
|
256
|
+
* limit, unparseable output and a timed-out call all produce exactly the same
|
|
257
|
+
* response: no `pr` key at all. That is the honest answer, because in every one
|
|
258
|
+
* of those cases the badge has nothing to say — and a caller that could tell
|
|
259
|
+
* "no PR" from "no gh" would still not know what to draw.
|
|
260
|
+
*
|
|
261
|
+
* Like the fetch above this is TTL-bounded and NEVER awaited by the route: the
|
|
262
|
+
* cached value is served immediately and a refresh that CHANGES it notifies
|
|
263
|
+
* subscribers, so the request that lapses the window carries the previous answer
|
|
264
|
+
* and the follow-up carries the new one. A forge round trip must never become
|
|
265
|
+
* badge latency.
|
|
266
|
+
*/
|
|
267
|
+
/** toplevel -> { lastAttemptAt, inFlight, value }; `value` undefined = nothing to say. */
|
|
268
|
+
const prState = new Map();
|
|
269
|
+
|
|
270
|
+
/** Test seam: the CLI invoker `gh` calls go through, so no test needs `gh`. */
|
|
271
|
+
function runPrCli(cmd, args, opts) {
|
|
272
|
+
return (config.prRunner ?? runCli)(cmd, args, opts);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Does `origin` point at github.com? A cheap LOCAL call, so a non-GitHub repo
|
|
277
|
+
* never pays for a `gh` process. Deliberately scoped to origin: that is the
|
|
278
|
+
* remote `gh` itself resolves the repository from, so agreeing with it here is
|
|
279
|
+
* what keeps the two from disagreeing later.
|
|
280
|
+
*/
|
|
281
|
+
async function originIsGitHub(toplevel) {
|
|
282
|
+
const out = await runGit(toplevel, ["remote", "get-url", "origin"]);
|
|
283
|
+
if (out.stdout === null) return false;
|
|
284
|
+
return /(^|[/@.])github\.com[:/]/i.test(out.stdout.trim());
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reduce a `statusCheckRollup` to ONE worst-case state, so the chip needs a
|
|
289
|
+
* single colour rather than a list.
|
|
290
|
+
*
|
|
291
|
+
* Two entry shapes share the array: a CheckRun carries `status` + `conclusion`,
|
|
292
|
+
* a StatusContext carries `state` alone. A single failure outweighs any number
|
|
293
|
+
* of successes and pending checks — "mostly passing" is not a thing a red/green
|
|
294
|
+
* token can express — so failure returns immediately and pending only wins over
|
|
295
|
+
* an otherwise all-successful set.
|
|
296
|
+
*/
|
|
297
|
+
function summarizeChecks(checks) {
|
|
298
|
+
let sawAny = false;
|
|
299
|
+
let sawPending = false;
|
|
300
|
+
for (const check of checks) {
|
|
301
|
+
const state = String(check?.state ?? "").toUpperCase();
|
|
302
|
+
const status = String(check?.status ?? "").toUpperCase();
|
|
303
|
+
const conclusion = String(check?.conclusion ?? "").toUpperCase();
|
|
304
|
+
sawAny = true;
|
|
305
|
+
const failed =
|
|
306
|
+
["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(state) ||
|
|
307
|
+
["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion);
|
|
308
|
+
if (failed) return "failing";
|
|
309
|
+
// unfinished: a CheckRun that has not completed, or a StatusContext PENDING
|
|
310
|
+
if (state === "PENDING" || (status !== "" && status !== "COMPLETED")) sawPending = true;
|
|
311
|
+
}
|
|
312
|
+
if (!sawAny) return "none";
|
|
313
|
+
return sawPending ? "pending" : "passing";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The compact PR shape the chip renders, or undefined when there is nothing to say. */
|
|
317
|
+
function summarizePr(json) {
|
|
318
|
+
if (json === null || typeof json !== "object") return void 0;
|
|
319
|
+
const number = Number.isFinite(json?.number) ? json.number : void 0;
|
|
320
|
+
if (number === void 0) return void 0;
|
|
321
|
+
const pr = {
|
|
322
|
+
number,
|
|
323
|
+
state: summarizeChecks(Array.isArray(json.statusCheckRollup) ? json.statusCheckRollup : []),
|
|
324
|
+
// draft and review state are separate from CI: a draft with green checks
|
|
325
|
+
// is not ready, and a blocked review is not a failing build
|
|
326
|
+
draft: json.isDraft === true,
|
|
327
|
+
open: String(json.state ?? "OPEN").toUpperCase() === "OPEN"
|
|
328
|
+
};
|
|
329
|
+
// omitted, not present-and-undefined: absence is the contract for "nothing to
|
|
330
|
+
// say" throughout this half, so a consumer tests the key, never the value
|
|
331
|
+
if (typeof json.reviewDecision === "string" && json.reviewDecision !== "") pr.review = json.reviewDecision;
|
|
332
|
+
// GitHub's OWN merge verdict (CLEAN / BLOCKED / BEHIND / DIRTY / DRAFT /
|
|
333
|
+
// UNSTABLE / HAS_HOOKS / UNKNOWN), passed through rather than re-derived here:
|
|
334
|
+
// whether a PR is mergeable depends on branch protection and required reviews,
|
|
335
|
+
// which is the forge's business and not this plugin's to interpret. `gh pr list`
|
|
336
|
+
// does not ask for it, so a worktree-candidate entry simply has no `mergeState`
|
|
337
|
+
// — absence is the contract, as everywhere else in this half.
|
|
338
|
+
if (typeof json.mergeStateStatus === "string" && json.mergeStateStatus !== "") pr.mergeState = json.mergeStateStatus.toUpperCase();
|
|
339
|
+
// The chip links the token to the PR, so `url` is the one field that becomes an
|
|
340
|
+
// `href`: only http(s) is emitted, and anything else is omitted like every
|
|
341
|
+
// other "nothing to say" field, so a payload value cannot reach an anchor as a
|
|
342
|
+
// `javascript:` URL. The client re-checks before it builds the href — this half
|
|
343
|
+
// is the authority on what it vouches for, not the only guard.
|
|
344
|
+
if (typeof json.url === "string" && json.url !== "") {
|
|
345
|
+
try {
|
|
346
|
+
const { protocol } = new URL(json.url);
|
|
347
|
+
if (protocol === "http:" || protocol === "https:") pr.url = json.url;
|
|
348
|
+
} catch {
|
|
349
|
+
// not a URL at all — omitted, exactly like an absent one
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return pr;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Read PR state for `branch`, or undefined. NEVER throws and never rejects: a
|
|
357
|
+
* forge is an optional luxury, so every failure path is "nothing to say".
|
|
358
|
+
*/
|
|
359
|
+
async function readPrStatus(toplevel, branch) {
|
|
360
|
+
// a detached HEAD is not a branch `gh` can resolve a PR for
|
|
361
|
+
if (typeof branch !== "string" || branch === "" || branch.startsWith("HEAD")) return void 0;
|
|
362
|
+
if (!(await originIsGitHub(toplevel))) return void 0;
|
|
363
|
+
const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url,mergeStateStatus"], {
|
|
364
|
+
cwd: toplevel,
|
|
365
|
+
timeout: config.prTimeoutMs,
|
|
366
|
+
// GH_PROMPT_DISABLED: an auth prompt must never hang the refresh (the gh
|
|
367
|
+
// analogue of the git fetch's GIT_TERMINAL_PROMPT=0). The pager vars keep
|
|
368
|
+
// gh from waiting on a pager that will never answer, and NO_COLOR keeps
|
|
369
|
+
// escape codes out of the JSON.
|
|
370
|
+
env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
|
|
371
|
+
});
|
|
372
|
+
if (out.stdout === null) return void 0;
|
|
373
|
+
let json;
|
|
374
|
+
try {
|
|
375
|
+
json = JSON.parse(out.stdout);
|
|
376
|
+
} catch {
|
|
377
|
+
return void 0;
|
|
378
|
+
}
|
|
379
|
+
return summarizePr(json);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** toplevel -> { lastAttemptAt, inFlight, value: Map<branch, pr> }. */
|
|
383
|
+
const prListState = new Map();
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Every OPEN pull request in `toplevel`'s repository, keyed by its head branch.
|
|
387
|
+
* ONE `gh pr list` answers the whole repository, which is what makes worktree
|
|
388
|
+
* selection affordable: N worktrees cost one forge call, not N.
|
|
389
|
+
*
|
|
390
|
+
* Degrades exactly like readPrStatus, and for the same reason: a non-array body,
|
|
391
|
+
* unparseable output, a logged-out `gh`, a non-GitHub `origin` and a timeout all
|
|
392
|
+
* resolve an empty map. "No PRs" and "no forge" are the same answer here — both
|
|
393
|
+
* mean no worktree can be shown to be the one the session is working in.
|
|
394
|
+
*/
|
|
395
|
+
async function readOpenPrs(toplevel) {
|
|
396
|
+
if (!(await originIsGitHub(toplevel))) return new Map();
|
|
397
|
+
const out = await runPrCli("gh", ["pr", "list", "--state", "open", "--limit", String(config.prListLimit), "--json", "number,headRefName,state,isDraft,reviewDecision,statusCheckRollup,url"], {
|
|
398
|
+
cwd: toplevel,
|
|
399
|
+
timeout: config.prTimeoutMs,
|
|
400
|
+
env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
|
|
401
|
+
});
|
|
402
|
+
if (out.stdout === null) return new Map();
|
|
403
|
+
let json;
|
|
404
|
+
try {
|
|
405
|
+
json = JSON.parse(out.stdout);
|
|
406
|
+
} catch {
|
|
407
|
+
return new Map();
|
|
408
|
+
}
|
|
409
|
+
if (!Array.isArray(json)) return new Map();
|
|
410
|
+
const byBranch = new Map();
|
|
411
|
+
for (const row of json) {
|
|
412
|
+
const branch = row?.headRefName;
|
|
413
|
+
if (typeof branch !== "string" || branch === "") continue;
|
|
414
|
+
const pr = summarizePr(row);
|
|
415
|
+
if (pr !== void 0) byBranch.set(branch, pr);
|
|
416
|
+
}
|
|
417
|
+
return byBranch;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Order-insensitive identity for an open-PR set, so "did it change?" is exact. */
|
|
421
|
+
function prListKey(byBranch) {
|
|
422
|
+
if (byBranch === void 0) return "";
|
|
423
|
+
return JSON.stringify([...byBranch].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Open PRs for toplevel: the value to serve NOW (possibly undefined), spawning an
|
|
428
|
+
* out-of-band refresh when the TTL has lapsed — the same shape and the same
|
|
429
|
+
* reasoning as prStatusFor, because the forge is never route latency.
|
|
430
|
+
*
|
|
431
|
+
* `notify` fires only when the set CHANGES: a changed set can change which
|
|
432
|
+
* worktree a session badge follows, so the client has to be told to refetch
|
|
433
|
+
* rather than waiting for its 60s safety-net poll.
|
|
434
|
+
*/
|
|
435
|
+
function openPrsFor(toplevel, notify) {
|
|
436
|
+
if (config.prStatus === "off") return void 0;
|
|
437
|
+
const now = Date.now();
|
|
438
|
+
const state = prListState.get(toplevel);
|
|
439
|
+
if (state !== void 0) {
|
|
440
|
+
// Fresh, or a refresh is already running: serve what we have either way.
|
|
441
|
+
if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
|
|
442
|
+
}
|
|
443
|
+
const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
|
|
444
|
+
const inFlight = (async () => {
|
|
445
|
+
const value = await readOpenPrs(toplevel).catch(() => void 0);
|
|
446
|
+
if (value !== void 0) {
|
|
447
|
+
const changed = prListKey(record.value) !== prListKey(value);
|
|
448
|
+
record.value = value;
|
|
449
|
+
if (changed) notify(toplevel);
|
|
450
|
+
}
|
|
451
|
+
record.lastAttemptAt = Date.now();
|
|
452
|
+
record.inFlight = null;
|
|
453
|
+
return value;
|
|
454
|
+
})();
|
|
455
|
+
record.inFlight = inFlight;
|
|
456
|
+
prListState.set(toplevel, record);
|
|
457
|
+
return record.value;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The one linked worktree a session badge may follow: exactly one entry other
|
|
462
|
+
* than the checkout itself that is checked out on a branch with an OPEN PR.
|
|
463
|
+
*
|
|
464
|
+
* Exactly one, because the plugin cannot know which tree a session uses — DSH
|
|
465
|
+
* records no session→worktree link at all — so an ambiguous repository stays on
|
|
466
|
+
* its own checkout rather than guessing. A second open PR is a reason to say
|
|
467
|
+
* nothing, not a reason to pick one. Detached, bare and prunable entries are
|
|
468
|
+
* never candidates: they have no branch a PR could belong to.
|
|
469
|
+
*/
|
|
470
|
+
function selectSessionWorktree(worktrees, openPrs, checkoutPath) {
|
|
471
|
+
if (openPrs === void 0 || openPrs.size === 0) return void 0;
|
|
472
|
+
const candidates = worktrees.filter((wt) =>
|
|
473
|
+
wt.path !== checkoutPath &&
|
|
474
|
+
wt.detached !== true &&
|
|
475
|
+
wt.bare !== true &&
|
|
476
|
+
wt.prunable !== true &&
|
|
477
|
+
typeof wt.branch === "string" &&
|
|
478
|
+
openPrs.has(wt.branch)
|
|
479
|
+
);
|
|
480
|
+
return candidates.length === 1 ? candidates[0] : void 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* PR state for toplevel: the value to serve NOW (possibly undefined, possibly
|
|
485
|
+
* stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
|
|
486
|
+
* called only when the refreshed value CHANGES, so an unchanged answer costs one
|
|
487
|
+
* forge call per TTL and zero SSE traffic.
|
|
488
|
+
*
|
|
489
|
+
* Keyed by BRANCH as well as toplevel: a checkout inside the TTL window would
|
|
490
|
+
* otherwise be served the PR of the branch it was on when the window opened.
|
|
491
|
+
*/
|
|
492
|
+
function prStatusFor(toplevel, branch, notify) {
|
|
493
|
+
if (config.prStatus === "off") return void 0;
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
const key = toplevel + "\u0000" + branch;
|
|
496
|
+
const state = prState.get(key);
|
|
497
|
+
if (state !== void 0) {
|
|
498
|
+
// Fresh, or a refresh is already running: serve what we have either way.
|
|
499
|
+
if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
|
|
500
|
+
}
|
|
501
|
+
const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
|
|
502
|
+
const inFlight = (async () => {
|
|
503
|
+
const value = await readPrStatus(toplevel, branch).catch(() => void 0);
|
|
504
|
+
const changed = JSON.stringify(record.value ?? null) !== JSON.stringify(value ?? null);
|
|
505
|
+
record.lastAttemptAt = Date.now();
|
|
506
|
+
record.inFlight = null;
|
|
507
|
+
record.value = value;
|
|
508
|
+
if (changed) notify(toplevel);
|
|
509
|
+
return value;
|
|
510
|
+
})();
|
|
511
|
+
record.inFlight = inFlight;
|
|
512
|
+
prState.set(key, record);
|
|
513
|
+
return record.value;
|
|
514
|
+
}
|
|
515
|
+
//#endregion
|
|
516
|
+
|
|
181
517
|
/**
|
|
182
518
|
* Parse `git status --porcelain=v2 --branch` output.
|
|
183
519
|
* Headers: `# branch.head <name>` (or `(detached)`), `# branch.upstream
|
|
@@ -214,6 +550,86 @@ function parseStatusV2(out) {
|
|
|
214
550
|
return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
|
|
215
551
|
}
|
|
216
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Parse `git worktree list --porcelain`. Blocks of `key value` lines separated by
|
|
555
|
+
* a blank line: `worktree <path>`, `HEAD <sha>`, `branch refs/heads/<name>` (the
|
|
556
|
+
* line is ABSENT when the entry is detached), and the flag lines `bare`, `locked`
|
|
557
|
+
* and `prunable`, which may carry a reason after the key.
|
|
558
|
+
*
|
|
559
|
+
* The last field is the checkout's directory NAME, which is all the badge ever
|
|
560
|
+
* reports — never the path (AGENTS.md rule 7). Git prints the main worktree first,
|
|
561
|
+
* but the caller identifies the checkout it is standing in by comparing `path`
|
|
562
|
+
* against its own toplevel rather than by list position.
|
|
563
|
+
*/
|
|
564
|
+
function parseWorktreeList(stdout) {
|
|
565
|
+
const entries = [];
|
|
566
|
+
let entry = null;
|
|
567
|
+
const flush = () => {
|
|
568
|
+
if (entry !== null) entries.push({ ...entry, name: basename(entry.path) });
|
|
569
|
+
entry = null;
|
|
570
|
+
};
|
|
571
|
+
for (const raw of String(stdout ?? "").split("\n")) {
|
|
572
|
+
const line = raw.trim();
|
|
573
|
+
if (line === "") {
|
|
574
|
+
flush();
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const at = line.indexOf(" ");
|
|
578
|
+
const key = at === -1 ? line : line.slice(0, at);
|
|
579
|
+
const value = at === -1 ? "" : line.slice(at + 1);
|
|
580
|
+
if (key === "worktree") {
|
|
581
|
+
flush();
|
|
582
|
+
entry = { path: value, head: "", branch: void 0, detached: false, bare: false, locked: false, prunable: false };
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (entry === null) continue;
|
|
586
|
+
if (key === "HEAD") entry.head = value;
|
|
587
|
+
else if (key === "branch") entry.branch = value.replace(/^refs\/heads\//, "");
|
|
588
|
+
else if (key === "detached") entry.detached = true;
|
|
589
|
+
else if (key === "bare") entry.bare = true;
|
|
590
|
+
else if (key === "locked") entry.locked = true;
|
|
591
|
+
else if (key === "prunable") entry.prunable = true;
|
|
592
|
+
}
|
|
593
|
+
flush();
|
|
594
|
+
return entries;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* The worktree landscape of the repository `dir` sits in: its toplevel in GIT's
|
|
599
|
+
* own spelling (so it compares exactly against the listed paths, with no
|
|
600
|
+
* spelling guesswork) and every worktree of that repository, main one included.
|
|
601
|
+
*
|
|
602
|
+
* Two cheap local invocations. A failure of either degrades to "no linked
|
|
603
|
+
* worktrees", which leaves the badge on the directory the session actually names
|
|
604
|
+
* rather than substituting a guess.
|
|
605
|
+
*/
|
|
606
|
+
async function readWorktrees(dir) {
|
|
607
|
+
const top = await runGit(dir, ["rev-parse", "--show-toplevel"]);
|
|
608
|
+
const toplevel = top.stdout === null ? "" : top.stdout.trim();
|
|
609
|
+
if (toplevel === "") return { toplevel: "", worktrees: [] };
|
|
610
|
+
const list = await runGit(dir, ["worktree", "list", "--porcelain"]);
|
|
611
|
+
return { toplevel, worktrees: list.stdout === null ? [] : parseWorktreeList(list.stdout) };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** In-flight worktree probes, so a burst of callers shares one pair of calls. */
|
|
615
|
+
const worktreeInFlight = new Map();
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* `readWorktrees` with identical CONCURRENT calls collapsed, the same way
|
|
619
|
+
* `gitStatus` collapses status reads: every session row of one workspace probes
|
|
620
|
+
* the same directory at the same moment. Deliberately no TTL — the probe is two
|
|
621
|
+
* local git calls, and a worktree created a moment ago must be visible at once.
|
|
622
|
+
*/
|
|
623
|
+
function worktreesFor(dir) {
|
|
624
|
+
const pending = worktreeInFlight.get(dir);
|
|
625
|
+
if (pending !== void 0) return pending;
|
|
626
|
+
const run = readWorktrees(dir).finally(() => {
|
|
627
|
+
worktreeInFlight.delete(dir);
|
|
628
|
+
});
|
|
629
|
+
worktreeInFlight.set(dir, run);
|
|
630
|
+
return run;
|
|
631
|
+
}
|
|
632
|
+
|
|
217
633
|
/**
|
|
218
634
|
* In-progress operation markers, in precedence order. Git drops one of these in
|
|
219
635
|
* the per-worktree git dir while an operation is paused; the first match names
|
|
@@ -259,11 +675,12 @@ function operationMarker(gitDir) {
|
|
|
259
675
|
* worktree reports that worktree (the toplevel walk starts above it), which is
|
|
260
676
|
* the same whole-repository rule as above.
|
|
261
677
|
*
|
|
262
|
-
* `wantDetail`
|
|
263
|
-
*
|
|
264
|
-
*
|
|
678
|
+
* `wantDetail` serves the input chip's HOVER CARD (recent commits + stash) and
|
|
679
|
+
* `wantPr` its PR/CI token. They are asked for separately and by the chip only,
|
|
680
|
+
* so neither is paid for by a sidebar row refresh: a row needs status and
|
|
681
|
+
* identity, nothing more.
|
|
265
682
|
*/
|
|
266
|
-
async function
|
|
683
|
+
async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
267
684
|
// one invocation answers both questions: the repository root (so a
|
|
268
685
|
// subdirectory workspace reports the whole repo) and the per-worktree git
|
|
269
686
|
// dir (where the operation markers above live)
|
|
@@ -360,9 +777,54 @@ async function gitStatus(dir, wantDetail) {
|
|
|
360
777
|
if (count > 0) info.stashCount = count;
|
|
361
778
|
}
|
|
362
779
|
}
|
|
780
|
+
// PR / CI is the INPUT CHIP's business, so it is asked for explicitly
|
|
781
|
+
// (`?pr=1`) rather than paid for by every sidebar row: a row that surveys
|
|
782
|
+
// twenty workspaces must not spawn twenty forge processes. Served from the
|
|
783
|
+
// TTL cache and refreshed out of band, exactly like the fetch above — the
|
|
784
|
+
// forge round trip is never route latency.
|
|
785
|
+
if (wantPr) {
|
|
786
|
+
const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
|
|
787
|
+
if (pr !== void 0) info.pr = pr;
|
|
788
|
+
}
|
|
363
789
|
return info;
|
|
364
790
|
}
|
|
365
791
|
|
|
792
|
+
/** In-flight status reads, so N callers asking at once share one `git status`. */
|
|
793
|
+
const statusInFlight = new Map();
|
|
794
|
+
/** Completed reads inside `config.statusCacheMs`; unused at the 0 default. */
|
|
795
|
+
const statusCache = new Map();
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Status for dir, with identical CONCURRENT reads collapsed into one. That is the
|
|
799
|
+
* shape a burst of sidebar session rows produces when they mount together: N rows
|
|
800
|
+
* of one workspace resolve to the same directory, and without this they would run
|
|
801
|
+
* N `git status` walks of the same tree at the same instant.
|
|
802
|
+
*
|
|
803
|
+
* The optional `statusCacheMs` window extends the collapse to serial bursts. It
|
|
804
|
+
* defaults to 0 on purpose: a cache that outlives the call would let a caller
|
|
805
|
+
* that mutates a repository and asks again read a stale answer, and correctness
|
|
806
|
+
* of a *status* badge outranks saving a walk.
|
|
807
|
+
*/
|
|
808
|
+
function gitStatus(dir, wantDetail, wantPr) {
|
|
809
|
+
const key = dir + "\u0000" + (wantDetail === true) + "\u0000" + (wantPr === true);
|
|
810
|
+
if (config.statusCacheMs > 0) {
|
|
811
|
+
const hit = statusCache.get(key);
|
|
812
|
+
if (hit !== void 0 && Date.now() - hit.at < config.statusCacheMs) return Promise.resolve(hit.value);
|
|
813
|
+
}
|
|
814
|
+
const pending = statusInFlight.get(key);
|
|
815
|
+
if (pending !== void 0) return pending;
|
|
816
|
+
const run = gitStatusUncached(dir, wantDetail, wantPr)
|
|
817
|
+
.then((value) => {
|
|
818
|
+
if (config.statusCacheMs > 0) statusCache.set(key, { at: Date.now(), value });
|
|
819
|
+
return value;
|
|
820
|
+
})
|
|
821
|
+
.finally(() => {
|
|
822
|
+
statusInFlight.delete(key);
|
|
823
|
+
});
|
|
824
|
+
statusInFlight.set(key, run);
|
|
825
|
+
return run;
|
|
826
|
+
}
|
|
827
|
+
|
|
366
828
|
//#region git-state watcher
|
|
367
829
|
/**
|
|
368
830
|
* One recursive watcher per workspace, plus a second one when the git dir lives
|
|
@@ -596,6 +1058,49 @@ function watchWorkspace(root, key, workspaceId) {
|
|
|
596
1058
|
}
|
|
597
1059
|
}
|
|
598
1060
|
|
|
1061
|
+
/**
|
|
1062
|
+
* Workspace path -> { worktreePath, workspaceId, owned } for the inferred
|
|
1063
|
+
* worktree a session badge is currently following. One per workspace: selection
|
|
1064
|
+
* is a single choice by construction (selectSessionWorktree), so following a
|
|
1065
|
+
* different tree retires the previous one here.
|
|
1066
|
+
*/
|
|
1067
|
+
const selectedWorktrees = new Map();
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Keep the watcher set aligned with the worktree a session badge has been told to
|
|
1071
|
+
* follow.
|
|
1072
|
+
*
|
|
1073
|
+
* A selected worktree is usually NOT a registered workspace, so nothing else
|
|
1074
|
+
* watches it — and freshness would then depend on the owning checkout's recursive
|
|
1075
|
+
* watch happening to cover it (true for a worktree nested inside the repository,
|
|
1076
|
+
* false for a sibling directory). Its events must also carry the OWNING workspace
|
|
1077
|
+
* id, because that is what a session-targeted client matches its events against:
|
|
1078
|
+
* `notifyChange` reads the id off the watcher record for the path it watched.
|
|
1079
|
+
*
|
|
1080
|
+
* `owned` records whether this call created the watcher. A worktree can itself be
|
|
1081
|
+
* a registered workspace (someone may have opened one as a workspace), and
|
|
1082
|
+
* retiring the selection must never tear down a watcher the registry owns.
|
|
1083
|
+
*
|
|
1084
|
+
* Idempotent and cheap: called on every session request, and a no-op while the
|
|
1085
|
+
* selection is unchanged. `chosenPath === undefined` retires whatever was
|
|
1086
|
+
* selected — the "the worktree is gone, or its PR closed" path.
|
|
1087
|
+
*/
|
|
1088
|
+
function selectWorktreeWatch(ownerPath, workspaceId, chosenPath) {
|
|
1089
|
+
const previous = selectedWorktrees.get(ownerPath);
|
|
1090
|
+
if (previous !== void 0 && previous.worktreePath === chosenPath) {
|
|
1091
|
+
previous.workspaceId = workspaceId;
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (previous !== void 0) {
|
|
1095
|
+
if (previous.owned) unwatchWorkspace(previous.worktreePath);
|
|
1096
|
+
selectedWorktrees.delete(ownerPath);
|
|
1097
|
+
}
|
|
1098
|
+
if (chosenPath === undefined) return;
|
|
1099
|
+
const owned = !watchers.has(chosenPath);
|
|
1100
|
+
watchWorkspace(chosenPath, chosenPath, workspaceId);
|
|
1101
|
+
selectedWorktrees.set(ownerPath, { worktreePath: chosenPath, workspaceId, owned });
|
|
1102
|
+
}
|
|
1103
|
+
|
|
599
1104
|
/** Reconcile the watcher set with the current workspace registry. */
|
|
600
1105
|
function syncWatchers(ctx) {
|
|
601
1106
|
const wanted = new Set();
|
|
@@ -605,6 +1110,17 @@ function syncWatchers(ctx) {
|
|
|
605
1110
|
wanted.add(entity.path);
|
|
606
1111
|
watchWorkspace(entity.path, entity.path, entity.id === void 0 ? void 0 : String(entity.id));
|
|
607
1112
|
}
|
|
1113
|
+
// An inferred worktree is watched because a session request selected it, not
|
|
1114
|
+
// because the registry owns it. Keep the ones whose owner is still registered
|
|
1115
|
+
// and retire the rest, so this reconcile never tears down a live selection.
|
|
1116
|
+
for (const [ownerPath, entry] of selectedWorktrees) {
|
|
1117
|
+
if (!wanted.has(ownerPath)) {
|
|
1118
|
+
if (entry.owned) unwatchWorkspace(entry.worktreePath);
|
|
1119
|
+
selectedWorktrees.delete(ownerPath);
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
wanted.add(entry.worktreePath);
|
|
1123
|
+
}
|
|
608
1124
|
for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
|
|
609
1125
|
}
|
|
610
1126
|
//#endregion
|
|
@@ -660,6 +1176,46 @@ async function resolveWorkspace(ctx, params) {
|
|
|
660
1176
|
return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
|
|
661
1177
|
}
|
|
662
1178
|
|
|
1179
|
+
/**
|
|
1180
|
+
* The directory a request is ABOUT.
|
|
1181
|
+
*
|
|
1182
|
+
* A SESSION-targeted chip may follow the one linked worktree its repository can be
|
|
1183
|
+
* shown to be working in (selectSessionWorktree): DSH records no session→worktree
|
|
1184
|
+
* link at all — session cwd is immutable creation metadata and `attachSession`
|
|
1185
|
+
* requires it to equal the workspace path — so a conversation started in the main
|
|
1186
|
+
* checkout would otherwise report `main` while its work, and its pull request,
|
|
1187
|
+
* live in a tree. The tree's watcher is reconciled HERE, because this is the only
|
|
1188
|
+
* place that knows which tree was chosen.
|
|
1189
|
+
*
|
|
1190
|
+
* A WORKSPACE-targeted row never follows a worktree: a row surveys the checkout
|
|
1191
|
+
* the registry owns, and substituting a tree would make the row lie about the
|
|
1192
|
+
* branch it is on.
|
|
1193
|
+
*
|
|
1194
|
+
* Gated three ways so the common case costs nothing: the caller must name a
|
|
1195
|
+
* session, must be asking about PR state at all (selection is defined by an open
|
|
1196
|
+
* PR, so a caller not asking cannot benefit), and the repository must actually
|
|
1197
|
+
* have a linked worktree. Only then is the forge consulted.
|
|
1198
|
+
*/
|
|
1199
|
+
async function effectiveTarget(target, bySession, wantPr, notify) {
|
|
1200
|
+
if (bySession !== true || wantPr !== true || config.worktreeStatus === "off") return { id: target.id, path: target.path };
|
|
1201
|
+
const { toplevel, worktrees } = await worktreesFor(target.path);
|
|
1202
|
+
// Git lists the main worktree first, so `main.path === toplevel` is the exact
|
|
1203
|
+
// test for "the session's own directory is the main checkout". A session whose
|
|
1204
|
+
// directory IS a linked worktree is its own answer and is never swapped
|
|
1205
|
+
// sideways onto a sibling.
|
|
1206
|
+
const main = worktrees[0];
|
|
1207
|
+
if (toplevel === "" || main === void 0 || main.path !== toplevel || worktrees.length <= 1) {
|
|
1208
|
+
selectWorktreeWatch(target.path, target.id, void 0);
|
|
1209
|
+
return { id: target.id, path: target.path };
|
|
1210
|
+
}
|
|
1211
|
+
const openPrs = openPrsFor(toplevel, notify);
|
|
1212
|
+
const chosen = selectSessionWorktree(worktrees, openPrs, toplevel);
|
|
1213
|
+
selectWorktreeWatch(target.path, target.id, chosen === void 0 ? void 0 : chosen.path);
|
|
1214
|
+
if (chosen === void 0) return { id: target.id, path: target.path };
|
|
1215
|
+
// The name and branch travel; the PATH never does (AGENTS.md rule 7).
|
|
1216
|
+
return { id: target.id, path: chosen.path, worktree: { name: chosen.name, branch: chosen.branch } };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
663
1219
|
/** Host plugin body — register the status route and the SSE change feed. */
|
|
664
1220
|
function apply(ctx) {
|
|
665
1221
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -675,13 +1231,44 @@ function apply(ctx) {
|
|
|
675
1231
|
return;
|
|
676
1232
|
}
|
|
677
1233
|
const detail = url.searchParams.get("detail") === "1";
|
|
678
|
-
const
|
|
1234
|
+
const pr = url.searchParams.get("pr") === "1";
|
|
1235
|
+
// WHO asked decides whether a linked worktree may stand in for the
|
|
1236
|
+
// session's own directory — see effectiveTarget. A workspace-targeted
|
|
1237
|
+
// row never leaves the checkout the registry owns.
|
|
1238
|
+
const session = url.searchParams.get("session");
|
|
1239
|
+
const effective = await effectiveTarget(target, session !== null && session !== "", pr, notifyChange);
|
|
1240
|
+
const info = await gitStatus(effective.path, detail, pr);
|
|
1241
|
+
const body = { ...info, workspace: target.id };
|
|
1242
|
+
if (effective.worktree !== void 0 && info.git === true) {
|
|
1243
|
+
// The status describes a worktree the SESSION's directory does not
|
|
1244
|
+
// name. Saying so is the whole honesty of the feature: the chip's
|
|
1245
|
+
// mark says "worktree", its name says which, and this flag tells the
|
|
1246
|
+
// card why the checkout below differs from the branch beside it.
|
|
1247
|
+
body.worktreeInferred = true;
|
|
1248
|
+
if (detail) {
|
|
1249
|
+
// The session's OWN directory, for the card's `checkout` row, so the
|
|
1250
|
+
// main checkout stays visible once the badge follows a tree. Gated
|
|
1251
|
+
// by detail=1 (a pointer resting on the chip), so nothing at rest
|
|
1252
|
+
// pays for the second read.
|
|
1253
|
+
const own = await gitStatus(target.path, false, false);
|
|
1254
|
+
if (own.git === true) {
|
|
1255
|
+
body.checkout = {
|
|
1256
|
+
branch: own.branch,
|
|
1257
|
+
dirty: own.dirty,
|
|
1258
|
+
changedFiles: own.changedFiles,
|
|
1259
|
+
untrackedFiles: own.untrackedFiles,
|
|
1260
|
+
ahead: own.ahead,
|
|
1261
|
+
behind: own.behind
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
679
1266
|
// Echo the resolved workspace id. SSE events identify the workspace,
|
|
680
1267
|
// not the session, so a session-targeted client has no other way to
|
|
681
1268
|
// tell whether an event belongs to it — without this the input chip
|
|
682
1269
|
// only ever refreshed on remount or the 60s poll.
|
|
683
1270
|
res.writeHead(200, { "content-type": "application/json" });
|
|
684
|
-
res.end(JSON.stringify(
|
|
1271
|
+
res.end(JSON.stringify(body));
|
|
685
1272
|
} catch (error) {
|
|
686
1273
|
res.writeHead(400, { "content-type": "application/json" });
|
|
687
1274
|
res.end(JSON.stringify({ git: false, error: { code: "bad-request", message: String(error?.message ?? error) } }));
|
|
@@ -757,9 +1344,27 @@ export {
|
|
|
757
1344
|
operationMarker,
|
|
758
1345
|
outerGitDir,
|
|
759
1346
|
parseStatusV2,
|
|
1347
|
+
parseWorktreeList,
|
|
1348
|
+
readWorktrees,
|
|
1349
|
+
worktreesFor,
|
|
1350
|
+
runCli,
|
|
760
1351
|
runGit,
|
|
761
1352
|
gitStatus,
|
|
1353
|
+
gitStatusUncached,
|
|
762
1354
|
resolveWorkspace,
|
|
1355
|
+
effectiveTarget,
|
|
1356
|
+
readPrStatus,
|
|
1357
|
+
summarizePr,
|
|
1358
|
+
summarizeChecks,
|
|
1359
|
+
prStatusFor,
|
|
1360
|
+
prState,
|
|
1361
|
+
readOpenPrs,
|
|
1362
|
+
openPrsFor,
|
|
1363
|
+
prListState,
|
|
1364
|
+
selectSessionWorktree,
|
|
1365
|
+
selectWorktreeWatch,
|
|
1366
|
+
selectedWorktrees,
|
|
1367
|
+
statusInFlight,
|
|
763
1368
|
watchWorkspace,
|
|
764
1369
|
unwatchWorkspace,
|
|
765
1370
|
syncWatchers,
|