dsh-git-badge 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -69,7 +69,7 @@
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 { clearRestartMarker, readRestartMarker } from "../seam/store.js";
72
+ import { SESSION_CHECKOUT_TTL_MS, clearRestartMarker, readRestartMarker, readSessionCheckouts } from "../seam/store.js";
73
73
 
74
74
  const inject = ["webServer", "workspaceRegistry"];
75
75
  const name = "dsh-git-badge";
@@ -82,6 +82,13 @@ const name = "dsh-git-badge";
82
82
  * single documented seam.
83
83
  */
84
84
  const config = {
85
+ /**
86
+ * Test seam: the session-checkout registrations file. Null (the default)
87
+ * resolves it under the plugin's data dir (SEAM_DATA_DIR-aware store.js).
88
+ */
89
+ sessionCheckoutsFile: null,
90
+ /** Test seam: resolves the schema lib the settings namespace is built from. */
91
+ settingsSchemaLoader: async () => (await import("@deepseek-ai/schemastery")).default,
85
92
  /** budget for every git invocation; an expired call is killed */
86
93
  gitTimeoutMs: 3000,
87
94
  /** network fetch budget; worst-case route latency, paid once per TTL */
@@ -380,43 +387,6 @@ async function readPrStatus(toplevel, branch) {
380
387
  return summarizePr(json);
381
388
  }
382
389
 
383
- /** toplevel -> { lastAttemptAt, inFlight, value: Map<branch, pr> }. */
384
- const prListState = new Map();
385
-
386
- /**
387
- * Every OPEN pull request in `toplevel`'s repository, keyed by its head branch.
388
- * ONE `gh pr list` answers the whole repository, which is what makes worktree
389
- * selection affordable: N worktrees cost one forge call, not N.
390
- *
391
- * Degrades exactly like readPrStatus, and for the same reason: a non-array body,
392
- * unparseable output, a logged-out `gh`, a non-GitHub `origin` and a timeout all
393
- * resolve an empty map. "No PRs" and "no forge" are the same answer here — both
394
- * mean no worktree can be shown to be the one the session is working in.
395
- */
396
- async function readOpenPrs(toplevel) {
397
- if (!(await originIsGitHub(toplevel))) return new Map();
398
- const out = await runPrCli("gh", ["pr", "list", "--state", "open", "--limit", String(config.prListLimit), "--json", "number,headRefName,state,isDraft,reviewDecision,statusCheckRollup,url"], {
399
- cwd: toplevel,
400
- timeout: config.prTimeoutMs,
401
- env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
402
- });
403
- if (out.stdout === null) return new Map();
404
- let json;
405
- try {
406
- json = JSON.parse(out.stdout);
407
- } catch {
408
- return new Map();
409
- }
410
- if (!Array.isArray(json)) return new Map();
411
- const byBranch = new Map();
412
- for (const row of json) {
413
- const branch = row?.headRefName;
414
- if (typeof branch !== "string" || branch === "") continue;
415
- const pr = summarizePr(row);
416
- if (pr !== void 0) byBranch.set(branch, pr);
417
- }
418
- return byBranch;
419
- }
420
390
 
421
391
  /** Order-insensitive identity for an open-PR set, so "did it change?" is exact. */
422
392
  function prListKey(byBranch) {
@@ -424,63 +394,6 @@ function prListKey(byBranch) {
424
394
  return JSON.stringify([...byBranch].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
425
395
  }
426
396
 
427
- /**
428
- * Open PRs for toplevel: the value to serve NOW (possibly undefined), spawning an
429
- * out-of-band refresh when the TTL has lapsed — the same shape and the same
430
- * reasoning as prStatusFor, because the forge is never route latency.
431
- *
432
- * `notify` fires only when the set CHANGES: a changed set can change which
433
- * worktree a session badge follows, so the client has to be told to refetch
434
- * rather than waiting for its 60s safety-net poll.
435
- */
436
- function openPrsFor(toplevel, notify) {
437
- if (config.prStatus === "off") return void 0;
438
- const now = Date.now();
439
- const state = prListState.get(toplevel);
440
- if (state !== void 0) {
441
- // Fresh, or a refresh is already running: serve what we have either way.
442
- if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
443
- }
444
- const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
445
- const inFlight = (async () => {
446
- const value = await readOpenPrs(toplevel).catch(() => void 0);
447
- if (value !== void 0) {
448
- const changed = prListKey(record.value) !== prListKey(value);
449
- record.value = value;
450
- if (changed) notify(toplevel);
451
- }
452
- record.lastAttemptAt = Date.now();
453
- record.inFlight = null;
454
- return value;
455
- })();
456
- record.inFlight = inFlight;
457
- prListState.set(toplevel, record);
458
- return record.value;
459
- }
460
-
461
- /**
462
- * The one linked worktree a session badge may follow: exactly one entry other
463
- * than the checkout itself that is checked out on a branch with an OPEN PR.
464
- *
465
- * Exactly one, because the plugin cannot know which tree a session uses — DSH
466
- * records no session→worktree link at all — so an ambiguous repository stays on
467
- * its own checkout rather than guessing. A second open PR is a reason to say
468
- * nothing, not a reason to pick one. Detached, bare and prunable entries are
469
- * never candidates: they have no branch a PR could belong to.
470
- */
471
- function selectSessionWorktree(worktrees, openPrs, checkoutPath) {
472
- if (openPrs === void 0 || openPrs.size === 0) return void 0;
473
- const candidates = worktrees.filter((wt) =>
474
- wt.path !== checkoutPath &&
475
- wt.detached !== true &&
476
- wt.bare !== true &&
477
- wt.prunable !== true &&
478
- typeof wt.branch === "string" &&
479
- openPrs.has(wt.branch)
480
- );
481
- return candidates.length === 1 ? candidates[0] : void 0;
482
- }
483
-
484
397
  /**
485
398
  * PR state for toplevel: the value to serve NOW (possibly undefined, possibly
486
399
  * stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
@@ -522,6 +435,32 @@ function prStatusFor(toplevel, branch, notify) {
522
435
  * two-column XY status (X = index/staged, Y = worktree), `u` unmerged,
523
436
  * `?` untracked.
524
437
  */
438
+ /** Relative path shortened to its last two segments — the hover-name contract. */
439
+ function shortenPath(name) {
440
+ const parts = name.split("/");
441
+ return parts.length <= 2 ? name : parts.slice(-2).join("/");
442
+ }
443
+
444
+ /**
445
+ * Convert a remote URL to the hosted repo's WEB root: https stays https,
446
+ * `git@host:path` ssh syntax converts, a `.git` suffix is stripped. Null when
447
+ * the URL is neither form — the caller decides what a null is worth.
448
+ */
449
+ function remoteToWebUrl(raw) {
450
+ const trimmed = raw.trim().replace(/\.git$/u, "");
451
+ const https = trimmed.match(/^https:\/\/([^/]+)\/(.+?)\/?$/u);
452
+ const ssh = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
453
+ const web = https !== null ? https[1] + "/" + https[2] : ssh !== null ? ssh[1] + "/" + ssh[2] : null;
454
+ if (web === null) return null;
455
+ try {
456
+ const url = new URL("https://" + web);
457
+ if (url.protocol === "https:" && url.pathname.length > 1) return url.origin + url.pathname.replace(/\/$/u, "");
458
+ } catch {
459
+ // not a URL we can vouch for
460
+ }
461
+ return null;
462
+ }
463
+
525
464
  function parseStatusV2(out) {
526
465
  let branch = "HEAD (detached)";
527
466
  let upstream;
@@ -531,6 +470,9 @@ function parseStatusV2(out) {
531
470
  let unstaged = 0;
532
471
  let unmerged = 0;
533
472
  let untracked = 0;
473
+ const untrackedNames = [];
474
+ const unstagedNames = [];
475
+ const stagedNames = [];
534
476
  for (const line of out.split("\n")) {
535
477
  if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
536
478
  else if (line.startsWith("# branch.upstream ")) upstream = line.slice(18).trim();
@@ -543,12 +485,34 @@ function parseStatusV2(out) {
543
485
  if (Number.isFinite(b)) behind = b;
544
486
  } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
545
487
  // XY columns: X = index (staged), Y = worktree (unstaged)
546
- if (line[2] !== ".") staged += 1;
547
- if (line[3] !== ".") unstaged += 1;
488
+ if (line[2] !== ".") {
489
+ staged += 1;
490
+ const path = line.startsWith("2 ")
491
+ ? line.split("\t").pop().trim()
492
+ : line.split(" ").slice(8).join(" ").trim();
493
+ if (path !== "") stagedNames.push(path);
494
+ }
495
+ if (line[3] !== ".") {
496
+ unstaged += 1;
497
+ // the path is the LAST field in both records (space-separated, and
498
+ // the path itself may contain spaces) — collected untruncated and
499
+ // capped by the caller, exactly like the untracked names
500
+ const path = line.startsWith("2 ")
501
+ ? line.split("\t").pop().trim()
502
+ : line.split(" ").slice(8).join(" ").trim();
503
+ if (path !== "") unstagedNames.push(path);
504
+ }
548
505
  } else if (line.startsWith("u ")) unmerged += 1;
549
- else if (line.startsWith("? ")) untracked += 1;
506
+ else if (line.startsWith("? ")) {
507
+ untracked += 1;
508
+ // `-uall` prints one `? <path>` per untracked FILE, so the names are
509
+ // the lines themselves — collected untruncated here and capped by the
510
+ // caller, so the hover's "and k more" arithmetic stays honest.
511
+ const name = line.slice(2).trim();
512
+ if (name !== "") untrackedNames.push(name);
513
+ }
550
514
  }
551
- return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
515
+ return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked, untrackedNames, unstagedNames, stagedNames };
552
516
  }
553
517
 
554
518
  /**
@@ -678,60 +642,136 @@ const OPERATION_NEXT = {
678
642
  };
679
643
 
680
644
  /**
681
- * The hover card's "next" row: the single highest-priority thing to do next,
682
- * as a conservative command plus the reason it applies. Pure derivation over
683
- * fields the status response already carries — no extra git invocations, so it
684
- * rides the base response and a future surface gets it for free. Ranking (the
685
- * first match wins):
645
+ * The configurable action ranking. Every rule carries a category; the order
646
+ * below (first match wins) is the default, and a user can override it by
647
+ * naming categories in `~/.dsh/git-badge-next.json`:
648
+ *
649
+ * { "order": ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"] }
650
+ *
651
+ * Any subset in any order; categories not listed rank after the listed ones in
652
+ * default order, unknown names are ignored. Typically edited for you by the
653
+ * agent via `/gh order`.
654
+ */
655
+ const DEFAULT_NEXT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
656
+
657
+ /** Normalise any order list: known categories first, then the rest in default order. */
658
+ function normalizeOrder(order) {
659
+ if (!Array.isArray(order)) return DEFAULT_NEXT_ORDER;
660
+ const known = order.filter((cat) => DEFAULT_NEXT_ORDER.includes(cat));
661
+ return [...known, ...DEFAULT_NEXT_ORDER.filter((cat) => !known.includes(cat))];
662
+ }
663
+
664
+ /** The settings namespace this plugin owns — its order lives there, live. */
665
+ const SETTINGS_NS = "git-badge";
666
+
667
+ /**
668
+ * The namespace schema, built from the host-provided schema lib — imported
669
+ * LAZILY because this plugin's module graph must load without it (the test
670
+ * suite runs from a bare checkout, and a host without the settings stack should
671
+ * lose only the settings card, never the badge).
672
+ */
673
+ function orderSettingsSchema(Schema) {
674
+ return Schema.object({
675
+ order: Schema.array(Schema.union(DEFAULT_NEXT_ORDER)).default(DEFAULT_NEXT_ORDER)
676
+ });
677
+ }
678
+
679
+ /**
680
+ * The pre-settings override file. Still honoured as the namespace's BASE (a
681
+ * composition default, so a user who configured the JSON keeps their order
682
+ * after the upgrade) and as a fallback when the settings service is absent.
683
+ */
684
+ function legacyOrderFile() {
685
+ try {
686
+ const home = process.env.DSH_HOME || join(process.env.HOME || "", ".dsh");
687
+ const raw = JSON.parse(readFileSync(join(home, "git-badge-next.json"), "utf8"));
688
+ return normalizeOrder(raw?.order);
689
+ } catch {
690
+ return DEFAULT_NEXT_ORDER;
691
+ }
692
+ }
693
+
694
+ /**
695
+ * The live order — one in-memory value, updated by the settings scope. It is
696
+ * authoritative only once the namespace actually registered; until then (and on
697
+ * a host without the settings stack, or in a unit test that never calls apply)
698
+ * the legacy file is read per call, which is the pre-settings behaviour.
699
+ */
700
+ let currentOrder = DEFAULT_NEXT_ORDER;
701
+ let settingsOrderActive = false;
702
+
703
+ /** The ranking order every status read uses. */
704
+ function nextOrder() {
705
+ return settingsOrderActive ? currentOrder : legacyOrderFile();
706
+ }
707
+
708
+ /**
709
+ * The hover card's "action" row and the (+) menu's picker: every sub-action the
710
+ * checkout justifies, each as a `gh`-skill invocation ({ args, why, what } —
711
+ * what the invocation DOES, why THIS checkout justifies it now), ranked. Pure
712
+ * derivation over fields the status response already carries — no extra git
713
+ * invocations, so it rides the base response.
714
+ *
715
+ * Ranking: rule candidates are collected with a category, then sorted by the
716
+ * configured order (nextOrder) — NOT first-match — so a configuration can, for
717
+ * example, rank "commit" above "sync" without touching code. Ties keep
718
+ * collection order.
686
719
  *
687
- * 1. a paused operation (conflicts included) — resuming it subsumes everything
688
- * else; unmerged files without a marker fall back to `git status`
689
- * 2. behind — reconciling comes before publishing
690
- * 3. no upstream on a dirty branch — publish it for the first time
691
- * 4. ahead — publish
692
- * 5. dirty — stage and commit (add -p for unstaged work; -A only when the
693
- * change is untracked-only, which add -p cannot see)
694
- * 6. PR checks failing — watch them land
720
+ * The new rules this generation adds:
721
+ * - diverged (ahead AND behind) → `sync`: reconciling means rebase-then-push,
722
+ * which the agent must confirm step by step — a plain pull would discard
723
+ * the local-commit context
724
+ * - merge-ready (`pr.mergeState === "CLEAN"`, GitHub's own verdict, or checks
725
+ * passing + review approved) → `merge <n>`: the endgame action the old
726
+ * table could never see
695
727
  *
696
728
  * Clean, synced, nothing failing → null: the row is omitted entirely, because
697
- * "no suggestion" is also a suggestion. Commands are conservative on purpose —
698
- * the card COPIES them into the user's terminal, it never runs them, but the
699
- * register still avoids anything destructive.
729
+ * "no suggestion" is also a suggestion.
700
730
  *
701
- * @returns {{ command: string|undefined, why: string } | null}
731
+ * @returns {{ args: string, command: string|undefined, why: string, what: string } | null}
702
732
  */
703
733
  function nextStep(info) {
704
734
  if (info === void 0 || info === null || info.git !== true) return null;
705
735
  const unmerged = info.unmergedFiles || 0;
706
736
  const plural = (n) => (n === 1 ? "" : "s");
737
+ const ahead = info.ahead || 0;
738
+ const behind = info.behind || 0;
739
+ const staged = info.stagedFiles || 0;
740
+ const unstaged = info.unstagedFiles || 0;
741
+ const untracked = info.untrackedFiles || 0;
742
+ const dirty = staged + unstaged + untracked;
743
+ const rules = [];
744
+ const add = (cat, args, why, what, command) => rules.push({ cat, args, why, what, command });
745
+
707
746
  if (info.operation !== void 0 && info.operation !== null) {
708
747
  const op = String(info.operation);
709
- return {
710
- command: OPERATION_NEXT[op],
711
- why: unmerged > 0
748
+ add("operation", "next",
749
+ unmerged > 0
712
750
  ? `${unmerged} unmerged file${plural(unmerged)} blocking the paused ${op}`
713
- : `a ${op} is paused mid-operation`
714
- };
751
+ : `a ${op} is paused mid-operation`,
752
+ `resume the paused ${op}`,
753
+ OPERATION_NEXT[op]);
715
754
  }
716
755
  if (unmerged > 0) {
717
- return { command: "git status", why: `${unmerged} unmerged file${plural(unmerged)} to resolve` };
756
+ add("unmerged", "next", `${unmerged} unmerged file${plural(unmerged)} to resolve`, "resolve the unmerged files (with you)", "git status");
718
757
  }
719
- const ahead = info.ahead || 0;
720
- const behind = info.behind || 0;
721
- if (behind > 0) {
722
- return { command: "git pull --ff-only", why: `${behind} behind ${info.upstream ?? "upstream"}` };
758
+ if (behind > 0 && ahead > 0) {
759
+ add("sync", "sync", `${ahead} ahead, ${behind} behind — diverged`, "rebase your commits onto upstream and push (asks before any force)");
760
+ } else if (behind > 0) {
761
+ add("sync", "pull", `${behind} behind ${info.upstream ?? "upstream"}`, "update this branch from upstream", "git pull --ff-only");
723
762
  }
724
- const staged = info.stagedFiles || 0;
725
- const unstaged = info.unstagedFiles || 0;
726
- const untracked = info.untrackedFiles || 0;
727
- if (info.upstream === void 0 && staged + unstaged + untracked > 0) {
728
- return { command: `git push -u origin ${info.branch}`, why: "no upstream configured" };
763
+ if (info.upstream === void 0 && dirty > 0) {
764
+ add("publish", "push", "no upstream configured", `publish ${info.branch} for the first time`, `git push -u origin ${info.branch}`);
765
+ } else if (ahead > 0) {
766
+ add("publish", "push", `${ahead} ahead of ${info.upstream ?? "upstream"}`, "push local commits to the remote", "git push");
729
767
  }
730
- if (ahead > 0) {
731
- return { command: "git push", why: `${ahead} ahead of ${info.upstream ?? "upstream"}` };
768
+ const pr = info.pr === void 0 || info.pr === null ? void 0 : info.pr;
769
+ if (pr !== void 0 && pr.number !== void 0 && pr.open !== false
770
+ && (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED"))) {
771
+ add("merge", `merge ${pr.number}`, `pull request ${pr.number} is ready to merge`, `merge pull request ${pr.number} (squash)`);
732
772
  }
733
- if (staged + unstaged + untracked > 0) {
734
- const why = [
773
+ if (dirty > 0) {
774
+ const parts = [
735
775
  staged > 0 ? `${staged} staged` : null,
736
776
  unstaged > 0 ? `${unstaged} unstaged` : null,
737
777
  untracked > 0 ? `${untracked} untracked` : null
@@ -741,12 +781,20 @@ function nextStep(info) {
741
781
  : untracked > 0 && unstaged === 0
742
782
  ? "git add -A && git commit"
743
783
  : "git add -p && git commit";
744
- return { command, why: `work to commit: ${why}` };
784
+ add("commit", "commit", `work to commit: ${parts}`, staged > 0 ? "commit the staged changes" : "stage and commit the working changes", command);
745
785
  }
746
- if (info.pr !== void 0 && info.pr !== null && info.pr.number !== void 0 && info.pr.state === "failing") {
747
- return { command: `gh pr checks ${info.pr.number} --watch`, why: `checks failing on #${info.pr.number}` };
786
+ if (pr !== void 0 && pr.number !== void 0 && pr.state === "failing") {
787
+ 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`);
748
788
  }
749
- return null;
789
+ if (rules.length === 0) return null;
790
+
791
+ const order = nextOrder();
792
+ const rank = (cat) => {
793
+ const at = order.indexOf(cat);
794
+ return at === -1 ? order.length : at;
795
+ };
796
+ const [top] = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat));
797
+ return { args: top.args, command: top.command, why: top.why, what: top.what };
750
798
  }
751
799
 
752
800
  /**
@@ -850,16 +898,103 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
850
898
  behind: parsed.behind
851
899
  };
852
900
  if (wantDetail) {
853
- // last 3 commits (subject + relative age) for the planned hover card
854
- const logOut = await runGit(toplevel, ["log", "-3", "--format=%h%x09%s%x09%cr"]);
855
- if (logOut.stdout !== null && logOut.stdout.trim() !== "") {
856
- info.lastCommits = logOut.stdout.trim().split("\n").map((line) => {
857
- const [hash, subject, when] = line.split("\t");
858
- return { hash, subject: subject ?? "", when: when ?? "" };
859
- }).filter((c) => c.hash !== void 0);
901
+ // The ✎n NAMES — the one part of the count the numbers cannot answer
902
+ // ("what are these?"). Hover-gated with the rest of detail=1, so no
903
+ // refresh pays for it. Posture: RELATIVE names only (git's own output,
904
+ // never absolute — AGENTS.md rule 7 is relaxed by an inch, not a mile),
905
+ // shortened to the last two path segments, capped at 20 with the total
906
+ // carried separately so the client can say "… and k more" honestly.
907
+ // Staged and unstaged names are TRACKED files: the collapsed untracked
908
+ // retry never affects them, so they ride regardless of untrackedMode.
909
+ const capped = (names) => names.slice(0, 20).map(shortenPath);
910
+ if (untrackedMode === "all" && parsed.untrackedNames.length > 0) {
911
+ info.untrackedNames = capped(parsed.untrackedNames);
912
+ info.untrackedNamesTotal = parsed.untrackedNames.length;
913
+ }
914
+ if (parsed.unstagedNames.length > 0) {
915
+ info.unstagedNames = capped(parsed.unstagedNames);
916
+ info.unstagedNamesTotal = parsed.unstagedNames.length;
917
+ }
918
+ if (parsed.stagedNames.length > 0) {
919
+ info.stagedNames = capped(parsed.stagedNames);
920
+ info.stagedNamesTotal = parsed.stagedNames.length;
921
+ }
922
+ // The signed commit list and the counts-vs-main row: ahead/behind against origin/main (then
923
+ // origin/master) is the merge-state line — "3 ahead, 0 behind" reads as
924
+ // "this is the PR's content"; "0 ahead, 5 behind" as "stale, rebase
925
+ // first". One rev-list --left-right, hover-gated like everything here,
926
+ // absent entirely when both are zero (in sync needs no row).
927
+ // The repo's WEB URL — the branch hover links the branch name to the
928
+ // compare view (`<repo>/compare/<base>...<branch>`), and that URL must be
929
+ // vouched for like pr.url is: http(s) only, rebuilt from the parsed parts
930
+ // so no payload value can reach an anchor as a scheme. Derived from
931
+ // `remote get-url origin` with ssh syntax converted; absent when there is
932
+ // no origin or the URL is not recognisably a hosted repo.
933
+ // These reads are independent — one batch, so hover latency is the
934
+ // longest call rather than their sum. The only dependent call (the
935
+ // left-right count needs a probed base ref) runs after the batch and
936
+ // tolerates its own failure.
937
+ const [remoteUrl, probeMain, probeMaster, logOut2, stashOut] = await Promise.all([
938
+ runGit(toplevel, ["remote", "get-url", "origin"]),
939
+ runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/main"]),
940
+ runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/master"]),
941
+ runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", "HEAD"]),
942
+ runGit(toplevel, ["stash", "list"])
943
+ ]);
944
+ if (remoteUrl.stdout !== null && remoteUrl.stdout.trim() !== "") {
945
+ info.repoUrl = remoteToWebUrl(remoteUrl.stdout);
946
+ }
947
+ const baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
948
+ ? "origin/main"
949
+ : probeMaster.stdout !== null && probeMaster.stdout.trim() !== "" ? "origin/master" : null;
950
+ if (baseRef !== null && info.repoUrl !== void 0) {
951
+ const baseName = baseRef.replace(/^origin\//u, "");
952
+ try {
953
+ const compare = new URL(info.repoUrl + "/compare/" + encodeURIComponent(baseName) + "..." + encodeURIComponent(info.branch));
954
+ if (compare.protocol === "https:") info.compareUrl = compare.href;
955
+ } catch {
956
+ // absent rather than wrong
957
+ }
958
+ }
959
+ if (baseRef !== null) {
960
+ const lrOut = await runGit(toplevel, ["rev-list", "--count", "--left-right", baseRef + "...HEAD"]);
961
+ const [mBehind, mAhead] = (lrOut.stdout ?? "").trim().split("\t").map((v) => Number.parseInt(v, 10));
962
+ if (Number.isFinite(mBehind) && Number.isFinite(mAhead) && (mBehind > 0 || mAhead > 0)) {
963
+ info.mainAhead = mAhead;
964
+ info.mainBehind = mBehind;
965
+ }
966
+ }
967
+ if (baseRef !== null) {
968
+ // The signed commit list: mixed newest-first from base...HEAD, each
969
+ // entry signed "+" (only on this branch - the PR's content) or "−"
970
+ // (only on main - what a rebase/merge brings in), git's own left-right
971
+ // verdict mapped onto the diff convention. Capped at 10 with the total
972
+ // from the same base so "... and k more" counts both sides. Without a
973
+ // default branch the list degrades to the plain last 10 on HEAD.
974
+ const signedOut = await runGit(toplevel, ["log", "--left-right", "-10", "--format=%m%x09%h%x09%s%x09%cr", baseRef + "...HEAD"]);
975
+ if (signedOut.stdout !== null && signedOut.stdout.trim() !== "") {
976
+ info.branchCommits = signedOut.stdout.trim().split("\n").map((line) => {
977
+ const [marker, hash, subject, when] = line.split("\t");
978
+ return {
979
+ sign: marker === ">" ? "+" : marker === "<" ? "−" : void 0,
980
+ hash,
981
+ subject: subject ?? "",
982
+ when: when ?? ""
983
+ };
984
+ }).filter((c) => c.hash !== void 0);
985
+ const totalOut = await runGit(toplevel, ["rev-list", "--count", baseRef + "...HEAD"]);
986
+ const total = Number.parseInt((totalOut.stdout ?? "").trim(), 10);
987
+ if (Number.isFinite(total)) info.branchCommitsTotal = total;
988
+ }
989
+ } else {
990
+ const plainOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", "HEAD"]);
991
+ if (plainOut.stdout !== null && plainOut.stdout.trim() !== "") {
992
+ info.branchCommits = plainOut.stdout.trim().split("\n").map((line) => {
993
+ const [hash, subject, when] = line.split("\t");
994
+ return { hash, subject: subject ?? "", when: when ?? "" };
995
+ }).filter((c) => c.hash !== void 0);
996
+ }
860
997
  }
861
- // stash count, only surfaced when nonzero
862
- const stashOut = await runGit(toplevel, ["stash", "list"]);
863
998
  if (stashOut.stdout !== null) {
864
999
  const count = stashOut.stdout.split("\n").filter((l) => l.trim() !== "").length;
865
1000
  if (count > 0) info.stashCount = count;
@@ -1149,10 +1284,10 @@ function watchWorkspace(root, key, workspaceId) {
1149
1284
  }
1150
1285
 
1151
1286
  /**
1152
- * Workspace path -> { worktreePath, workspaceId, owned } for the inferred
1153
- * worktree a session badge is currently following. One per workspace: selection
1154
- * is a single choice by construction (selectSessionWorktree), so following a
1155
- * different tree retires the previous one here.
1287
+ * Workspace path -> { worktreePath, workspaceId, owned } for the worktree a
1288
+ * session badge is currently following. One per workspace: a session follows at
1289
+ * most one tree, so following a different tree (or none) retires the previous
1290
+ * entry here.
1156
1291
  */
1157
1292
  const selectedWorktrees = new Map();
1158
1293
 
@@ -1266,48 +1401,117 @@ async function resolveWorkspace(ctx, params) {
1266
1401
  return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
1267
1402
  }
1268
1403
 
1404
+ /**
1405
+ * The checkout a session registered for itself, or undefined.
1406
+ *
1407
+ * This is the ONLY source for "which worktree is this session using", and it has
1408
+ * to be explicit: DSH records no session→worktree link (a session's cwd is
1409
+ * immutable creation metadata and always the main checkout), so nothing in the
1410
+ * host can answer it. The agent writes the registration with
1411
+ * `dsh-git-badge-checkout <path>`; the file is advisory, so it is validated
1412
+ * here before it is believed — the path must be a worktree git itself lists,
1413
+ * the entry must be fresher than SESSION_CHECKOUT_TTL_MS, and the name/branch
1414
+ * come from git rather than from the file. A stale, hand-edited or removed
1415
+ * registration can therefore only fail to resolve, never point the badge at a
1416
+ * directory that is not a worktree of this repository.
1417
+ */
1418
+ function sessionCheckouts() {
1419
+ if (typeof config.sessionCheckoutsFile === "string") {
1420
+ try {
1421
+ const raw = JSON.parse(readFileSync(config.sessionCheckoutsFile, "utf8"));
1422
+ return raw !== null && typeof raw === "object" && typeof raw.sessions === "object" && raw.sessions !== null
1423
+ ? raw.sessions
1424
+ : {};
1425
+ } catch {
1426
+ return {};
1427
+ }
1428
+ }
1429
+ return readSessionCheckouts();
1430
+ }
1431
+
1432
+ async function registeredCheckout(target, sessionId) {
1433
+ if (typeof sessionId !== "string" || sessionId === "") return void 0;
1434
+ const entry = sessionCheckouts()[sessionId];
1435
+ if (entry === void 0 || typeof entry?.path !== "string") return void 0;
1436
+ const at = Date.parse(String(entry.at ?? ""));
1437
+ if (!Number.isFinite(at) || Date.now() - at > SESSION_CHECKOUT_TTL_MS) return void 0;
1438
+ const { toplevel, worktrees } = await worktreesFor(target.path);
1439
+ // Git lists the main worktree first, and a registration only means something
1440
+ // when the session's own directory IS that main checkout: a session already
1441
+ // inside a linked worktree is its own answer and is never swapped sideways.
1442
+ const main = worktrees[0];
1443
+ if (toplevel === "" || main === void 0 || main.path !== toplevel || target.path !== toplevel || worktrees.length <= 1) return void 0;
1444
+ const found = worktrees.find((tree) => tree.path === entry.path);
1445
+ if (found === void 0 || found.path === toplevel) return void 0;
1446
+ return { path: found.path, name: found.name, branch: found.branch };
1447
+ }
1448
+
1269
1449
  /**
1270
1450
  * The directory a request is ABOUT.
1271
1451
  *
1272
- * A SESSION-targeted chip may follow the one linked worktree its repository can be
1273
- * shown to be working in (selectSessionWorktree): DSH records no session→worktree
1274
- * link at all — session cwd is immutable creation metadata and `attachSession`
1275
- * requires it to equal the workspace path — so a conversation started in the main
1276
- * checkout would otherwise report `main` while its work, and its pull request,
1277
- * live in a tree. The tree's watcher is reconciled HERE, because this is the only
1278
- * place that knows which tree was chosen.
1452
+ * A SESSION-targeted chip follows the checkout that session REGISTERED — the one
1453
+ * signal that knows where the agent is actually working. Without a registration
1454
+ * the request stays on the session's own directory: the plugin never guesses a
1455
+ * worktree from pull requests (an earlier build inferred one from the branch
1456
+ * with the open PR; the guess was invisible to the session that made it and
1457
+ * silently wrong whenever two trees were in play).
1279
1458
  *
1280
1459
  * A WORKSPACE-targeted row never follows a worktree: a row surveys the checkout
1281
1460
  * the registry owns, and substituting a tree would make the row lie about the
1282
1461
  * branch it is on.
1283
- *
1284
- * Gated three ways so the common case costs nothing: the caller must name a
1285
- * session, must be asking about PR state at all (selection is defined by an open
1286
- * PR, so a caller not asking cannot benefit), and the repository must actually
1287
- * have a linked worktree. Only then is the forge consulted.
1288
1462
  */
1289
- async function effectiveTarget(target, bySession, wantPr, notify) {
1290
- if (bySession !== true || wantPr !== true || config.worktreeStatus === "off") return { id: target.id, path: target.path };
1291
- const { toplevel, worktrees } = await worktreesFor(target.path);
1292
- // Git lists the main worktree first, so `main.path === toplevel` is the exact
1293
- // test for "the session's own directory is the main checkout". A session whose
1294
- // directory IS a linked worktree is its own answer and is never swapped
1295
- // sideways onto a sibling.
1296
- const main = worktrees[0];
1297
- if (toplevel === "" || main === void 0 || main.path !== toplevel || worktrees.length <= 1) {
1298
- selectWorktreeWatch(target.path, target.id, void 0);
1299
- return { id: target.id, path: target.path };
1463
+ async function effectiveTarget(target, sessionId) {
1464
+ const bySession = typeof sessionId === "string" && sessionId !== "";
1465
+ if (config.worktreeStatus !== "off") {
1466
+ const registered = bySession ? await registeredCheckout(target, sessionId) : void 0;
1467
+ if (registered !== void 0) {
1468
+ selectWorktreeWatch(target.path, target.id, registered.path);
1469
+ // The name and branch travel; the PATH never does (AGENTS.md rule 7).
1470
+ return { id: target.id, path: registered.path, worktree: { name: registered.name, branch: registered.branch } };
1471
+ }
1300
1472
  }
1301
- const openPrs = openPrsFor(toplevel, notify);
1302
- const chosen = selectSessionWorktree(worktrees, openPrs, toplevel);
1303
- selectWorktreeWatch(target.path, target.id, chosen === void 0 ? void 0 : chosen.path);
1304
- if (chosen === void 0) return { id: target.id, path: target.path };
1305
- // The name and branch travel; the PATH never does (AGENTS.md rule 7).
1306
- return { id: target.id, path: chosen.path, worktree: { name: chosen.name, branch: chosen.branch } };
1473
+ // No registration (or a stale one): retire any follow this workspace had, so
1474
+ // clearing the registration actually stops the extra watcher.
1475
+ selectWorktreeWatch(target.path, target.id, void 0);
1476
+ return { id: target.id, path: target.path };
1307
1477
  }
1308
1478
 
1309
1479
  /** Host plugin body — register the status route and the SSE change feed. */
1310
1480
  function apply(ctx) {
1481
+ // The ranking order lives in this plugin's own settings namespace: declared
1482
+ // with a schema so the host validates it, stored in the user's settings
1483
+ // document, and observed live — a change applies to the next status read
1484
+ // with no file poll and no restart. The pre-settings JSON file, when it
1485
+ // exists, seeds the namespace as its composition BASE so an order configured
1486
+ // before this existed survives the upgrade; it is also the fallback when the
1487
+ // settings service is absent (a host composed without dsh-settings).
1488
+ currentOrder = legacyOrderFile();
1489
+ if (typeof ctx.inject === "function") {
1490
+ try {
1491
+ ctx.inject(["settings"], async (settingsCtx) => {
1492
+ let Schema;
1493
+ try {
1494
+ Schema = await config.settingsSchemaLoader();
1495
+ } catch (error) {
1496
+ console.error("[dsh-git-badge] schema lib unavailable; keeping the legacy order file:", error);
1497
+ return;
1498
+ }
1499
+ const scope = settingsCtx.settings.register(SETTINGS_NS, orderSettingsSchema(Schema), {
1500
+ base: { order: legacyOrderFile() }
1501
+ });
1502
+ currentOrder = normalizeOrder(scope.get()?.order);
1503
+ settingsOrderActive = true;
1504
+ settingsCtx.effect(() => scope.watch((next) => {
1505
+ currentOrder = normalizeOrder(next?.order);
1506
+ }), "dsh-git-badge: order settings");
1507
+ settingsCtx.effect(() => () => {
1508
+ settingsOrderActive = false;
1509
+ }, "dsh-git-badge: order settings teardown");
1510
+ });
1511
+ } catch (error) {
1512
+ console.error("[dsh-git-badge] settings namespace unavailable, using the legacy order file:", error);
1513
+ }
1514
+ }
1311
1515
  // A fresh boot composed the files on disk into the new bundles — any
1312
1516
  // restart-pending marker was written by an install/apply against the PREVIOUS
1313
1517
  // boot and its change is now live. Clearing here is what makes the marker
@@ -1332,7 +1536,7 @@ function apply(ctx) {
1332
1536
  // session's own directory — see effectiveTarget. A workspace-targeted
1333
1537
  // row never leaves the checkout the registry owns.
1334
1538
  const session = url.searchParams.get("session");
1335
- const effective = await effectiveTarget(target, session !== null && session !== "", pr, notifyChange);
1539
+ const effective = await effectiveTarget(target, session ?? void 0);
1336
1540
  const info = await gitStatus(effective.path, detail, pr);
1337
1541
  const body = { ...info, workspace: target.id };
1338
1542
  if (effective.worktree !== void 0 && info.git === true) {
@@ -1340,7 +1544,7 @@ function apply(ctx) {
1340
1544
  // name. Saying so is the whole honesty of the feature: the chip's
1341
1545
  // mark says "worktree", its name says which, and this flag tells the
1342
1546
  // card why the checkout below differs from the branch beside it.
1343
- body.worktreeInferred = true;
1547
+ body.worktreeFollowed = true;
1344
1548
  if (detail) {
1345
1549
  // The session's OWN directory, for the card's `checkout` row, so the
1346
1550
  // main checkout stays visible once the badge follows a tree. Gated
@@ -1461,10 +1665,6 @@ export {
1461
1665
  summarizeChecks,
1462
1666
  prStatusFor,
1463
1667
  prState,
1464
- readOpenPrs,
1465
- openPrsFor,
1466
- prListState,
1467
- selectSessionWorktree,
1468
1668
  selectWorktreeWatch,
1469
1669
  selectedWorktrees,
1470
1670
  statusInFlight,