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/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, clearRestartMarker, readRestartMarker, readSessionCheckouts } from "../seam/store.js";
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 whose branch has an open PR, so a conversation doing its
157
- * work in a worktree reports that tree instead of the main checkout its
158
- * directory names; "off" always reports the session's own directory. A
159
- * workspace-targeted row never follows a worktree whatever this is set to — a
160
- * row surveys the checkout the registry owns. See effectiveTarget.
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
- for (const candidate of ["main", "master"]) {
278
- const probe = await runGit(toplevel, ["rev-parse", "--verify", "--quiet", "refs/remotes/origin/" + candidate]);
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 (!sawAny) return "none";
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. `gh pr list`
413
- // does not ask for it, so a worktree-candidate entry simply has no `mergeState`
414
- // — absence is the contract, as everywhere else in this half.
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 runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url,mergeStateStatus"], {
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 (nextOrder) — NOT first-match — so a configuration can, for
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
- const add = (cat, args, why, what, command) => rules.push({ cat, args, why, what, command });
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
- if (pr !== void 0 && pr.number !== void 0 && pr.open !== false
833
- && (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED"))) {
834
- add("merge", `merge ${pr.number}`, `pull request ${pr.number} is ready to merge`, `merge pull request ${pr.number} (squash)`);
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 (pr !== void 0 && pr.number !== void 0 && pr.state === "failing") {
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 (pr !== void 0 && pr.number !== void 0) {
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 = nextOrder();
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) or none at all —
896
- * a clean, synced checkout has actions (look at the PR, clean up) but no
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
- ...ranked.map((rule, index) => ({
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
- return { actions, next: primary };
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
- // The ✎n NAMES — the one part of the count the numbers cannot answer
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
- if (baseRef !== null) {
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
- if (baseRef !== null && info.pr !== void 0 && info.pr !== null && info.pr.number !== void 0
1142
- && info.pr.open !== false && (info.mainAhead || 0) > 0) {
1143
- // The PR's OWN commits (base..HEAD) — the token's hover answers "what is in
1144
- // this pull request", so it lists exactly that: not the branch's recent
1145
- // history, nothing else. Hover-gated with the rest of detail=1, and the
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
- if ("extra" in record) record.extra = null;
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.workspaceRegistry.list()) {
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 === null
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,