dsh-git-badge 0.8.0 → 0.9.1
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 +102 -1
- package/README.md +9 -3
- package/lib/client.js +220 -100
- package/lib/index.js +395 -7
- package/package.json +9 -8
- package/seam/anchors.js +138 -0
- package/seam/apply.js +304 -0
- package/seam/stub-index.js +11 -0
package/lib/index.js
CHANGED
|
@@ -89,6 +89,15 @@ const config = {
|
|
|
89
89
|
fetchTtlMs: 60000,
|
|
90
90
|
/** fs-event burst collapse window */
|
|
91
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,
|
|
92
101
|
/** backoff before re-attempting a failed watcher (or a missing .git) */
|
|
93
102
|
watchRetryMs: 60000,
|
|
94
103
|
/**
|
|
@@ -126,7 +135,22 @@ const config = {
|
|
|
126
135
|
* one so every degradation path (no gh, logged out, no PR, garbage output,
|
|
127
136
|
* timeout) is covered with no gh, no network and no forge.
|
|
128
137
|
*/
|
|
129
|
-
prRunner: null
|
|
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"
|
|
130
154
|
};
|
|
131
155
|
|
|
132
156
|
/**
|
|
@@ -305,6 +329,13 @@ function summarizePr(json) {
|
|
|
305
329
|
// omitted, not present-and-undefined: absence is the contract for "nothing to
|
|
306
330
|
// say" throughout this half, so a consumer tests the key, never the value
|
|
307
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();
|
|
308
339
|
// The chip links the token to the PR, so `url` is the one field that becomes an
|
|
309
340
|
// `href`: only http(s) is emitted, and anything else is omitted like every
|
|
310
341
|
// other "nothing to say" field, so a payload value cannot reach an anchor as a
|
|
@@ -329,7 +360,7 @@ async function readPrStatus(toplevel, branch) {
|
|
|
329
360
|
// a detached HEAD is not a branch `gh` can resolve a PR for
|
|
330
361
|
if (typeof branch !== "string" || branch === "" || branch.startsWith("HEAD")) return void 0;
|
|
331
362
|
if (!(await originIsGitHub(toplevel))) return void 0;
|
|
332
|
-
const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url"], {
|
|
363
|
+
const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url,mergeStateStatus"], {
|
|
333
364
|
cwd: toplevel,
|
|
334
365
|
timeout: config.prTimeoutMs,
|
|
335
366
|
// GH_PROMPT_DISABLED: an auth prompt must never hang the refresh (the gh
|
|
@@ -348,16 +379,121 @@ async function readPrStatus(toplevel, branch) {
|
|
|
348
379
|
return summarizePr(json);
|
|
349
380
|
}
|
|
350
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
|
+
|
|
351
483
|
/**
|
|
352
484
|
* PR state for toplevel: the value to serve NOW (possibly undefined, possibly
|
|
353
485
|
* stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
|
|
354
486
|
* called only when the refreshed value CHANGES, so an unchanged answer costs one
|
|
355
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.
|
|
356
491
|
*/
|
|
357
492
|
function prStatusFor(toplevel, branch, notify) {
|
|
358
493
|
if (config.prStatus === "off") return void 0;
|
|
359
494
|
const now = Date.now();
|
|
360
|
-
const
|
|
495
|
+
const key = toplevel + "\u0000" + branch;
|
|
496
|
+
const state = prState.get(key);
|
|
361
497
|
if (state !== void 0) {
|
|
362
498
|
// Fresh, or a refresh is already running: serve what we have either way.
|
|
363
499
|
if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
|
|
@@ -373,7 +509,7 @@ function prStatusFor(toplevel, branch, notify) {
|
|
|
373
509
|
return value;
|
|
374
510
|
})();
|
|
375
511
|
record.inFlight = inFlight;
|
|
376
|
-
prState.set(
|
|
512
|
+
prState.set(key, record);
|
|
377
513
|
return record.value;
|
|
378
514
|
}
|
|
379
515
|
//#endregion
|
|
@@ -414,6 +550,86 @@ function parseStatusV2(out) {
|
|
|
414
550
|
return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
|
|
415
551
|
}
|
|
416
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
|
+
|
|
417
633
|
/**
|
|
418
634
|
* In-progress operation markers, in precedence order. Git drops one of these in
|
|
419
635
|
* the per-worktree git dir while an operation is paused; the first match names
|
|
@@ -464,7 +680,7 @@ function operationMarker(gitDir) {
|
|
|
464
680
|
* so neither is paid for by a sidebar row refresh: a row needs status and
|
|
465
681
|
* identity, nothing more.
|
|
466
682
|
*/
|
|
467
|
-
async function
|
|
683
|
+
async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
468
684
|
// one invocation answers both questions: the repository root (so a
|
|
469
685
|
// subdirectory workspace reports the whole repo) and the per-worktree git
|
|
470
686
|
// dir (where the operation markers above live)
|
|
@@ -573,6 +789,42 @@ async function gitStatus(dir, wantDetail, wantPr) {
|
|
|
573
789
|
return info;
|
|
574
790
|
}
|
|
575
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
|
+
|
|
576
828
|
//#region git-state watcher
|
|
577
829
|
/**
|
|
578
830
|
* One recursive watcher per workspace, plus a second one when the git dir lives
|
|
@@ -806,6 +1058,49 @@ function watchWorkspace(root, key, workspaceId) {
|
|
|
806
1058
|
}
|
|
807
1059
|
}
|
|
808
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
|
+
|
|
809
1104
|
/** Reconcile the watcher set with the current workspace registry. */
|
|
810
1105
|
function syncWatchers(ctx) {
|
|
811
1106
|
const wanted = new Set();
|
|
@@ -815,6 +1110,17 @@ function syncWatchers(ctx) {
|
|
|
815
1110
|
wanted.add(entity.path);
|
|
816
1111
|
watchWorkspace(entity.path, entity.path, entity.id === void 0 ? void 0 : String(entity.id));
|
|
817
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
|
+
}
|
|
818
1124
|
for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
|
|
819
1125
|
}
|
|
820
1126
|
//#endregion
|
|
@@ -870,6 +1176,46 @@ async function resolveWorkspace(ctx, params) {
|
|
|
870
1176
|
return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
|
|
871
1177
|
}
|
|
872
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
|
+
|
|
873
1219
|
/** Host plugin body — register the status route and the SSE change feed. */
|
|
874
1220
|
function apply(ctx) {
|
|
875
1221
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -886,13 +1232,43 @@ function apply(ctx) {
|
|
|
886
1232
|
}
|
|
887
1233
|
const detail = url.searchParams.get("detail") === "1";
|
|
888
1234
|
const pr = url.searchParams.get("pr") === "1";
|
|
889
|
-
|
|
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
|
+
}
|
|
890
1266
|
// Echo the resolved workspace id. SSE events identify the workspace,
|
|
891
1267
|
// not the session, so a session-targeted client has no other way to
|
|
892
1268
|
// tell whether an event belongs to it — without this the input chip
|
|
893
1269
|
// only ever refreshed on remount or the 60s poll.
|
|
894
1270
|
res.writeHead(200, { "content-type": "application/json" });
|
|
895
|
-
res.end(JSON.stringify(
|
|
1271
|
+
res.end(JSON.stringify(body));
|
|
896
1272
|
} catch (error) {
|
|
897
1273
|
res.writeHead(400, { "content-type": "application/json" });
|
|
898
1274
|
res.end(JSON.stringify({ git: false, error: { code: "bad-request", message: String(error?.message ?? error) } }));
|
|
@@ -968,15 +1344,27 @@ export {
|
|
|
968
1344
|
operationMarker,
|
|
969
1345
|
outerGitDir,
|
|
970
1346
|
parseStatusV2,
|
|
1347
|
+
parseWorktreeList,
|
|
1348
|
+
readWorktrees,
|
|
1349
|
+
worktreesFor,
|
|
971
1350
|
runCli,
|
|
972
1351
|
runGit,
|
|
973
1352
|
gitStatus,
|
|
1353
|
+
gitStatusUncached,
|
|
974
1354
|
resolveWorkspace,
|
|
1355
|
+
effectiveTarget,
|
|
975
1356
|
readPrStatus,
|
|
976
1357
|
summarizePr,
|
|
977
1358
|
summarizeChecks,
|
|
978
1359
|
prStatusFor,
|
|
979
1360
|
prState,
|
|
1361
|
+
readOpenPrs,
|
|
1362
|
+
openPrsFor,
|
|
1363
|
+
prListState,
|
|
1364
|
+
selectSessionWorktree,
|
|
1365
|
+
selectWorktreeWatch,
|
|
1366
|
+
selectedWorktrees,
|
|
1367
|
+
statusInFlight,
|
|
980
1368
|
watchWorkspace,
|
|
981
1369
|
unwatchWorkspace,
|
|
982
1370
|
syncWatchers,
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Git status badges for DeepSeek Harness: sidebar
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Git status badges for DeepSeek Harness: sidebar session rows (via the sidebar.workspaces.sessionRow seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dsh-git-badge-seam": "seam/apply.js"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./lib/index.js",
|
|
9
12
|
"./client": "./lib/client.js",
|
|
@@ -45,15 +48,13 @@
|
|
|
45
48
|
"files": [
|
|
46
49
|
"lib/index.js",
|
|
47
50
|
"lib/client.js",
|
|
51
|
+
"seam/apply.js",
|
|
52
|
+
"seam/anchors.js",
|
|
53
|
+
"seam/stub-index.js",
|
|
48
54
|
"cordis.patch.yml",
|
|
49
55
|
"package.json",
|
|
50
56
|
"README.md",
|
|
51
57
|
"CHANGELOG.md",
|
|
52
58
|
"LICENSE"
|
|
53
|
-
]
|
|
54
|
-
"peerDependencies": {
|
|
55
|
-
"@deepseek-ai/cordis": ">=4",
|
|
56
|
-
"@deepseek-ai/dsh-client-runtime": ">=0.1",
|
|
57
|
-
"react": ">=18"
|
|
58
|
-
}
|
|
59
|
+
]
|
|
59
60
|
}
|