dsh-git-badge 0.19.0 → 0.20.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 +149 -0
- package/README.md +15 -7
- package/lib/client.js +826 -722
- package/lib/index.js +272 -176
- package/package.json +5 -11
- package/seam/checkout.js +1 -1
- package/seam/cli.js +31 -0
- package/seam/skill.js +3 -3
- package/seam/store.js +15 -74
- package/skill/gh/SKILL.md +20 -7
- package/seam/anchors.js +0 -105
- package/seam/apply.js +0 -243
- package/seam/patch.js +0 -181
- package/seam/postinstall.js +0 -82
- package/seam/stub-index.js +0 -11
package/lib/index.js
CHANGED
|
@@ -69,7 +69,8 @@
|
|
|
69
69
|
import { execFile } from "node:child_process";
|
|
70
70
|
import { watch, existsSync, readFileSync } from "node:fs";
|
|
71
71
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
72
|
-
import { SESSION_CHECKOUT_TTL_MS,
|
|
72
|
+
import { SESSION_CHECKOUT_TTL_MS, readSessionCheckouts } from "../seam/store.js";
|
|
73
|
+
import { installSkill } from "../seam/skill.js";
|
|
73
74
|
|
|
74
75
|
const inject = ["webServer", "workspaceRegistry"];
|
|
75
76
|
const name = "dsh-git-badge";
|
|
@@ -145,19 +146,14 @@ const config = {
|
|
|
145
146
|
* timeout) is covered with no gh, no network and no forge.
|
|
146
147
|
*/
|
|
147
148
|
prRunner: null,
|
|
148
|
-
/**
|
|
149
|
-
* Bound on the repository-wide open-PR read (`gh pr list`). Only one page is
|
|
150
|
-
* read, so a repository with more open PRs than this simply has the excess
|
|
151
|
-
* invisible to worktree selection; the checkout's own PR read is unaffected.
|
|
152
|
-
*/
|
|
153
|
-
prListLimit: 50,
|
|
154
149
|
/**
|
|
155
150
|
* Worktree-aware status: "auto" lets a SESSION-targeted badge follow the one
|
|
156
|
-
* linked worktree
|
|
157
|
-
* work in a worktree reports that tree instead of the main checkout its
|
|
158
|
-
* directory names;
|
|
159
|
-
*
|
|
160
|
-
*
|
|
151
|
+
* linked worktree the session registered for itself, so a conversation doing
|
|
152
|
+
* its work in a worktree reports that tree instead of the main checkout its
|
|
153
|
+
* directory names; the plugin never guesses a worktree from pull requests;
|
|
154
|
+
* "off" always reports the session's own directory. A workspace-targeted row
|
|
155
|
+
* never follows a worktree whatever this is set to — a row surveys the
|
|
156
|
+
* checkout the registry owns. See effectiveTarget.
|
|
161
157
|
*/
|
|
162
158
|
worktreeStatus: "auto"
|
|
163
159
|
};
|
|
@@ -255,6 +251,15 @@ function singleFlight(store, key, { ttlMs = 0, keepValue = false, serve }, run)
|
|
|
255
251
|
*/
|
|
256
252
|
const defaultBranchState = new Map();
|
|
257
253
|
|
|
254
|
+
/** The first existing of origin/main and origin/master, or null. */
|
|
255
|
+
async function originBaseName(toplevel) {
|
|
256
|
+
for (const candidate of ["main", "master"]) {
|
|
257
|
+
const probe = await runGit(toplevel, ["rev-parse", "--verify", "--quiet", "refs/remotes/origin/" + candidate]);
|
|
258
|
+
if (probe.stdout !== null && probe.stdout.trim() !== "") return candidate;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
258
263
|
/** The cached default branch, kicking a refresh when stale. Never throws. */
|
|
259
264
|
function defaultBranchFor(toplevel, notify) {
|
|
260
265
|
return singleFlight(defaultBranchState, toplevel, {
|
|
@@ -274,10 +279,8 @@ function defaultBranchFor(toplevel, notify) {
|
|
|
274
279
|
// back, network-free, to the same base branch this module already
|
|
275
280
|
// probes for its ahead/behind row. A repository whose default branch is
|
|
276
281
|
// neither main nor master still answers undefined, exactly as before.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (probe.stdout !== null && probe.stdout.trim() !== "") { name = candidate; break; }
|
|
280
|
-
}
|
|
282
|
+
const fallback = await originBaseName(toplevel);
|
|
283
|
+
if (fallback !== null) name = fallback;
|
|
281
284
|
}
|
|
282
285
|
const value = name === "" ? void 0 : name;
|
|
283
286
|
if (record.value !== value) notify?.();
|
|
@@ -285,9 +288,40 @@ function defaultBranchFor(toplevel, notify) {
|
|
|
285
288
|
});
|
|
286
289
|
}
|
|
287
290
|
|
|
291
|
+
/**
|
|
292
|
+
* toplevel+branch -> { ahead, behind } against the branch's BASE (origin/main,
|
|
293
|
+
* then origin/master). Cached and refreshed out of band, like the default branch.
|
|
294
|
+
*
|
|
295
|
+
* The branch's relation to its base used to be detail-only (`detail=1`, the chip's
|
|
296
|
+
* hover card). The IDLE icon's colour needs it on EVERY response: green when no
|
|
297
|
+
* commit is ahead of the base, amber when some are. Reading it in only one path
|
|
298
|
+
* made the chip and the row say green while the card said amber — the same
|
|
299
|
+
* checkout, two answers. A rev-list per refresh is too expensive on the hot path,
|
|
300
|
+
* so the first read may answer without the relation and the next one carries it.
|
|
301
|
+
*/
|
|
302
|
+
const baseRelationState = new Map();
|
|
303
|
+
|
|
304
|
+
/** The cached branch-vs-base relation, kicking a refresh when stale. Never throws. */
|
|
305
|
+
function baseRelationFor(toplevel, branch, notify) {
|
|
306
|
+
return singleFlight(baseRelationState, toplevel + "\u0000" + branch, {
|
|
307
|
+
ttlMs: config.defaultBranchTtlMs,
|
|
308
|
+
keepValue: true,
|
|
309
|
+
serve: (state) => state.value
|
|
310
|
+
}, async (record) => {
|
|
311
|
+
const candidate = await originBaseName(toplevel);
|
|
312
|
+
if (candidate === null) return void 0;
|
|
313
|
+
const base = "origin/" + candidate;
|
|
314
|
+
const out = await runGit(toplevel, ["rev-list", "--count", "--left-right", base + "...HEAD"]);
|
|
315
|
+
const [behind, ahead] = (out.stdout ?? "").trim().split("\t").map((v) => Number.parseInt(v, 10));
|
|
316
|
+
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return void 0;
|
|
317
|
+
const value = { ahead, behind, base: candidate };
|
|
318
|
+
if (record.value?.ahead !== value.ahead || record.value?.behind !== value.behind) notify?.();
|
|
319
|
+
return value;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
288
323
|
/** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
|
|
289
324
|
const fetchState = new Map();
|
|
290
|
-
|
|
291
325
|
/**
|
|
292
326
|
* Refresh the remote-tracking refs for toplevel, bounded by TTL. Resolves
|
|
293
327
|
* true only when a fetch actually ran and succeeded; every failure mode
|
|
@@ -344,10 +378,6 @@ async function maybeFetch(toplevel) {
|
|
|
344
378
|
/** toplevel -> { lastAttemptAt, inFlight, value }; `value` undefined = nothing to say. */
|
|
345
379
|
const prState = new Map();
|
|
346
380
|
|
|
347
|
-
/** Test seam: the CLI invoker `gh` calls go through, so no test needs `gh`. */
|
|
348
|
-
function runPrCli(cmd, args, opts) {
|
|
349
|
-
return (config.prRunner ?? runCli)(cmd, args, opts);
|
|
350
|
-
}
|
|
351
381
|
|
|
352
382
|
/**
|
|
353
383
|
* Does `origin` point at github.com? A cheap LOCAL call, so a non-GitHub repo
|
|
@@ -372,13 +402,11 @@ async function originIsGitHub(toplevel) {
|
|
|
372
402
|
* an otherwise all-successful set.
|
|
373
403
|
*/
|
|
374
404
|
function summarizeChecks(checks) {
|
|
375
|
-
let sawAny = false;
|
|
376
405
|
let sawPending = false;
|
|
377
406
|
for (const check of checks) {
|
|
378
407
|
const state = String(check?.state ?? "").toUpperCase();
|
|
379
408
|
const status = String(check?.status ?? "").toUpperCase();
|
|
380
409
|
const conclusion = String(check?.conclusion ?? "").toUpperCase();
|
|
381
|
-
sawAny = true;
|
|
382
410
|
const failed =
|
|
383
411
|
["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(state) ||
|
|
384
412
|
["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion);
|
|
@@ -386,7 +414,7 @@ function summarizeChecks(checks) {
|
|
|
386
414
|
// unfinished: a CheckRun that has not completed, or a StatusContext PENDING
|
|
387
415
|
if (state === "PENDING" || (status !== "" && status !== "COMPLETED")) sawPending = true;
|
|
388
416
|
}
|
|
389
|
-
if (
|
|
417
|
+
if (checks.length === 0) return "none";
|
|
390
418
|
return sawPending ? "pending" : "passing";
|
|
391
419
|
}
|
|
392
420
|
|
|
@@ -409,9 +437,9 @@ function summarizePr(json) {
|
|
|
409
437
|
// GitHub's OWN merge verdict (CLEAN / BLOCKED / BEHIND / DIRTY / DRAFT /
|
|
410
438
|
// UNSTABLE / HAS_HOOKS / UNKNOWN), passed through rather than re-derived here:
|
|
411
439
|
// whether a PR is mergeable depends on branch protection and required reviews,
|
|
412
|
-
// which is the forge's business and not this plugin's to interpret.
|
|
413
|
-
//
|
|
414
|
-
//
|
|
440
|
+
// which is the forge's business and not this plugin's to interpret. It is
|
|
441
|
+
// omitted when `gh` did not report it — absence is the contract, as
|
|
442
|
+
// everywhere else in this half.
|
|
415
443
|
if (typeof json.mergeStateStatus === "string" && json.mergeStateStatus !== "") pr.mergeState = json.mergeStateStatus.toUpperCase();
|
|
416
444
|
// The chip links the token to the PR, so `url` is the one field that becomes an
|
|
417
445
|
// `href`: only http(s) is emitted, and anything else is omitted like every
|
|
@@ -437,7 +465,7 @@ async function readPrStatus(toplevel, branch) {
|
|
|
437
465
|
// a detached HEAD is not a branch `gh` can resolve a PR for
|
|
438
466
|
if (typeof branch !== "string" || branch === "" || branch.startsWith("HEAD")) return void 0;
|
|
439
467
|
if (!(await originIsGitHub(toplevel))) return void 0;
|
|
440
|
-
const out = await
|
|
468
|
+
const out = await (config.prRunner ?? runCli)("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url,mergeStateStatus"], {
|
|
441
469
|
cwd: toplevel,
|
|
442
470
|
timeout: config.prTimeoutMs,
|
|
443
471
|
// GH_PROMPT_DISABLED: an auth prompt must never hang the refresh (the gh
|
|
@@ -456,13 +484,6 @@ async function readPrStatus(toplevel, branch) {
|
|
|
456
484
|
return summarizePr(json);
|
|
457
485
|
}
|
|
458
486
|
|
|
459
|
-
|
|
460
|
-
/** Order-insensitive identity for an open-PR set, so "did it change?" is exact. */
|
|
461
|
-
function prListKey(byBranch) {
|
|
462
|
-
if (byBranch === void 0) return "";
|
|
463
|
-
return JSON.stringify([...byBranch].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
464
|
-
}
|
|
465
|
-
|
|
466
487
|
/**
|
|
467
488
|
* PR state for toplevel: the value to serve NOW (possibly undefined, possibly
|
|
468
489
|
* stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
|
|
@@ -546,22 +567,20 @@ function parseStatusV2(out) {
|
|
|
546
567
|
if (Number.isFinite(a)) ahead = a;
|
|
547
568
|
if (Number.isFinite(b)) behind = b;
|
|
548
569
|
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
549
|
-
// XY columns: X = index (staged), Y = worktree (unstaged)
|
|
570
|
+
// XY columns: X = index (staged), Y = worktree (unstaged). The path is
|
|
571
|
+
// the LAST field in both record shapes (space-separated, and the path
|
|
572
|
+
// itself may contain spaces), so it is computed once per record and
|
|
573
|
+
// shared by both columns — collected untruncated and capped by the
|
|
574
|
+
// caller, exactly like the untracked names.
|
|
575
|
+
const path = line.startsWith("2 ")
|
|
576
|
+
? line.split("\t").pop().trim()
|
|
577
|
+
: line.split(" ").slice(8).join(" ").trim();
|
|
550
578
|
if (line[2] !== ".") {
|
|
551
579
|
staged += 1;
|
|
552
|
-
const path = line.startsWith("2 ")
|
|
553
|
-
? line.split("\t").pop().trim()
|
|
554
|
-
: line.split(" ").slice(8).join(" ").trim();
|
|
555
580
|
if (path !== "") stagedNames.push(path);
|
|
556
581
|
}
|
|
557
582
|
if (line[3] !== ".") {
|
|
558
583
|
unstaged += 1;
|
|
559
|
-
// the path is the LAST field in both records (space-separated, and
|
|
560
|
-
// the path itself may contain spaces) — collected untruncated and
|
|
561
|
-
// capped by the caller, exactly like the untracked names
|
|
562
|
-
const path = line.startsWith("2 ")
|
|
563
|
-
? line.split("\t").pop().trim()
|
|
564
|
-
: line.split(" ").slice(8).join(" ").trim();
|
|
565
584
|
if (path !== "") unstagedNames.push(path);
|
|
566
585
|
}
|
|
567
586
|
} else if (line.startsWith("u ")) unmerged += 1;
|
|
@@ -577,6 +596,14 @@ function parseStatusV2(out) {
|
|
|
577
596
|
return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked, untrackedNames, unstagedNames, stagedNames };
|
|
578
597
|
}
|
|
579
598
|
|
|
599
|
+
/** Parse a plain (unsigned) `log --format=%h%x09%s%x09%cr` body: hash, subject, relative time. */
|
|
600
|
+
function parseCommitLines(stdout) {
|
|
601
|
+
return stdout.trim().split("\n").map((line) => {
|
|
602
|
+
const [hash, subject, when] = line.split("\t");
|
|
603
|
+
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
604
|
+
}).filter((c) => c.hash !== void 0);
|
|
605
|
+
}
|
|
606
|
+
|
|
580
607
|
/**
|
|
581
608
|
* Parse `git worktree list --porcelain`. Blocks of `key value` lines separated by
|
|
582
609
|
* a blank line: `worktree <path>`, `HEAD <sha>`, `branch refs/heads/<name>` (the
|
|
@@ -702,13 +729,18 @@ const OPERATION_NEXT = {
|
|
|
702
729
|
* below (first match wins) is the default, and a user can override it by
|
|
703
730
|
* naming categories in `~/.dsh/git-badge-next.json`:
|
|
704
731
|
*
|
|
705
|
-
* { "order": ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"] }
|
|
732
|
+
* { "order": ["operation", "unmerged", "conflict", "review", "sync", "publish", "merge", "commit", "checks"] }
|
|
706
733
|
*
|
|
707
734
|
* Any subset in any order; categories not listed rank after the listed ones in
|
|
708
735
|
* default order, unknown names are ignored. Typically edited for you by the
|
|
709
736
|
* agent via `/gh order`.
|
|
737
|
+
*
|
|
738
|
+
* Migration note: `conflict` and `review` were added after an existing user's
|
|
739
|
+
* stored order was written. `normalizeOrder` appends the categories they did
|
|
740
|
+
* not list, so an old seven-category order ranks the two last until the user
|
|
741
|
+
* reorders them in Settings. Nothing re-ranks silently.
|
|
710
742
|
*/
|
|
711
|
-
const DEFAULT_NEXT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
|
|
743
|
+
const DEFAULT_NEXT_ORDER = ["operation", "unmerged", "conflict", "review", "sync", "publish", "merge", "commit", "checks"];
|
|
712
744
|
|
|
713
745
|
/** Normalise any order list: known categories first, then the rest in default order. */
|
|
714
746
|
function normalizeOrder(order) {
|
|
@@ -763,11 +795,6 @@ function seedOrder() {
|
|
|
763
795
|
currentOrder = legacyOrderFile();
|
|
764
796
|
}
|
|
765
797
|
|
|
766
|
-
/** The ranking order every status read uses. */
|
|
767
|
-
function nextOrder() {
|
|
768
|
-
return currentOrder;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
798
|
/**
|
|
772
799
|
* The hover card's "action" row and the (+) menu's picker: every sub-action the
|
|
773
800
|
* checkout justifies, each as a `gh`-skill invocation ({ args, why, what } —
|
|
@@ -776,7 +803,7 @@ function nextOrder() {
|
|
|
776
803
|
* invocations, so it rides the base response.
|
|
777
804
|
*
|
|
778
805
|
* Ranking: rule candidates are collected with a category, then sorted by the
|
|
779
|
-
* configured order (
|
|
806
|
+
* configured order (currentOrder) — NOT first-match — so a configuration can, for
|
|
780
807
|
* example, rank "commit" above "sync" without touching code. Ties keep
|
|
781
808
|
* collection order.
|
|
782
809
|
*
|
|
@@ -791,7 +818,7 @@ function nextOrder() {
|
|
|
791
818
|
* Clean, synced, nothing failing → null: the row is omitted entirely, because
|
|
792
819
|
* "no suggestion" is also a suggestion.
|
|
793
820
|
*
|
|
794
|
-
* @returns {{ args: string, command: string|undefined, why: string, what: string } | null}
|
|
821
|
+
* @returns {{ args: string|null, cat: string, command: string|undefined, why: string, what: string, token: string|null, severity: string|null } | null}
|
|
795
822
|
*/
|
|
796
823
|
function nextStepRules(info) {
|
|
797
824
|
if (info === void 0 || info === null || info.git !== true) return null;
|
|
@@ -804,7 +831,13 @@ function nextStepRules(info) {
|
|
|
804
831
|
const untracked = info.untrackedFiles || 0;
|
|
805
832
|
const dirty = staged + unstaged + untracked;
|
|
806
833
|
const rules = [];
|
|
807
|
-
|
|
834
|
+
// `args` is the `/gh` invocation, or null for a ROW-ONLY rule the `gh` skill
|
|
835
|
+
// has no verb for (the picker must not offer a verb that does not exist).
|
|
836
|
+
// `token` is the row's one-word imperative for the rule, or null.
|
|
837
|
+
// `severity` is the ONE colour role every surface paints the icon with:
|
|
838
|
+
// error | warn | success | business. The client holds no token-to-colour map,
|
|
839
|
+
// so the chip and the row cannot disagree about how loud a state is.
|
|
840
|
+
const add = (cat, args, why, what, command, token, severity) => rules.push({ cat, args, why, what, command, token, severity });
|
|
808
841
|
|
|
809
842
|
if (info.operation !== void 0 && info.operation !== null) {
|
|
810
843
|
const op = String(info.operation);
|
|
@@ -813,25 +846,57 @@ function nextStepRules(info) {
|
|
|
813
846
|
? `${unmerged} unmerged file${plural(unmerged)} blocking the paused ${op}`
|
|
814
847
|
: `a ${op} is paused mid-operation`,
|
|
815
848
|
`resume the paused ${op}`,
|
|
816
|
-
OPERATION_NEXT[op]
|
|
849
|
+
OPERATION_NEXT[op],
|
|
850
|
+
"resume",
|
|
851
|
+
"error");
|
|
817
852
|
}
|
|
818
853
|
if (unmerged > 0) {
|
|
819
|
-
add("unmerged", "next", `${unmerged} unmerged file${plural(unmerged)} to resolve`, "resolve the unmerged files (with you)", "git status");
|
|
854
|
+
add("unmerged", "next", `${unmerged} unmerged file${plural(unmerged)} to resolve`, "resolve the unmerged files (with you)", "git status", "resolve", "error");
|
|
820
855
|
}
|
|
821
856
|
if (behind > 0 && ahead > 0) {
|
|
822
|
-
add("sync", "sync", `${ahead} ahead, ${behind} behind — diverged`, "rebase your commits onto upstream and push (asks before any force)");
|
|
857
|
+
add("sync", "sync", `${ahead} ahead, ${behind} behind — diverged`, "rebase your commits onto upstream and push (asks before any force)", void 0, "sync", "business");
|
|
823
858
|
} else if (behind > 0) {
|
|
824
|
-
add("sync", "pull", `${behind} behind ${info.upstream ?? "upstream"}`, "update this branch from upstream", "git pull --ff-only");
|
|
859
|
+
add("sync", "pull", `${behind} behind ${info.upstream ?? "upstream"}`, "update this branch from upstream", "git pull --ff-only", "pull", "business");
|
|
825
860
|
}
|
|
861
|
+
// The upstream is `<remote>/<branch>`. A branch created from `origin/main`
|
|
862
|
+
// tracks origin/main, and a bare `git push` is refused there under the default
|
|
863
|
+
// `push.default=simple`, because the upstream branch name does not match the
|
|
864
|
+
// local branch name. Treat that as "publish under its own name", which pushes
|
|
865
|
+
// AND repairs the tracking in one step.
|
|
866
|
+
const upstreamSlash = typeof info.upstream === "string" ? info.upstream.indexOf("/") : -1;
|
|
867
|
+
const upstreamRemote = upstreamSlash > 0 ? info.upstream.slice(0, upstreamSlash) : "origin";
|
|
868
|
+
const upstreamBranch = upstreamSlash > 0 ? info.upstream.slice(upstreamSlash + 1) : void 0;
|
|
869
|
+
const upstreamNamesOtherBranch = typeof info.branch === "string" && info.branch !== ""
|
|
870
|
+
&& upstreamBranch !== void 0 && upstreamBranch !== info.branch;
|
|
826
871
|
if (info.upstream === void 0 && dirty > 0) {
|
|
827
|
-
add("publish", "push", "no upstream configured", `publish ${info.branch} for the first time`, `git push -u origin ${info.branch}
|
|
872
|
+
add("publish", "push", "no upstream configured", `publish ${info.branch} for the first time`, `git push -u origin ${info.branch}`, "push", "business");
|
|
873
|
+
} else if (ahead > 0 && upstreamNamesOtherBranch) {
|
|
874
|
+
add("publish", "push", `${ahead} ahead of ${info.upstream} (tracks a different branch)`, `publish ${info.branch} under its own name and set its upstream`, `git push -u ${upstreamRemote} ${info.branch}`, "push", "business");
|
|
828
875
|
} else if (ahead > 0) {
|
|
829
|
-
add("publish", "push", `${ahead} ahead of ${info.upstream ?? "upstream"}`, "push local commits to the remote", "git push");
|
|
876
|
+
add("publish", "push", `${ahead} ahead of ${info.upstream ?? "upstream"}`, "push local commits to the remote", "git push", "push", "business");
|
|
830
877
|
}
|
|
831
878
|
const pr = info.pr === void 0 || info.pr === null ? void 0 : info.pr;
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
879
|
+
const hasPr = pr !== void 0 && pr.number !== void 0;
|
|
880
|
+
const openPr = hasPr && pr.open !== false;
|
|
881
|
+
// PR states no `/gh` verb covers: ROW-ONLY rules. They carry the row's token
|
|
882
|
+
// and `args: null`, so they triage on the row without the picker offering a
|
|
883
|
+
// verb the skill does not have. They ride the same ranking as everything
|
|
884
|
+
// else, so a row-only rule can sit above the first picker-eligible one.
|
|
885
|
+
if (openPr) {
|
|
886
|
+
if (pr.mergeState === "DIRTY") {
|
|
887
|
+
add("conflict", null, `pull request ${pr.number} conflicts with its base`, `resolve the conflict on pull request ${pr.number}`, void 0, "resolve", "error");
|
|
888
|
+
}
|
|
889
|
+
if (pr.review === "CHANGES_REQUESTED") {
|
|
890
|
+
add("review", null, `changes requested on pull request ${pr.number}`, `address the review on pull request ${pr.number}`, void 0, "review", "warn");
|
|
891
|
+
} else if (pr.review === "REVIEW_REQUIRED") {
|
|
892
|
+
add("review", null, `pull request ${pr.number} needs a review`, `get pull request ${pr.number} reviewed`, void 0, "review", "warn");
|
|
893
|
+
}
|
|
894
|
+
if (pr.mergeState === "BEHIND") {
|
|
895
|
+
add("sync", null, `pull request ${pr.number} is behind its base`, `update pull request ${pr.number} from its base`, void 0, "update", "business");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (openPr && (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED"))) {
|
|
899
|
+
add("merge", `merge ${pr.number}`, `pull request ${pr.number} is ready to merge`, `merge pull request ${pr.number} (squash)`, void 0, "merge", "success");
|
|
835
900
|
}
|
|
836
901
|
if (dirty > 0) {
|
|
837
902
|
const parts = [
|
|
@@ -844,10 +909,10 @@ function nextStepRules(info) {
|
|
|
844
909
|
: untracked > 0 && unstaged === 0
|
|
845
910
|
? "git add -A && git commit"
|
|
846
911
|
: "git add -p && git commit";
|
|
847
|
-
add("commit", "commit", `work to commit: ${parts}`, staged > 0 ? "commit the staged changes" : "stage and commit the working changes", command);
|
|
912
|
+
add("commit", "commit", `work to commit: ${parts}`, staged > 0 ? "commit the staged changes" : "stage and commit the working changes", command, "commit", "business");
|
|
848
913
|
}
|
|
849
|
-
if (
|
|
850
|
-
add("checks", `checks ${pr.number}`, `checks failing on #${pr.number}`, "watch the CI checks on pull request " + pr.number, `gh pr checks ${pr.number} --watch
|
|
914
|
+
if (hasPr && pr.state === "failing") {
|
|
915
|
+
add("checks", `checks ${pr.number}`, `checks failing on #${pr.number}`, "watch the CI checks on pull request " + pr.number, `gh pr checks ${pr.number} --watch`, "fix CI", "error");
|
|
851
916
|
}
|
|
852
917
|
|
|
853
918
|
// STANDING options: never ranked, never the primary suggestion — they are
|
|
@@ -856,7 +921,7 @@ function nextStepRules(info) {
|
|
|
856
921
|
// is not the default; `clean` last and flagged, because a destructive verb
|
|
857
922
|
// whose intent git status cannot reveal must never be suggested.
|
|
858
923
|
const standing = [];
|
|
859
|
-
if (
|
|
924
|
+
if (hasPr) {
|
|
860
925
|
standing.push({
|
|
861
926
|
args: `pr view ${pr.number}`,
|
|
862
927
|
why: `open pull request #${pr.number}`,
|
|
@@ -874,7 +939,7 @@ function nextStepRules(info) {
|
|
|
874
939
|
});
|
|
875
940
|
}
|
|
876
941
|
|
|
877
|
-
const order =
|
|
942
|
+
const order = currentOrder;
|
|
878
943
|
const rank = (cat) => {
|
|
879
944
|
const at = order.indexOf(cat);
|
|
880
945
|
return at === -1 ? order.length : at;
|
|
@@ -892,18 +957,31 @@ function nextStepRules(info) {
|
|
|
892
957
|
* extra into its action row, and `/gh pr` was offered on the default branch.
|
|
893
958
|
* One authority, one list, no reconciliation.
|
|
894
959
|
*
|
|
895
|
-
* Exactly one entry can be `primary` (the top RANKED rule
|
|
896
|
-
* a clean, synced checkout has actions (look at the PR,
|
|
897
|
-
* suggestion, because "no suggestion" is also a suggestion.
|
|
960
|
+
* Exactly one entry can be `primary` (the top RANKED rule that has a `/gh`
|
|
961
|
+
* verb) or none at all — a clean, synced checkout has actions (look at the PR,
|
|
962
|
+
* clean up) but no suggestion, because "no suggestion" is also a suggestion.
|
|
963
|
+
*
|
|
964
|
+
* `rowToken` is the row's own verdict: the first ranked rule with a non-null
|
|
965
|
+
* `token`. It may name a ROW-ONLY rule (a PR conflict, a requested review) that
|
|
966
|
+
* never enters `actions`, so the row triages while the chip and picker offer
|
|
967
|
+
* only real actions. `rowWhy` is that same rule's reason, so a surface with no
|
|
968
|
+
* room for the word (the leading glyph) can say WHY on hover. `rowSeverity` is
|
|
969
|
+
* the same rule's colour role, so every surface paints the icon the same way.
|
|
970
|
+
* With NO rule at all the three become the idle verdict: the token `in sync`,
|
|
971
|
+
* a reason, and a severity that is `success` when no commit is ahead of the base
|
|
972
|
+
* and `warn` when some are. Only a non-repository returns null.
|
|
898
973
|
*
|
|
899
|
-
* @returns {{ actions: Array<{args, why, what, command?, danger?, primary?}>, next: object|null }}
|
|
974
|
+
* @returns {{ actions: Array<{args, why, what, command?, danger?, primary?}>, next: object|null, rowToken: string|null, rowWhy: string|null, rowSeverity: string|null }}
|
|
900
975
|
*/
|
|
901
976
|
function nextActions(info) {
|
|
902
977
|
const built = nextStepRules(info);
|
|
903
|
-
if (built === null) return { actions: [], next: null };
|
|
978
|
+
if (built === null) return { actions: [], next: null, rowToken: null, rowWhy: null, rowSeverity: null };
|
|
904
979
|
const { ranked, standing } = built;
|
|
980
|
+
// Row-only rules carry `args: null` and are dropped here: the picker must
|
|
981
|
+
// never offer a `/gh` verb the skill does not have.
|
|
982
|
+
const offerable = ranked.filter((rule) => typeof rule.args === "string");
|
|
905
983
|
const actions = [
|
|
906
|
-
...
|
|
984
|
+
...offerable.map((rule, index) => ({
|
|
907
985
|
args: rule.args,
|
|
908
986
|
why: rule.why,
|
|
909
987
|
what: rule.what,
|
|
@@ -913,7 +991,86 @@ function nextActions(info) {
|
|
|
913
991
|
...standing
|
|
914
992
|
];
|
|
915
993
|
const primary = actions.find((action) => action.primary === true) ?? null;
|
|
916
|
-
|
|
994
|
+
// The winning row rule supplies the token, its reason AND its severity, so the
|
|
995
|
+
// three can never describe different rules.
|
|
996
|
+
const rowRule = ranked.find((rule) => rule.token !== null && rule.token !== void 0) ?? null;
|
|
997
|
+
// NO rule fired: the checkout is clean and nothing local needs doing. The WORD
|
|
998
|
+
// says only what is known — "in sync" — and the COLOUR says whether work is
|
|
999
|
+
// still unmerged. `mainAhead` counts commits on this branch that its base does
|
|
1000
|
+
// not have, so an amber disc means "clean, pushed, and not merged"; green means
|
|
1001
|
+
// the branch is the default branch, or its work is already in the base. This is
|
|
1002
|
+
// NOT a ranked rule and NOT a picker entry: no `/gh` verb covers it. A
|
|
1003
|
+
// non-repository still returns null (see above).
|
|
1004
|
+
const notMerged = info.mainAhead || 0;
|
|
1005
|
+
const idle = notMerged > 0
|
|
1006
|
+
? { why: notMerged + " commit" + (notMerged === 1 ? "" : "s") + " not merged", severity: "warn" }
|
|
1007
|
+
: { why: "clean and matching the remote", severity: "success" };
|
|
1008
|
+
return {
|
|
1009
|
+
actions,
|
|
1010
|
+
next: primary,
|
|
1011
|
+
rowToken: rowRule?.token ?? "in sync",
|
|
1012
|
+
rowWhy: rowRule?.why ?? idle.why,
|
|
1013
|
+
rowSeverity: rowRule?.severity ?? idle.severity
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The ✎n NAMES — the one part of the count the numbers cannot answer ("what are
|
|
1019
|
+
* these?"). Hover-gated with the rest of detail=1, so no refresh pays for it.
|
|
1020
|
+
* Posture: RELATIVE names only (git's own output, never absolute — AGENTS.md
|
|
1021
|
+
* rule 7 is relaxed by an inch, not a mile), shortened to the last two path
|
|
1022
|
+
* segments, capped at 20 with the total carried separately so the client can
|
|
1023
|
+
* say "… and k more" honestly. Staged and unstaged names are TRACKED files: the
|
|
1024
|
+
* collapsed untracked retry never affects them, so they ride regardless of
|
|
1025
|
+
* untrackedMode.
|
|
1026
|
+
*/
|
|
1027
|
+
function attachDetail(info, parsed, untrackedMode) {
|
|
1028
|
+
const capped = (names) => names.slice(0, 20).map(shortenPath);
|
|
1029
|
+
if (untrackedMode === "all" && parsed.untrackedNames.length > 0) {
|
|
1030
|
+
info.untrackedNames = capped(parsed.untrackedNames);
|
|
1031
|
+
info.untrackedNamesTotal = parsed.untrackedNames.length;
|
|
1032
|
+
}
|
|
1033
|
+
if (parsed.unstagedNames.length > 0) {
|
|
1034
|
+
info.unstagedNames = capped(parsed.unstagedNames);
|
|
1035
|
+
info.unstagedNamesTotal = parsed.unstagedNames.length;
|
|
1036
|
+
}
|
|
1037
|
+
if (parsed.stagedNames.length > 0) {
|
|
1038
|
+
info.stagedNames = capped(parsed.stagedNames);
|
|
1039
|
+
info.stagedNamesTotal = parsed.stagedNames.length;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* The signed commit list: mixed newest-first from base...HEAD, each entry signed
|
|
1045
|
+
* "+" (only on this branch - the PR's content) or "−" (only on main - what a
|
|
1046
|
+
* rebase/merge brings in), git's own left-right verdict mapped onto the diff
|
|
1047
|
+
* convention. Capped at 10 with the total from the same base so "... and k more"
|
|
1048
|
+
* counts both sides. Called only when a base ref resolved; without one the
|
|
1049
|
+
* caller serves the plain HEAD log instead.
|
|
1050
|
+
*/
|
|
1051
|
+
async function attachBranchCommits(info, toplevel, baseRef, logHead) {
|
|
1052
|
+
if (baseRef !== null) {
|
|
1053
|
+
const signedOut = await runGit(toplevel, ["log", "--left-right", "-10", "--format=%m%x09%h%x09%s%x09%cr", baseRef + "...HEAD"]);
|
|
1054
|
+
if (signedOut.stdout !== null && signedOut.stdout.trim() !== "") {
|
|
1055
|
+
info.branchCommits = signedOut.stdout.trim().split("\n").map((line) => {
|
|
1056
|
+
const [marker, hash, subject, when] = line.split("\t");
|
|
1057
|
+
return {
|
|
1058
|
+
sign: marker === ">" ? "+" : marker === "<" ? "−" : void 0,
|
|
1059
|
+
hash,
|
|
1060
|
+
subject: subject ?? "",
|
|
1061
|
+
when: when ?? ""
|
|
1062
|
+
};
|
|
1063
|
+
}).filter((c) => c.hash !== void 0);
|
|
1064
|
+
const totalOut = await runGit(toplevel, ["rev-list", "--count", baseRef + "...HEAD"]);
|
|
1065
|
+
const total = Number.parseInt((totalOut.stdout ?? "").trim(), 10);
|
|
1066
|
+
if (Number.isFinite(total)) info.branchCommitsTotal = total;
|
|
1067
|
+
}
|
|
1068
|
+
} else {
|
|
1069
|
+
// already fetched in the batch above — no second invocation
|
|
1070
|
+
if (logHead.stdout !== null && logHead.stdout.trim() !== "") {
|
|
1071
|
+
info.branchCommits = parseCommitLines(logHead.stdout);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
917
1074
|
}
|
|
918
1075
|
|
|
919
1076
|
/**
|
|
@@ -1022,31 +1179,24 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1022
1179
|
// band: the flag may be absent on the very first read, present on the next.
|
|
1023
1180
|
const defaultBranch = defaultBranchFor(toplevel, notifyChange);
|
|
1024
1181
|
if (defaultBranch !== void 0 && defaultBranch === parsed.branch) info.defaultBranch = true;
|
|
1182
|
+
// The branch-vs-base relation rides EVERY response, because the IDLE icon's
|
|
1183
|
+
// COLOUR depends on it: green when no commit is ahead of the base, amber when
|
|
1184
|
+
// some are. It used to be detail-only, so the chip and the row said green while
|
|
1185
|
+
// the hover card said amber. Cached like the default branch, so no refresh pays
|
|
1186
|
+
// for a rev-list on the hot path; the first read may answer without it.
|
|
1187
|
+
const relation = baseRelationFor(toplevel, parsed.branch, notifyChange);
|
|
1188
|
+
if (relation !== void 0) {
|
|
1189
|
+
info.mainBranch = relation.base;
|
|
1190
|
+
if (relation.ahead > 0 || relation.behind > 0) {
|
|
1191
|
+
info.mainAhead = relation.ahead;
|
|
1192
|
+
info.mainBehind = relation.behind;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1025
1195
|
// Resolved inside the detail block; the PR-token hover below reuses it, so it
|
|
1026
1196
|
// is declared here rather than scoped to that block.
|
|
1027
1197
|
let baseRef = null;
|
|
1028
1198
|
if (wantDetail) {
|
|
1029
|
-
|
|
1030
|
-
// ("what are these?"). Hover-gated with the rest of detail=1, so no
|
|
1031
|
-
// refresh pays for it. Posture: RELATIVE names only (git's own output,
|
|
1032
|
-
// never absolute — AGENTS.md rule 7 is relaxed by an inch, not a mile),
|
|
1033
|
-
// shortened to the last two path segments, capped at 20 with the total
|
|
1034
|
-
// carried separately so the client can say "… and k more" honestly.
|
|
1035
|
-
// Staged and unstaged names are TRACKED files: the collapsed untracked
|
|
1036
|
-
// retry never affects them, so they ride regardless of untrackedMode.
|
|
1037
|
-
const capped = (names) => names.slice(0, 20).map(shortenPath);
|
|
1038
|
-
if (untrackedMode === "all" && parsed.untrackedNames.length > 0) {
|
|
1039
|
-
info.untrackedNames = capped(parsed.untrackedNames);
|
|
1040
|
-
info.untrackedNamesTotal = parsed.untrackedNames.length;
|
|
1041
|
-
}
|
|
1042
|
-
if (parsed.unstagedNames.length > 0) {
|
|
1043
|
-
info.unstagedNames = capped(parsed.unstagedNames);
|
|
1044
|
-
info.unstagedNamesTotal = parsed.unstagedNames.length;
|
|
1045
|
-
}
|
|
1046
|
-
if (parsed.stagedNames.length > 0) {
|
|
1047
|
-
info.stagedNames = capped(parsed.stagedNames);
|
|
1048
|
-
info.stagedNamesTotal = parsed.stagedNames.length;
|
|
1049
|
-
}
|
|
1199
|
+
attachDetail(info, parsed, untrackedMode);
|
|
1050
1200
|
// The signed commit list and the counts-vs-main row: ahead/behind against origin/main (then
|
|
1051
1201
|
// origin/master) is the merge-state line — "3 ahead, 0 behind" reads as
|
|
1052
1202
|
// "this is the PR's content"; "0 ahead, 5 behind" as "stale, rebase
|
|
@@ -1086,6 +1236,7 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1086
1236
|
}
|
|
1087
1237
|
}
|
|
1088
1238
|
if (baseRef !== null) {
|
|
1239
|
+
info.mainBranch = baseRef.replace(/^origin\//u, "");
|
|
1089
1240
|
const lrOut = await runGit(toplevel, ["rev-list", "--count", "--left-right", baseRef + "...HEAD"]);
|
|
1090
1241
|
const [mBehind, mAhead] = (lrOut.stdout ?? "").trim().split("\t").map((v) => Number.parseInt(v, 10));
|
|
1091
1242
|
if (Number.isFinite(mBehind) && Number.isFinite(mAhead) && (mBehind > 0 || mAhead > 0)) {
|
|
@@ -1093,37 +1244,7 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1093
1244
|
info.mainBehind = mBehind;
|
|
1094
1245
|
}
|
|
1095
1246
|
}
|
|
1096
|
-
|
|
1097
|
-
// The signed commit list: mixed newest-first from base...HEAD, each
|
|
1098
|
-
// entry signed "+" (only on this branch - the PR's content) or "−"
|
|
1099
|
-
// (only on main - what a rebase/merge brings in), git's own left-right
|
|
1100
|
-
// verdict mapped onto the diff convention. Capped at 10 with the total
|
|
1101
|
-
// from the same base so "... and k more" counts both sides. Without a
|
|
1102
|
-
// default branch the list degrades to the plain last 10 on HEAD.
|
|
1103
|
-
const signedOut = await runGit(toplevel, ["log", "--left-right", "-10", "--format=%m%x09%h%x09%s%x09%cr", baseRef + "...HEAD"]);
|
|
1104
|
-
if (signedOut.stdout !== null && signedOut.stdout.trim() !== "") {
|
|
1105
|
-
info.branchCommits = signedOut.stdout.trim().split("\n").map((line) => {
|
|
1106
|
-
const [marker, hash, subject, when] = line.split("\t");
|
|
1107
|
-
return {
|
|
1108
|
-
sign: marker === ">" ? "+" : marker === "<" ? "−" : void 0,
|
|
1109
|
-
hash,
|
|
1110
|
-
subject: subject ?? "",
|
|
1111
|
-
when: when ?? ""
|
|
1112
|
-
};
|
|
1113
|
-
}).filter((c) => c.hash !== void 0);
|
|
1114
|
-
const totalOut = await runGit(toplevel, ["rev-list", "--count", baseRef + "...HEAD"]);
|
|
1115
|
-
const total = Number.parseInt((totalOut.stdout ?? "").trim(), 10);
|
|
1116
|
-
if (Number.isFinite(total)) info.branchCommitsTotal = total;
|
|
1117
|
-
}
|
|
1118
|
-
} else {
|
|
1119
|
-
// already fetched in the batch above — no second invocation
|
|
1120
|
-
if (logHead.stdout !== null && logHead.stdout.trim() !== "") {
|
|
1121
|
-
info.branchCommits = logHead.stdout.trim().split("\n").map((line) => {
|
|
1122
|
-
const [hash, subject, when] = line.split("\t");
|
|
1123
|
-
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
1124
|
-
}).filter((c) => c.hash !== void 0);
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1247
|
+
await attachBranchCommits(info, toplevel, baseRef, logHead);
|
|
1127
1248
|
if (stashOut.stdout !== null) {
|
|
1128
1249
|
const count = stashOut.stdout.split("\n").filter((l) => l.trim() !== "").length;
|
|
1129
1250
|
if (count > 0) info.stashCount = count;
|
|
@@ -1138,27 +1259,15 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1138
1259
|
const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
|
|
1139
1260
|
if (pr !== void 0) info.pr = pr;
|
|
1140
1261
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
// total is the ahead count already computed above (no second rev-list).
|
|
1147
|
-
const prOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", baseRef + "..HEAD"]);
|
|
1148
|
-
if (prOut.stdout !== null && prOut.stdout.trim() !== "") {
|
|
1149
|
-
info.prCommits = prOut.stdout.trim().split("\n").map((line) => {
|
|
1150
|
-
const [hash, subject, when] = line.split("\t");
|
|
1151
|
-
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
1152
|
-
}).filter((c) => c.hash !== void 0);
|
|
1153
|
-
info.prCommitsTotal = info.mainAhead;
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
// The ranked suggestion rides EVERY response (it is pure derivation, and a
|
|
1157
|
-
// sidebar row may one day use it instead of its own copy of these rules);
|
|
1158
|
-
// the full action list is sent only for `pr=1`, the surfaces that have a
|
|
1159
|
-
// picker, so a row's payload stays lean.
|
|
1160
|
-
const { actions, next } = nextActions(info);
|
|
1262
|
+
// The ranked suggestion AND the row's token ride EVERY response (both are
|
|
1263
|
+
// pure derivation, and the sidebar row now reads the node half's token
|
|
1264
|
+
// instead of re-deriving it); the full action list is sent only for `pr=1`,
|
|
1265
|
+
// the surfaces that have a picker, so a row's payload stays lean.
|
|
1266
|
+
const { actions, next, rowToken, rowWhy, rowSeverity } = nextActions(info);
|
|
1161
1267
|
if (next !== null) info.next = next;
|
|
1268
|
+
if (rowToken !== null) info.rowToken = rowToken;
|
|
1269
|
+
if (rowWhy !== null) info.rowTokenWhy = rowWhy;
|
|
1270
|
+
if (rowSeverity !== null) info.rowSeverity = rowSeverity;
|
|
1162
1271
|
if (wantPr && actions.length > 0) info.actions = actions;
|
|
1163
1272
|
return info;
|
|
1164
1273
|
}
|
|
@@ -1237,7 +1346,7 @@ function closeWatchers(record) {
|
|
|
1237
1346
|
}
|
|
1238
1347
|
}
|
|
1239
1348
|
record.watcher = null;
|
|
1240
|
-
|
|
1349
|
+
record.extra = null;
|
|
1241
1350
|
}
|
|
1242
1351
|
|
|
1243
1352
|
function unwatchWorkspace(key) {
|
|
@@ -1456,12 +1565,16 @@ function selectWorktreeWatch(ownerPath, workspaceId, chosenPath) {
|
|
|
1456
1565
|
selectedWorktrees.set(ownerPath, { worktreePath: chosenPath, workspaceId, owned });
|
|
1457
1566
|
}
|
|
1458
1567
|
|
|
1568
|
+
/** The registry entities whose public surface carries a string `path`. */
|
|
1569
|
+
function listEntities(ctx) {
|
|
1570
|
+
return ctx.workspaceRegistry.list().filter((entity) => typeof entity?.path === "string");
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1459
1573
|
/** Reconcile the watcher set with the current workspace registry. */
|
|
1460
1574
|
function syncWatchers(ctx) {
|
|
1461
1575
|
const wanted = new Set();
|
|
1462
|
-
for (const entity of ctx
|
|
1576
|
+
for (const entity of listEntities(ctx)) {
|
|
1463
1577
|
// `path` and `id` are the entity's public surface; its `record` is private
|
|
1464
|
-
if (typeof entity?.path !== "string") continue;
|
|
1465
1578
|
wanted.add(entity.path);
|
|
1466
1579
|
watchWorkspace(entity.path, entity.path, entity.id === void 0 ? void 0 : String(entity.id));
|
|
1467
1580
|
}
|
|
@@ -1503,10 +1616,7 @@ async function resolveWorkspace(ctx, params) {
|
|
|
1503
1616
|
if (workspaceId === "" && sessionId === "") {
|
|
1504
1617
|
return { status: 400, error: { code: "target-required", message: "pass workspace=<id> or session=<id>" } };
|
|
1505
1618
|
}
|
|
1506
|
-
const entities =
|
|
1507
|
-
for (const entity of ctx.workspaceRegistry.list()) {
|
|
1508
|
-
if (typeof entity?.path === "string") entities.push(entity);
|
|
1509
|
-
}
|
|
1619
|
+
const entities = listEntities(ctx);
|
|
1510
1620
|
if (workspaceId !== "") {
|
|
1511
1621
|
const entity = entities.find((candidate) => String(candidate.id) === workspaceId);
|
|
1512
1622
|
if (entity === void 0) {
|
|
@@ -1547,9 +1657,7 @@ async function resolveWorkspace(ctx, params) {
|
|
|
1547
1657
|
*/
|
|
1548
1658
|
function sessionCheckouts() {
|
|
1549
1659
|
// The store owns the file's shape; the test seam only redirects the path.
|
|
1550
|
-
return config.sessionCheckoutsFile
|
|
1551
|
-
? readSessionCheckouts()
|
|
1552
|
-
: readSessionCheckouts(config.sessionCheckoutsFile);
|
|
1660
|
+
return readSessionCheckouts(config.sessionCheckoutsFile ?? undefined);
|
|
1553
1661
|
}
|
|
1554
1662
|
|
|
1555
1663
|
async function registeredCheckout(target, sessionId) {
|
|
@@ -1601,6 +1709,11 @@ async function effectiveTarget(target, sessionId) {
|
|
|
1601
1709
|
|
|
1602
1710
|
/** Host plugin body — register the status route and the SSE change feed. */
|
|
1603
1711
|
function apply(ctx) {
|
|
1712
|
+
// The /gh skill must exist in the user's skills catalog for the commands
|
|
1713
|
+
// menu to find it. Installing it here (not only from a CLI run) means a
|
|
1714
|
+
// market install gets /gh on the next boot. installSkill() never throws: it
|
|
1715
|
+
// returns a reason string, so a read-only home degrades to a console line.
|
|
1716
|
+
console.log(`[dsh-git-badge] gh skill: ${installSkill()}`);
|
|
1604
1717
|
// The ranking order lives in this plugin's own settings namespace: declared
|
|
1605
1718
|
// with a schema so the host validates it, stored in the user's settings
|
|
1606
1719
|
// document, and observed live — a change applies to the next status read
|
|
@@ -1635,11 +1748,6 @@ function apply(ctx) {
|
|
|
1635
1748
|
console.error("[dsh-git-badge] settings namespace unavailable, using the legacy order file:", error);
|
|
1636
1749
|
}
|
|
1637
1750
|
}
|
|
1638
|
-
// A fresh boot composed the files on disk into the new bundles — any
|
|
1639
|
-
// restart-pending marker was written by an install/apply against the PREVIOUS
|
|
1640
|
-
// boot and its change is now live. Clearing here is what makes the marker
|
|
1641
|
-
// self-expiring: it survives exactly until the restart it asks for happens.
|
|
1642
|
-
clearRestartMarker();
|
|
1643
1751
|
ctx.effect(() => ctx.webServer.register({
|
|
1644
1752
|
kind: "exact",
|
|
1645
1753
|
path: "/api/git-badge",
|
|
@@ -1652,7 +1760,6 @@ function apply(ctx) {
|
|
|
1652
1760
|
res.end(JSON.stringify({ git: false, error: target.error }));
|
|
1653
1761
|
return;
|
|
1654
1762
|
}
|
|
1655
|
-
const pending = readRestartMarker();
|
|
1656
1763
|
const detail = url.searchParams.get("detail") === "1";
|
|
1657
1764
|
const pr = url.searchParams.get("pr") === "1";
|
|
1658
1765
|
// WHO asked decides whether a linked worktree may stand in for the
|
|
@@ -1702,12 +1809,6 @@ function apply(ctx) {
|
|
|
1702
1809
|
// tell whether an event belongs to it — without this the input chip
|
|
1703
1810
|
// only ever refreshed on remount or the 60s poll.
|
|
1704
1811
|
res.writeHead(200, { "content-type": "application/json" });
|
|
1705
|
-
if (pending !== null) {
|
|
1706
|
-
// dshmarket cannot see postinstall/apply host-file changes; this
|
|
1707
|
-
// flag is how the running UI learns a restart is pending and
|
|
1708
|
-
// offers its own Restart button (dshmarket v1 restart endpoint).
|
|
1709
|
-
body.seamRestartPending = { by: pending.by, at: pending.at };
|
|
1710
|
-
}
|
|
1711
1812
|
res.end(JSON.stringify(body));
|
|
1712
1813
|
} catch (error) {
|
|
1713
1814
|
res.writeHead(400, { "content-type": "application/json" });
|
|
@@ -1780,27 +1881,22 @@ export { apply, inject, name };
|
|
|
1780
1881
|
// what lets a node-half change be verified without a restart.
|
|
1781
1882
|
export {
|
|
1782
1883
|
config,
|
|
1783
|
-
OPERATION_MARKERS,
|
|
1784
|
-
operationMarker,
|
|
1785
1884
|
nextActions,
|
|
1885
|
+
normalizeOrder,
|
|
1786
1886
|
seedOrder,
|
|
1787
1887
|
outerGitDir,
|
|
1788
1888
|
parseStatusV2,
|
|
1789
1889
|
parseWorktreeList,
|
|
1790
1890
|
readWorktrees,
|
|
1791
|
-
worktreesFor,
|
|
1792
1891
|
runCli,
|
|
1793
1892
|
runGit,
|
|
1794
1893
|
gitStatus,
|
|
1795
|
-
gitStatusUncached,
|
|
1796
|
-
resolveWorkspace,
|
|
1797
1894
|
effectiveTarget,
|
|
1798
1895
|
readPrStatus,
|
|
1799
1896
|
summarizePr,
|
|
1800
1897
|
summarizeChecks,
|
|
1801
1898
|
prStatusFor,
|
|
1802
1899
|
prState,
|
|
1803
|
-
selectWorktreeWatch,
|
|
1804
1900
|
selectedWorktrees,
|
|
1805
1901
|
statusInFlight,
|
|
1806
1902
|
watchWorkspace,
|