dsh-git-badge 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,143 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.15.0] - 2026-09-15
14
+
15
+ ### Changed
16
+
17
+ - **Internal polish, no behaviour change:** the `detail=1` hover reads run in
18
+ one parallel batch (hover latency is the longest call, not the sum of seven
19
+ serial git spawns), the path-shortening and remote-URL conversion moved into
20
+ named helpers, and the three hover bodies share one row builder.
21
+
22
+ ### Added
23
+
24
+ - **Merge-blocker phrasing in the branch hover.** A `BLOCKED` verdict says why
25
+ — `blocked: review required`, `blocked: checks failing`, `blocked: draft` —
26
+ combined with the ahead/behind-main counts when both apply, so a dead-end
27
+ word names the next action. CLEAN stays silent: the action row already
28
+ offers `/gh merge`.
29
+
30
+ - **Staged names in the count's hover.** The stacked column gains a `staged:`
31
+ section above `unstaged` and `untracked`, completing the ✎n story — and
32
+ pairing with `/gh commit` when work is already staged.
33
+
34
+ - **Two new `gh` verbs:** `/gh stash pop` (shows the top stash's subject,
35
+ confirms, and on conflict reports that the stash was NOT dropped) and
36
+ `/gh checkout <branch>` (states what is dirty and confirms when work would
37
+ carry over).
38
+
39
+ ### Added
40
+
41
+ - **Unstaged names join the count's hover.** The ✎n tooltip stacks one column
42
+ with two sub-headers — `unstaged` (tracked, edited files; valid regardless of
43
+ the collapsed-untracked retry) above `untracked` — each capped at 20 with the
44
+ same last-two-segments shortening and "… and k more" arithmetic. The picker
45
+ question "what are these?" now answers for both halves of the count.
46
+
47
+ - **The branch hover gains a merge-state row.** `main: N ahead, M behind`
48
+ against `origin/main` — in words, deliberately distinct from the upstream
49
+ sync arrows: "3 ahead, 0 behind" reads as "this is the PR's content";
50
+ "0 ahead, 5 behind" as "stale, rebase first". Absent entirely when in sync.
51
+
52
+ - **The branch hover's commit list is signed and base-relative.** One mixed
53
+ list from `main...HEAD`: entries marked `+` are only on this branch (the
54
+ PR's content), `\u2212` only on main (what a rebase/merge brings in) — git's
55
+ own left-right verdict mapped onto the diff convention, sign before the
56
+ hash. Capped at 10 with the total from the same base; without a default
57
+ branch it degrades to the plain last 10, unsigned.
58
+
59
+ - **A commits hover on the branch name.** Hovering the branch lists the
60
+ COMMITS THIS BRANCH ADDS (`log <upstream>..HEAD`) — hash + subject + age,
61
+ capped at 10 with the total from one `rev-list --count` so "… and k more" is
62
+ arithmetic — not the card's last-three-overall list. Hover-gated with the
63
+ rest of `detail=1`; absent when the branch adds nothing (no tooltip rather
64
+ than an empty one) and when there is no upstream (the publish suggestion
65
+ covers that case).
66
+
67
+ ### Changed
68
+
69
+ - **The hover surfaces have one information architecture.** The branch name's
70
+ hover is the branch's LINEAGE and always renders: `upstream`, `sync` (in
71
+ words, "in sync with upstream" when clean), the commits this branch adds,
72
+ and the pull request when one exists. The status card loses its branch,
73
+ upstream, sync and pull-request rows — the branch name is the chip's own
74
+ text, and lineage facts belong beside the lineage surface — keeping action,
75
+ files, operation, worktree, checkout and stash.
76
+
77
+ - **The status card no longer repeats what the other hovers own.** Its commits
78
+ row and untracked-names row are gone — commit listings live on the branch
79
+ name's hover, untracked names on the count's hover, and the server stopped
80
+ fetching the unused last-three-commits on every detail read. The card keeps
81
+ action, branch, upstream, sync, files, operation, PR, worktree, checkout,
82
+ stash.
83
+
84
+ ### Added
85
+
86
+ - **A names-only hover on the ✎n count.** The count gets its own tooltip —
87
+ just the untracked list (one name per line, "… and k more"), no action row —
88
+ while the full card stays on the status mark. It shares the mark's lazy
89
+ detail fetch (resting on either triggers it once) and degrades to a plain
90
+ count without the Tooltip primitive or when nothing is untracked.
91
+
92
+ ### Added
93
+
94
+ - **Untracked file names in the hover card.** The `✎n` count becomes
95
+ answerable — "what did I create here?" — with a `untracked:` row listing the
96
+ names, hover-gated with the rest of `detail=1` (computed when the card opens,
97
+ refreshed by the existing SSE invalidation; no per-edit cost). Posture: the
98
+ plugin's never-display-a-path rule is relaxed by an inch, not a mile —
99
+ RELATIVE names only, truncated to the last two path segments, capped at 20
100
+ with the total carried separately so "… and k more" is arithmetic, not a
101
+ guess. Collapsed-count payloads (huge untracked trees) omit the field
102
+ entirely rather than under-report with directory names.
103
+
104
+ ### Changed
105
+
106
+ - **A smarter next-action state machine.** `nextStep` now ranks rule
107
+ candidates by category instead of first-match, and two new rules join the
108
+ table: a **diverged** branch (ahead AND behind) suggests `/gh sync` —
109
+ rebase-then-push, with the force-push confirmed by you — instead of a plain
110
+ pull that would discard the local-commit context; and a **merge-ready** PR
111
+ (GitHub's own `mergeState: CLEAN`, or checks passing + review approved)
112
+ suggests `/gh merge <n>` — the endgame action the old table could never see.
113
+ - **Configurable ranking via `/gh order`.** The category order is one line of
114
+ JSON at `~/.dsh/git-badge-next.json`
115
+ (`{"order": ["operation","unmerged","sync","publish","merge","commit","checks"]}`);
116
+ any subset in any order, unlisted categories rank after in default order.
117
+ The `gh` skill gains the `sync`, `merge <n>` and `order` verbs — say
118
+ "/gh order commit first" and the agent edits the file. The server's ranked
119
+ `next` now carries the skill `args` directly, so the hover card and the (+)
120
+ picker lead with exactly what the server decided and only add secondary
121
+ actions around it.
122
+
123
+ ## [0.14.1] - 2026-09-15
124
+
125
+ - **Skill alignment:** the `gh` skill gains an explicit handoff — one verb per
126
+ invocation is its whole job; the multi-step PR lifecycle (review, merge,
127
+ verify, cleanup) belongs to the `gh-pr` skill. The `gh-pr` skill states the
128
+ mirror scope: one-shot verbs are `gh`'s, lifecycle is its own. The catalog
129
+ stays global-only; repo conventions stay in `AGENTS.md`.
130
+
131
+ - **The hover card is 33% wider** (360px → 480px) and its `action:` row now
132
+ carries a trailing what-comment in git-comment convention — e.g.
133
+ `action: /gh push # push local commits to the remote` — so the row says
134
+ what the invocation does, not just which invocation it is. The picker in the
135
+ (+) menu shares the same descriptions.
136
+
137
+ ## [0.14.1] - 2026-09-15
138
+
139
+ ### Changed
140
+
141
+ - **The chip's hover card top line is now an `action:` row** — the gh skill
142
+ invocation the checkout justifies (e.g. `/gh push`), in the card's ordinary
143
+ label/value layout, replacing the next-step line's why-plus-copy-chip. The
144
+ actionable surface remains the (+) menu's `/gh` picker; the card row is its
145
+ read-only pointer.
146
+ - **The branch pull-down is removed from the chip.** The (+) menu's `/gh`
147
+ picker supersedes it as the actionable surface, and the chip returns to
148
+ mark + branch + PR token + notice.
149
+
13
150
  ## [0.14.0] - 2026-09-15
14
151
 
15
152
  ### Added
package/lib/client.js CHANGED
@@ -230,90 +230,45 @@ window.__ModuleLoader__.load({
230
230
  return "ok";
231
231
  }
232
232
 
233
- /**
234
- * The git action list both actionable surfaces render — the `!` trigger
235
- * menu in the input and the branch pull-down on the chip. State-filtered:
236
- * the server's `next` suggestion leads, then only actions the current
237
- * status justifies. Deduped by command (the next step already covers one
238
- * of these when it agrees) and capped, because a menu of twelve is a
239
- * reference manual, not an action menu.
240
- */
241
- function buildActions(info) {
242
- if (info === void 0 || info === null || info.git !== true) return [];
243
- const actions = [];
244
- const seen = new Set();
245
- const add = (command, why) => {
246
- if (typeof command !== "string" || command === "" || seen.has(command)) return;
247
- seen.add(command);
248
- actions.push({ name: command, label: command, description: why, command, why });
249
- };
250
- const next = info.next;
251
- if (next !== void 0 && next !== null && typeof next.command === "string") {
252
- add(next.command, next.why);
253
- }
254
- const ahead = info.ahead || 0;
255
- const behind = info.behind || 0;
256
- const staged = info.stagedFiles || 0;
257
- const unstaged = info.unstagedFiles || 0;
258
- const untracked = info.untrackedFiles || 0;
259
- if (behind > 0) add("git pull --ff-only", behind + " behind " + (info.upstream ?? "upstream"));
260
- if (ahead > 0) add("git push", ahead + " ahead of " + (info.upstream ?? "upstream"));
261
- if (staged > 0) add("git commit", staged + " staged");
262
- if (unstaged > 0) add("git add -p && git commit", unstaged + " unstaged");
263
- if (unstaged === 0 && untracked > 0) add("git add -A && git commit", untracked + " untracked");
264
- if ((info.stashCount || 0) > 0) add("git stash list", info.stashCount + " stashed");
265
- const pr = info.pr;
266
- if (pr !== void 0 && pr !== null && pr.number !== void 0) {
267
- if (pr.state === "failing") add("gh pr checks " + pr.number + " --watch", "checks failing on #" + pr.number);
268
- add("gh pr view " + pr.number, "open pull request #" + pr.number);
269
- } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
270
- add("gh pr create", "branch is pushed and has no pull request");
271
- }
272
- return actions.slice(0, 6);
273
- }
274
-
275
- /** The input text a picked action becomes: the command, then why as a comment. */
276
- function actionText(action) {
277
- return "!" + action.command + " # " + action.why;
278
- }
279
233
 
280
234
  /**
281
235
  * The /gh picker's sub-actions — the skill invocations the checkout
282
- * justifies, most urgent first. Same ranking the server's nextStep uses,
283
- * but spoken in skill arguments; the generic "next" entry is skipped when
284
- * a specific rule already names the same situation (its why would be
285
- * identical, and two rows for one fact is noise).
236
+ * justifies, most urgent first. The server's `next` (the ranked top rule,
237
+ * already carrying args/why/what) always leads; the client adds the other
238
+ * justified actions as secondary entries, mirroring the server's category
239
+ * rules but never re-deciding the winner.
286
240
  */
287
241
  function ghSkillActions(info) {
288
242
  if (info === void 0 || info === null || info.git !== true) return [];
289
243
  const subs = [];
290
244
  const seen = new Set();
291
- const add = (args, why) => {
292
- if (seen.has(args)) return;
245
+ const add = (args, why, what) => {
246
+ if (args === void 0 || args === null || args === "" || seen.has(args)) return;
293
247
  seen.add(args);
294
- subs.push({ args, why });
248
+ subs.push({ args, why, what });
295
249
  };
250
+ const next = info.next;
251
+ if (next !== void 0 && next !== null) add(next.args, next.why, next.what);
296
252
  const ahead = info.ahead || 0;
297
253
  const behind = info.behind || 0;
298
254
  const staged = info.stagedFiles || 0;
299
255
  const unstaged = info.unstagedFiles || 0;
300
256
  const untracked = info.untrackedFiles || 0;
301
- if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"));
302
- if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"));
303
- if (staged > 0) add("commit", staged + " staged");
304
- if (unstaged > 0) add("commit", unstaged + " unstaged");
305
- if (unstaged === 0 && untracked > 0) add("commit", untracked + " untracked");
257
+ if (behind > 0 && ahead > 0) {
258
+ add("sync", ahead + " ahead, " + behind + " behind — diverged", "rebase your commits onto upstream and push (asks before any force)");
259
+ } else {
260
+ if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"), "update this branch from upstream");
261
+ if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"), "push local commits to the remote");
262
+ }
263
+ if (staged > 0) add("commit", staged + " staged", "commit the staged changes");
264
+ if (unstaged > 0) add("commit", unstaged + " unstaged", "stage and commit the working changes");
265
+ if (unstaged === 0 && untracked > 0) add("commit", untracked + " untracked", "stage the new files and commit them");
306
266
  const pr = info.pr;
307
267
  if (pr !== void 0 && pr !== null && pr.number !== void 0) {
308
- if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number);
309
- add("pr view " + pr.number, "open pull request #" + pr.number);
268
+ if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number, "watch the CI checks on pull request " + pr.number);
269
+ add("pr view " + pr.number, "open pull request #" + pr.number, "show pull request " + pr.number + " on GitHub");
310
270
  } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
311
- add("pr", "branch is pushed and has no pull request");
312
- }
313
- const next = info.next;
314
- if (next !== void 0 && next !== null && typeof next.command === "string"
315
- && !subs.some((entry) => entry.why === next.why)) {
316
- add("next", next.why);
271
+ add("pr", "branch is pushed and has no pull request", "open a pull request for this branch");
317
272
  }
318
273
  return subs.slice(0, 6);
319
274
  }
@@ -704,72 +659,179 @@ window.__ModuleLoader__.load({
704
659
  gap: "4px",
705
660
  fontSize: "12px",
706
661
  lineHeight: "18px",
707
- maxWidth: "340px"
662
+ // Auto-width: the card fits its widest row instead of a guessed
663
+ // constant, capped at 600px. The Tooltip's maxWidth must stay >= the
664
+ // cap or IT becomes the clamp.
665
+ width: "max-content",
666
+ maxWidth: "600px"
708
667
  };
709
- const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
710
- const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
711
- const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
712
-
713
668
  /**
714
- * The "next" row's body: why, then the command as a click-to-copy chip.
715
- * Clipboard-only by design — no server call, no path exposure; the command
716
- * lands in the user's clipboard to run in their own terminal. `navigator.
717
- * clipboard` needs a user gesture (a click is one) and a secure context
718
- * (localhost is one); a denied write degrades to a no-op and the command
719
- * is still readable as text.
669
+ * The ✎n count's own hover body — the changed-file lists and NOTHING
670
+ * else: the full card stays on the status mark, and this tooltip answers
671
+ * the one question the count poses ("what are these?"). One stacked
672
+ * column with sub-headers: UNSTAGED (tracked, edited — valid regardless
673
+ * of the collapsed untracked retry) then UNTRACKED. Reads the same lazy
674
+ * detail payload the mark's card uses; no tooltip at all when neither
675
+ * list has names (the count itself is absent on a clean tree, so an
676
+ * empty list is rare — a mid-fetch hover shows it plain until the
677
+ * response lands).
720
678
  */
721
- function NextStepRow({ next }) {
722
- const [copied, setCopied] = react.useState(false);
723
- const copy = () => {
724
- if (typeof next.command !== "string") return;
725
- try {
726
- void navigator.clipboard.writeText(next.command).then(
727
- () => {
728
- setCopied(true);
729
- setTimeout(() => setCopied(false), 1500);
730
- },
731
- () => void 0,
732
- );
733
- } catch {
734
- void 0;
679
+ function CountNames({ info }) {
680
+ const sections = [
681
+ {
682
+ label: "staged",
683
+ names: info?.stagedNames,
684
+ total: info?.stagedNamesTotal
685
+ },
686
+ {
687
+ label: "unstaged",
688
+ names: info?.unstagedNames,
689
+ total: info?.unstagedNamesTotal
690
+ },
691
+ {
692
+ label: "untracked",
693
+ names: info?.untrackedNames,
694
+ total: info?.untrackedNamesTotal
735
695
  }
696
+ ].filter((section) => Array.isArray(section.names) && section.names.length > 0);
697
+ if (sections.length === 0) {
698
+ return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no changed files" });
699
+ }
700
+ return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children:
701
+ sections.map((section) => react_jsx_runtime.jsxs("div", { style: CARD_ROW, children: [
702
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: section.label }),
703
+ react_jsx_runtime.jsx("span", {
704
+ style: { ...CARD_VALUE, display: "flex", flexDirection: "column", gap: "2px" },
705
+ children: [
706
+ ...section.names.map((name) => react_jsx_runtime.jsx("span", { children: name }, section.label + name)),
707
+ (typeof section.total === "number" && section.total > section.names.length
708
+ ? "\u2026 and " + (section.total - section.names.length) + " more"
709
+ : null)
710
+ ]
711
+ })
712
+ ] }, section.label))
713
+ });
714
+ }
715
+
716
+ /**
717
+ * Compress git's relative age to its shortest honest form: 43 seconds
718
+ * ago -> 43s ago, 2 hours ago -> 2h ago, weeks -> w, months -> mo (to
719
+ * stay distinct from minutes), years -> y.
720
+ */
721
+ function shortWhen(when) {
722
+ return String(when)
723
+ .replace(/(\d+) seconds? ago/u, "$1s ago")
724
+ .replace(/(\d+) minutes? ago/u, "$1m ago")
725
+ .replace(/(\d+) hours? ago/u, "$1h ago")
726
+ .replace(/(\d+) days? ago/u, "$1d ago")
727
+ .replace(/(\d+) weeks? ago/u, "$1w ago")
728
+ .replace(/(\d+) months? ago/u, "$1mo ago")
729
+ .replace(/(\d+) years? ago/u, "$1y ago");
730
+ }
731
+
732
+ /**
733
+ * The branch name's own hover body — the branch's LINEAGE, and it ALWAYS
734
+ * renders once the lazy detail payload has landed. Rows: upstream, sync
735
+ * in words, the branch's last 10 commits (WHAT THIS BRANCH HAS, full
736
+ * stop — no base-relative filtering, which empties on every push or
737
+ * merge). The pull request has no row here — the chip's own PR token
738
+ * already carries number, CI state and the link.
739
+ */
740
+ function BranchLineage({ info }) {
741
+ const rows = [];
742
+ const add = (label, value) => {
743
+ if (value === void 0 || value === null || value === "") return;
744
+ rows.push(cardRow(label, value));
736
745
  };
737
- return react_jsx_runtime.jsxs(
738
- "span",
739
- {
740
- style: { ...CARD_VALUE, display: "inline-flex", alignItems: "center", gap: "6px", flexWrap: "wrap" },
741
- children: [
742
- react_jsx_runtime.jsx("span", { children: next.why }),
743
- typeof next.command === "string"
744
- ? react_jsx_runtime.jsx(
745
- "button",
746
- {
747
- "aria-label": "Copy command: " + next.command,
748
- title: "Copy command: " + next.command,
749
- onClick: copy,
746
+ add("upstream", info?.upstream === void 0 ? "none configured" : info.upstream);
747
+ const ahead = info?.ahead || 0;
748
+ const behind = info?.behind || 0;
749
+ // The sync row speaks ONLY about the upstream it names: with no
750
+ // upstream configured there are no ahead/behind counts at all (no
751
+ // `# branch.ab` header), and falling back to 0/0 would claim "in
752
+ // sync" — a lie the upstream row directly contradicts. Omit it; the
753
+ // "none configured" row above carries the fact.
754
+ if (info?.upstream !== void 0) {
755
+ add("sync", ahead === 0 && behind === 0
756
+ ? "in sync with upstream"
757
+ : "\u2191" + ahead + " \u2193" + behind);
758
+ }
759
+ // The merge-state row, in WORDS (deliberately not arrows — the sync
760
+ // row above already uses ↑↓ for the push axis, and two arrow pairs
761
+ // with different references would be read as one). "merge" is its
762
+ // label because it is the merge axis — the counterpart of "sync",
763
+ // the push axis — and it stays correct whatever the default branch
764
+ // is named. A BLOCKED verdict becomes the reason (review required,
765
+ // checks failing, draft) so a dead-end word names the next action;
766
+ // CLEAN stays silent — the action row already offers /gh merge.
767
+ const pr = info?.pr;
768
+ const mergeParts = [];
769
+ if (pr !== void 0 && pr !== null && pr.number !== void 0) {
770
+ if (pr.draft === true) mergeParts.push("blocked: draft");
771
+ else if (pr.mergeState === "BLOCKED") {
772
+ mergeParts.push(pr.review === "REVIEW_REQUIRED" ? "blocked: review required"
773
+ : pr.state === "failing" ? "blocked: checks failing"
774
+ : "blocked");
775
+ } else if (pr.state === "failing") mergeParts.push("blocked: checks failing");
776
+ else if (pr.mergeState !== "CLEAN" && pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
777
+ }
778
+ if (info?.mainAhead !== void 0 || info?.mainBehind !== void 0) {
779
+ mergeParts.push((info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind main");
780
+ }
781
+ if (mergeParts.length > 0) add("merge", mergeParts.join(" \u00B7 "));
782
+ const commits = info?.branchCommits;
783
+ if (Array.isArray(commits) && commits.length > 0) {
784
+ // Full-width block, not the label/value two-column row: commit lines
785
+ // use the card's whole width, and each line is nowrap with CSS
786
+ // ellipsis so long subjects clip at the edge instead of pushing the
787
+ // card out. `when` is compressed the way git log users shorten it:
788
+ // 43 seconds ago -> 43s ago.
789
+ rows.push(
790
+ react_jsx_runtime.jsxs("div", {
791
+ style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
792
+ children:
793
+ commits.map((commit) => react_jsx_runtime.jsxs("span", {
794
+ style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
795
+ children: [
796
+ // fixed-width monospace hash cell: every description starts
797
+ // on the same column, so the list reads as a table
798
+ react_jsx_runtime.jsx("span", {
750
799
  style: {
751
- cursor: "pointer",
752
- color: "inherit",
753
- background: "none",
754
- border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
755
- borderRadius: "4px",
756
- fontSize: "11px",
757
- lineHeight: "16px",
758
- padding: "0 5px",
759
- flex: "none",
760
- fontFamily: "monospace"
800
+ color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
801
+ fontFamily: "monospace",
802
+ display: "inline-block",
803
+ minWidth: "68px",
804
+ flex: "none"
761
805
  },
762
- children: copied ? "copied" : next.command
763
- },
764
- "cmd"
765
- )
766
- : null
767
- ]
768
- },
769
- "next-body"
770
- );
806
+ children: (commit.sign === "+" ? "+" : commit.sign === "\u2212" ? "\u2212" : "") + commit.hash + ":"
807
+ }),
808
+ commit.subject + (commit.when === "" ? "" : " \u00B7 " + shortWhen(commit.when))
809
+ ]
810
+ }, commit.hash))
811
+ }, "branch-commits")
812
+ );
813
+ }
814
+ // NO pull-request row: the chip's own PR token (number, CI state, link)
815
+ // carries it — a second rendering two pixels away is pure duplication.
816
+ return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
817
+ }
818
+
819
+ const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
820
+ const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
821
+ const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
822
+
823
+ /** One label/value row — the shape every hover body shares. */
824
+ function cardRow(label, value) {
825
+ return react_jsx_runtime.jsxs("div", {
826
+ style: CARD_ROW,
827
+ children: [
828
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
829
+ react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
830
+ ]
831
+ }, label);
771
832
  }
772
833
 
834
+
773
835
  /**
774
836
  * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
775
837
  * over fields the status response already carries, plus the `detail=1`
@@ -783,36 +845,25 @@ window.__ModuleLoader__.load({
783
845
  const rows = [];
784
846
  const add = (label, value) => {
785
847
  if (value === void 0 || value === null || value === "") return;
786
- rows.push(
787
- react_jsx_runtime.jsxs("div", {
788
- style: CARD_ROW,
789
- children: [
790
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
791
- react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
792
- ]
793
- }, label)
794
- );
848
+ rows.push(cardRow(label, value));
795
849
  };
796
- // The "next" row — the node half's single highest-priority suggestion,
797
- // { command, why } or absent (clean + synced + nothing failing). Clicking
798
- // copies the command; it is the one actionable affordance the card
799
- // carries, and it costs no server surface: the command runs in the
800
- // user's own terminal, never here. Per-open state: the copied ack
801
- // resets the next time the card opens, which is the honest lifetime for it.
802
- const next = data.next;
803
- if (next !== void 0 && next !== null && typeof next.why === "string") {
804
- rows.push(
805
- react_jsx_runtime.jsxs(NextStepRow, { next }, "next")
806
- );
807
- }
808
- add("branch", info.branch);
809
- add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
810
- if (info.ahead !== void 0 || info.behind !== void 0) {
811
- add("sync", "\u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0));
850
+ // The "action" row — the top line, in the card's ordinary row layout:
851
+ // the gh skill invocation the checkout justifies, derived from the same
852
+ // state the /gh picker uses (first sub-action wins). Plain text, no
853
+ // affordance: the actionable surface is the (+) menu's /gh picker, and
854
+ // this row is the read-only pointer to it.
855
+ const topAction = ghSkillActions(data)[0];
856
+ if (topAction !== void 0) {
857
+ // git-comment convention: the invocation, two spaces, then "# "
858
+ // plus what it does
859
+ add("action", "/gh " + topAction.args + " # " + topAction.what);
812
860
  }
861
+ // The branch lineage rows (branch, upstream, sync, pull request) moved
862
+ // to the branch NAME's hover — the card is the checkout's verdict, not
863
+ // the branch's CV. The branch name is also the chip's own text, so a
864
+ // card row for it was pure repetition.
813
865
  add("files", formatFileBreakdown(info));
814
866
  add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
815
- add("pull request", formatPrDetail(data.pr));
816
867
  // WHICH checkout the badge describes, when it is a worktree. The chip shows
817
868
  // the branch alone, so this row is the only place the tree is named.
818
869
  add("worktree", worktreeDetail(data));
@@ -821,27 +872,6 @@ window.__ModuleLoader__.load({
821
872
  // tree's in that case, so this is where the checkout's own state stays
822
873
  // visible. Absent until the `detail=1` response lands, like commits/stash.
823
874
  if (data.worktreeInferred === true) add("checkout", formatCheckoutDetail(data.checkout));
824
- if (Array.isArray(data.lastCommits) && data.lastCommits.length > 0) {
825
- rows.push(
826
- react_jsx_runtime.jsxs("div", {
827
- style: CARD_ROW,
828
- children: [
829
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: "commits" }),
830
- react_jsx_runtime.jsx("span", {
831
- style: { ...CARD_VALUE, display: "flex", flexDirection: "column", gap: "2px" },
832
- children: data.lastCommits.map((commit) =>
833
- react_jsx_runtime.jsxs("span", {
834
- children: [
835
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: commit.hash }),
836
- " " + commit.subject + (commit.when === "" ? "" : " \u00B7 " + commit.when)
837
- ]
838
- }, commit.hash)
839
- )
840
- })
841
- ]
842
- }, "commits")
843
- );
844
- }
845
875
  add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
846
876
  return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
847
877
  }
@@ -894,16 +924,13 @@ window.__ModuleLoader__.load({
894
924
  // re-render that no fetch will schedule, so the × bumps a counter here.
895
925
  const [, bumpNotice] = react.useState(0);
896
926
  const seamNotice = seamHint !== null && !seamNoticeDismissed ? seamHint : null;
897
- // The branch pull-down (model-selection pattern): a chevron opens the
898
- // shell Menu of state-filtered git actions; picking COPIES the command —
899
- // the composer has no public insert API (see the trigger source in
900
- // apply() for the insertion-capable surface), so this surface's contract
901
- // is paste-and-send, the same one the hover card's chip has always had.
902
- const [menuOpen, setMenuOpen] = react.useState(false);
903
- const [copied, setCopied] = react.useState(false);
927
+ // the branch link's hover underline — the PR token's pattern: the
928
+ // affordance appears on hover/focus, never at rest. Its OWN state, not
929
+ // the PR token's linkHover: two links sharing one flag would
930
+ // underline both when either is hovered.
931
+ const [branchLinkHover, setBranchLinkHover] = react.useState(false);
904
932
  const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
905
933
  if (info === void 0 || info.git !== true) return null;
906
- const actions = buildActions({ ...info, ...(detail ?? {}) });
907
934
 
908
935
  // The status mark wrapped in the chip's hover card — the card's ONLY
909
936
  // anchor. Pointer rest here (and only here) enables the detail=1 fetch,
@@ -919,7 +946,8 @@ window.__ModuleLoader__.load({
919
946
  if (Tooltip === void 0) return mark;
920
947
  return react_jsx_runtime.jsx(Tooltip, {
921
948
  side: "top",
922
- maxWidth: 360,
949
+ // matches the body cap: the tooltip must never be the clamp
950
+ maxWidth: 600,
923
951
  // a function label keeps the card's element tree out of every render
924
952
  // until the tooltip actually opens
925
953
  label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
@@ -929,7 +957,74 @@ window.__ModuleLoader__.load({
929
957
  // the mark is an element now rather than a leading glyph in the string, so
930
958
  // the SAME StatusMark the sidebar row draws carries the status here too;
931
959
  // the container's 4px gap supplies the space the emoji's own did
932
- const text = info.branch + formatOperationToken(info) + formatGitSuffix(info);
960
+ // The branch NAME is its own hover surface: the commits this branch adds
961
+ // (BranchCommitsList), sharing the mark's lazy detail fetch. The rest of
962
+ // the leading text (operation token) and the count keep their own
963
+ // surfaces. Degrades to plain text without the Tooltip primitive or
964
+ // when the branch adds nothing beyond upstream.
965
+ const branchHover = (() => {
966
+ // The branch name links to the compare view (base...branch) — the web
967
+ // page of exactly what the lineage tooltip lists. Vouched http(s)
968
+ // only (the node half rebuilt the URL from parsed parts); new tab,
969
+ // never navigating the conversation away.
970
+ const isLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
971
+ const branch = isLink
972
+ ? react_jsx_runtime.jsx("a", {
973
+ href: detail.compareUrl,
974
+ target: "_blank",
975
+ rel: "noopener noreferrer",
976
+ "aria-label": "Compare " + info.branch + " with main on GitHub",
977
+ onPointerEnter: () => { setHovered(true); setBranchLinkHover(true); },
978
+ onPointerLeave: () => setBranchLinkHover(false),
979
+ onFocus: () => { setHovered(true); setBranchLinkHover(true); },
980
+ onBlur: () => setBranchLinkHover(false),
981
+ style: {
982
+ color: "inherit",
983
+ textDecoration: branchLinkHover ? "underline" : "none",
984
+ cursor: "pointer"
985
+ },
986
+ children: info.branch
987
+ })
988
+ : react_jsx_runtime.jsx("span", {
989
+ onPointerEnter: () => setHovered(true),
990
+ style: { cursor: "default" },
991
+ children: info.branch
992
+ });
993
+ if (Tooltip === void 0 || detail === void 0 || detail === null) {
994
+ // mid-fetch the branch shows plain; the lineage tooltip is
995
+ // unconditional once the payload lands
996
+ return branch;
997
+ }
998
+ return react_jsx_runtime.jsx(Tooltip, {
999
+ side: "top",
1000
+ maxWidth: 480,
1001
+ label: () => react_jsx_runtime.jsx(BranchLineage, { info: detail }),
1002
+ children: branch
1003
+ });
1004
+ })();
1005
+ const text = formatOperationToken(info);
1006
+ // The ✎n count is its OWN hover surface: a names-only tooltip (see
1007
+ // UntrackedList). It shares the mark's lazy detail fetch — resting on
1008
+ // either triggers it once — and degrades to a plain count on a shell
1009
+ // without the Tooltip primitive.
1010
+ const countHover = (() => {
1011
+ const count = react_jsx_runtime.jsx("span", {
1012
+ onPointerEnter: () => setHovered(true),
1013
+ style: { cursor: "default" },
1014
+ children: formatGitSuffix(info)
1015
+ });
1016
+ const hasNames = (Array.isArray(detail?.untrackedNames) && detail.untrackedNames.length > 0)
1017
+ || (Array.isArray(detail?.unstagedNames) && detail.unstagedNames.length > 0);
1018
+ if (Tooltip === void 0 || !hasNames) {
1019
+ return count;
1020
+ }
1021
+ return react_jsx_runtime.jsx(Tooltip, {
1022
+ side: "top",
1023
+ maxWidth: 480,
1024
+ label: () => react_jsx_runtime.jsx(CountNames, { info: detail }),
1025
+ children: count
1026
+ });
1027
+ })();
933
1028
  const prToken = formatPrToken(info);
934
1029
  const prUrl = prLinkUrl(info);
935
1030
  const chip = react_jsx_runtime.jsxs("span", {
@@ -962,69 +1057,9 @@ window.__ModuleLoader__.load({
962
1057
  // stays on the whole chip — it is about the badge, not the mark.
963
1058
  hoverableMark,
964
1059
 
965
- Menu === void 0 || actions.length === 0
966
- ? null
967
- : react_jsx_runtime.jsx(Menu, {
968
- key: "actions",
969
- open: menuOpen,
970
- onClose: () => setMenuOpen(false),
971
- items: actions.map((action) => ({ id: action.command, label: action.label, description: action.description })),
972
- onSelect: (picked) => {
973
- setMenuOpen(false);
974
- // the shell may hand back the id or the whole item — take either
975
- const id = typeof picked === "string" ? picked : picked?.id;
976
- const action = actions.find((entry) => entry.command === id);
977
- console.info("[dsh-git-badge] pull-down pick:", id, "->", action === void 0 ? "NOT FOUND" : actionText(action));
978
- if (action === void 0) return;
979
- try {
980
- void navigator.clipboard.writeText(actionText(action)).then(() => {
981
- setCopied(true);
982
- setTimeout(() => setCopied(false), 1200);
983
- }, () => void 0);
984
- } catch {
985
- void 0;
986
- }
987
- },
988
- align: "start",
989
- portal: true,
990
- anchor: react_jsx_runtime.jsxs(
991
- "button",
992
- {
993
- type: "button",
994
- "aria-haspopup": "menu",
995
- "aria-expanded": menuOpen,
996
- "aria-label": "Git actions for " + info.branch,
997
- title: copied ? "copied — paste in your terminal" : "git actions",
998
- onClick: () => setMenuOpen((value) => !value),
999
- // the metrics of the row's own selectors (the access/model
1000
- // pull-downs): inline-flex, centered on the 24px line, no
1001
- // border or padding, icon flex-none so the chevron reads as an
1002
- // affordance, not a second label
1003
- style: {
1004
- cursor: "pointer",
1005
- color: "inherit",
1006
- background: "none",
1007
- border: "none",
1008
- display: "inline-flex",
1009
- alignItems: "center",
1010
- justifyContent: "center",
1011
- fontSize: "12px",
1012
- lineHeight: "24px",
1013
- padding: "0",
1014
- flex: "none"
1015
- },
1016
- children: [
1017
- copied
1018
- ? "\u2713"
1019
- : (primitives !== null && primitives.IconChevronDownOutline14 !== void 0
1020
- ? react_jsx_runtime.jsx(primitives.IconChevronDownOutline14, {})
1021
- : "\u25BE")
1022
- ]
1023
- },
1024
- "actions-anchor"
1025
- )
1026
- }),
1027
- react_jsx_runtime.jsx("span", { key: "text", children: text }),
1060
+ branchHover,
1061
+ react_jsx_runtime.jsx("span", { key: "text", children: text }),
1062
+ countHover,
1028
1063
  prToken === ""
1029
1064
  ? null
1030
1065
  : prUrl === void 0
@@ -1313,7 +1348,7 @@ window.__ModuleLoader__.load({
1313
1348
  // Additive; the host reads apply/inject and ignores the rest. The suite
1314
1349
  // drives these to assert the REQUEST contract — which surface asks for the
1315
1350
  // expensive extras — without a browser, a fetch or a network.
1316
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, buildActions, actionText, ghSkillActions };
1351
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions };
1317
1352
  return module.exports;
1318
1353
  }
1319
1354
  });
package/lib/index.js CHANGED
@@ -522,6 +522,32 @@ function prStatusFor(toplevel, branch, notify) {
522
522
  * two-column XY status (X = index/staged, Y = worktree), `u` unmerged,
523
523
  * `?` untracked.
524
524
  */
525
+ /** Relative path shortened to its last two segments — the hover-name contract. */
526
+ function shortenPath(name) {
527
+ const parts = name.split("/");
528
+ return parts.length <= 2 ? name : parts.slice(-2).join("/");
529
+ }
530
+
531
+ /**
532
+ * Convert a remote URL to the hosted repo's WEB root: https stays https,
533
+ * `git@host:path` ssh syntax converts, a `.git` suffix is stripped. Null when
534
+ * the URL is neither form — the caller decides what a null is worth.
535
+ */
536
+ function remoteToWebUrl(raw) {
537
+ const trimmed = raw.trim().replace(/\.git$/u, "");
538
+ const https = trimmed.match(/^https:\/\/([^/]+)\/(.+?)\/?$/u);
539
+ const ssh = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
540
+ const web = https !== null ? https[1] + "/" + https[2] : ssh !== null ? ssh[1] + "/" + ssh[2] : null;
541
+ if (web === null) return null;
542
+ try {
543
+ const url = new URL("https://" + web);
544
+ if (url.protocol === "https:" && url.pathname.length > 1) return url.origin + url.pathname.replace(/\/$/u, "");
545
+ } catch {
546
+ // not a URL we can vouch for
547
+ }
548
+ return null;
549
+ }
550
+
525
551
  function parseStatusV2(out) {
526
552
  let branch = "HEAD (detached)";
527
553
  let upstream;
@@ -531,6 +557,9 @@ function parseStatusV2(out) {
531
557
  let unstaged = 0;
532
558
  let unmerged = 0;
533
559
  let untracked = 0;
560
+ const untrackedNames = [];
561
+ const unstagedNames = [];
562
+ const stagedNames = [];
534
563
  for (const line of out.split("\n")) {
535
564
  if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
536
565
  else if (line.startsWith("# branch.upstream ")) upstream = line.slice(18).trim();
@@ -543,12 +572,34 @@ function parseStatusV2(out) {
543
572
  if (Number.isFinite(b)) behind = b;
544
573
  } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
545
574
  // XY columns: X = index (staged), Y = worktree (unstaged)
546
- if (line[2] !== ".") staged += 1;
547
- if (line[3] !== ".") unstaged += 1;
575
+ if (line[2] !== ".") {
576
+ staged += 1;
577
+ const path = line.startsWith("2 ")
578
+ ? line.split("\t").pop().trim()
579
+ : line.split(" ").slice(8).join(" ").trim();
580
+ if (path !== "") stagedNames.push(path);
581
+ }
582
+ if (line[3] !== ".") {
583
+ unstaged += 1;
584
+ // the path is the LAST field in both records (space-separated, and
585
+ // the path itself may contain spaces) — collected untruncated and
586
+ // capped by the caller, exactly like the untracked names
587
+ const path = line.startsWith("2 ")
588
+ ? line.split("\t").pop().trim()
589
+ : line.split(" ").slice(8).join(" ").trim();
590
+ if (path !== "") unstagedNames.push(path);
591
+ }
548
592
  } else if (line.startsWith("u ")) unmerged += 1;
549
- else if (line.startsWith("? ")) untracked += 1;
593
+ else if (line.startsWith("? ")) {
594
+ untracked += 1;
595
+ // `-uall` prints one `? <path>` per untracked FILE, so the names are
596
+ // the lines themselves — collected untruncated here and capped by the
597
+ // caller, so the hover's "and k more" arithmetic stays honest.
598
+ const name = line.slice(2).trim();
599
+ if (name !== "") untrackedNames.push(name);
600
+ }
550
601
  }
551
- return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
602
+ return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked, untrackedNames, unstagedNames, stagedNames };
552
603
  }
553
604
 
554
605
  /**
@@ -678,60 +729,98 @@ const OPERATION_NEXT = {
678
729
  };
679
730
 
680
731
  /**
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):
732
+ * The configurable action ranking. Every rule carries a category; the order
733
+ * below (first match wins) is the default, and a user can override it by
734
+ * naming categories in `~/.dsh/git-badge-next.json`:
686
735
  *
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
736
+ * { "order": ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"] }
737
+ *
738
+ * Any subset in any order; categories not listed rank after the listed ones in
739
+ * default order, unknown names are ignored. Typically edited for you by the
740
+ * agent via `/gh order`.
741
+ */
742
+ const DEFAULT_NEXT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
743
+
744
+ /** The ranking override file — read per call (one tiny stat+read per status). */
745
+ function nextOrder() {
746
+ try {
747
+ const home = process.env.DSH_HOME || join(process.env.HOME || "", ".dsh");
748
+ const raw = JSON.parse(readFileSync(join(home, "git-badge-next.json"), "utf8"));
749
+ if (!Array.isArray(raw?.order)) return DEFAULT_NEXT_ORDER;
750
+ const known = raw.order.filter((cat) => DEFAULT_NEXT_ORDER.includes(cat));
751
+ return [...known, ...DEFAULT_NEXT_ORDER.filter((cat) => !known.includes(cat))];
752
+ } catch {
753
+ return DEFAULT_NEXT_ORDER;
754
+ }
755
+ }
756
+
757
+ /**
758
+ * The hover card's "action" row and the (+) menu's picker: every sub-action the
759
+ * checkout justifies, each as a `gh`-skill invocation ({ args, why, what } —
760
+ * what the invocation DOES, why THIS checkout justifies it now), ranked. Pure
761
+ * derivation over fields the status response already carries — no extra git
762
+ * invocations, so it rides the base response.
763
+ *
764
+ * Ranking: rule candidates are collected with a category, then sorted by the
765
+ * configured order (nextOrder) — NOT first-match — so a configuration can, for
766
+ * example, rank "commit" above "sync" without touching code. Ties keep
767
+ * collection order.
768
+ *
769
+ * The new rules this generation adds:
770
+ * - diverged (ahead AND behind) → `sync`: reconciling means rebase-then-push,
771
+ * which the agent must confirm step by step — a plain pull would discard
772
+ * the local-commit context
773
+ * - merge-ready (`pr.mergeState === "CLEAN"`, GitHub's own verdict, or checks
774
+ * passing + review approved) → `merge <n>`: the endgame action the old
775
+ * table could never see
695
776
  *
696
777
  * 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.
778
+ * "no suggestion" is also a suggestion.
700
779
  *
701
- * @returns {{ command: string|undefined, why: string } | null}
780
+ * @returns {{ args: string, command: string|undefined, why: string, what: string } | null}
702
781
  */
703
782
  function nextStep(info) {
704
783
  if (info === void 0 || info === null || info.git !== true) return null;
705
784
  const unmerged = info.unmergedFiles || 0;
706
785
  const plural = (n) => (n === 1 ? "" : "s");
786
+ const ahead = info.ahead || 0;
787
+ const behind = info.behind || 0;
788
+ const staged = info.stagedFiles || 0;
789
+ const unstaged = info.unstagedFiles || 0;
790
+ const untracked = info.untrackedFiles || 0;
791
+ const dirty = staged + unstaged + untracked;
792
+ const rules = [];
793
+ const add = (cat, args, why, what, command) => rules.push({ cat, args, why, what, command });
794
+
707
795
  if (info.operation !== void 0 && info.operation !== null) {
708
796
  const op = String(info.operation);
709
- return {
710
- command: OPERATION_NEXT[op],
711
- why: unmerged > 0
797
+ add("operation", "next",
798
+ unmerged > 0
712
799
  ? `${unmerged} unmerged file${plural(unmerged)} blocking the paused ${op}`
713
- : `a ${op} is paused mid-operation`
714
- };
800
+ : `a ${op} is paused mid-operation`,
801
+ `resume the paused ${op}`,
802
+ OPERATION_NEXT[op]);
715
803
  }
716
804
  if (unmerged > 0) {
717
- return { command: "git status", why: `${unmerged} unmerged file${plural(unmerged)} to resolve` };
805
+ add("unmerged", "next", `${unmerged} unmerged file${plural(unmerged)} to resolve`, "resolve the unmerged files (with you)", "git status");
718
806
  }
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"}` };
807
+ if (behind > 0 && ahead > 0) {
808
+ add("sync", "sync", `${ahead} ahead, ${behind} behind — diverged`, "rebase your commits onto upstream and push (asks before any force)");
809
+ } else if (behind > 0) {
810
+ add("sync", "pull", `${behind} behind ${info.upstream ?? "upstream"}`, "update this branch from upstream", "git pull --ff-only");
723
811
  }
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" };
812
+ if (info.upstream === void 0 && dirty > 0) {
813
+ add("publish", "push", "no upstream configured", `publish ${info.branch} for the first time`, `git push -u origin ${info.branch}`);
814
+ } else if (ahead > 0) {
815
+ add("publish", "push", `${ahead} ahead of ${info.upstream ?? "upstream"}`, "push local commits to the remote", "git push");
729
816
  }
730
- if (ahead > 0) {
731
- return { command: "git push", why: `${ahead} ahead of ${info.upstream ?? "upstream"}` };
817
+ const pr = info.pr === void 0 || info.pr === null ? void 0 : info.pr;
818
+ if (pr !== void 0 && pr.number !== void 0 && pr.open !== false
819
+ && (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED"))) {
820
+ add("merge", `merge ${pr.number}`, `pull request ${pr.number} is ready to merge`, `merge pull request ${pr.number} (squash)`);
732
821
  }
733
- if (staged + unstaged + untracked > 0) {
734
- const why = [
822
+ if (dirty > 0) {
823
+ const parts = [
735
824
  staged > 0 ? `${staged} staged` : null,
736
825
  unstaged > 0 ? `${unstaged} unstaged` : null,
737
826
  untracked > 0 ? `${untracked} untracked` : null
@@ -741,12 +830,20 @@ function nextStep(info) {
741
830
  : untracked > 0 && unstaged === 0
742
831
  ? "git add -A && git commit"
743
832
  : "git add -p && git commit";
744
- return { command, why: `work to commit: ${why}` };
833
+ add("commit", "commit", `work to commit: ${parts}`, staged > 0 ? "commit the staged changes" : "stage and commit the working changes", command);
745
834
  }
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}` };
835
+ if (pr !== void 0 && pr.number !== void 0 && pr.state === "failing") {
836
+ 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
837
  }
749
- return null;
838
+ if (rules.length === 0) return null;
839
+
840
+ const order = nextOrder();
841
+ const rank = (cat) => {
842
+ const at = order.indexOf(cat);
843
+ return at === -1 ? order.length : at;
844
+ };
845
+ const [top] = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat));
846
+ return { args: top.args, command: top.command, why: top.why, what: top.what };
750
847
  }
751
848
 
752
849
  /**
@@ -850,16 +947,103 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
850
947
  behind: parsed.behind
851
948
  };
852
949
  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);
950
+ // The ✎n NAMES — the one part of the count the numbers cannot answer
951
+ // ("what are these?"). Hover-gated with the rest of detail=1, so no
952
+ // refresh pays for it. Posture: RELATIVE names only (git's own output,
953
+ // never absolute — AGENTS.md rule 7 is relaxed by an inch, not a mile),
954
+ // shortened to the last two path segments, capped at 20 with the total
955
+ // carried separately so the client can say "… and k more" honestly.
956
+ // Staged and unstaged names are TRACKED files: the collapsed untracked
957
+ // retry never affects them, so they ride regardless of untrackedMode.
958
+ const capped = (names) => names.slice(0, 20).map(shortenPath);
959
+ if (untrackedMode === "all" && parsed.untrackedNames.length > 0) {
960
+ info.untrackedNames = capped(parsed.untrackedNames);
961
+ info.untrackedNamesTotal = parsed.untrackedNames.length;
962
+ }
963
+ if (parsed.unstagedNames.length > 0) {
964
+ info.unstagedNames = capped(parsed.unstagedNames);
965
+ info.unstagedNamesTotal = parsed.unstagedNames.length;
966
+ }
967
+ if (parsed.stagedNames.length > 0) {
968
+ info.stagedNames = capped(parsed.stagedNames);
969
+ info.stagedNamesTotal = parsed.stagedNames.length;
970
+ }
971
+ // The signed commit list and the counts-vs-main row: ahead/behind against origin/main (then
972
+ // origin/master) is the merge-state line — "3 ahead, 0 behind" reads as
973
+ // "this is the PR's content"; "0 ahead, 5 behind" as "stale, rebase
974
+ // first". One rev-list --left-right, hover-gated like everything here,
975
+ // absent entirely when both are zero (in sync needs no row).
976
+ // The repo's WEB URL — the branch hover links the branch name to the
977
+ // compare view (`<repo>/compare/<base>...<branch>`), and that URL must be
978
+ // vouched for like pr.url is: http(s) only, rebuilt from the parsed parts
979
+ // so no payload value can reach an anchor as a scheme. Derived from
980
+ // `remote get-url origin` with ssh syntax converted; absent when there is
981
+ // no origin or the URL is not recognisably a hosted repo.
982
+ // These reads are independent — one batch, so hover latency is the
983
+ // longest call rather than their sum. The only dependent call (the
984
+ // left-right count needs a probed base ref) runs after the batch and
985
+ // tolerates its own failure.
986
+ const [remoteUrl, probeMain, probeMaster, logOut2, stashOut] = await Promise.all([
987
+ runGit(toplevel, ["remote", "get-url", "origin"]),
988
+ runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/main"]),
989
+ runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/master"]),
990
+ runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", "HEAD"]),
991
+ runGit(toplevel, ["stash", "list"])
992
+ ]);
993
+ if (remoteUrl.stdout !== null && remoteUrl.stdout.trim() !== "") {
994
+ info.repoUrl = remoteToWebUrl(remoteUrl.stdout);
995
+ }
996
+ const baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
997
+ ? "origin/main"
998
+ : probeMaster.stdout !== null && probeMaster.stdout.trim() !== "" ? "origin/master" : null;
999
+ if (baseRef !== null && info.repoUrl !== void 0) {
1000
+ const baseName = baseRef.replace(/^origin\//u, "");
1001
+ try {
1002
+ const compare = new URL(info.repoUrl + "/compare/" + encodeURIComponent(baseName) + "..." + encodeURIComponent(info.branch));
1003
+ if (compare.protocol === "https:") info.compareUrl = compare.href;
1004
+ } catch {
1005
+ // absent rather than wrong
1006
+ }
1007
+ }
1008
+ if (baseRef !== null) {
1009
+ const lrOut = await runGit(toplevel, ["rev-list", "--count", "--left-right", baseRef + "...HEAD"]);
1010
+ const [mBehind, mAhead] = (lrOut.stdout ?? "").trim().split("\t").map((v) => Number.parseInt(v, 10));
1011
+ if (Number.isFinite(mBehind) && Number.isFinite(mAhead) && (mBehind > 0 || mAhead > 0)) {
1012
+ info.mainAhead = mAhead;
1013
+ info.mainBehind = mBehind;
1014
+ }
1015
+ }
1016
+ if (baseRef !== null) {
1017
+ // The signed commit list: mixed newest-first from base...HEAD, each
1018
+ // entry signed "+" (only on this branch - the PR's content) or "−"
1019
+ // (only on main - what a rebase/merge brings in), git's own left-right
1020
+ // verdict mapped onto the diff convention. Capped at 10 with the total
1021
+ // from the same base so "... and k more" counts both sides. Without a
1022
+ // default branch the list degrades to the plain last 10 on HEAD.
1023
+ const signedOut = await runGit(toplevel, ["log", "--left-right", "-10", "--format=%m%x09%h%x09%s%x09%cr", baseRef + "...HEAD"]);
1024
+ if (signedOut.stdout !== null && signedOut.stdout.trim() !== "") {
1025
+ info.branchCommits = signedOut.stdout.trim().split("\n").map((line) => {
1026
+ const [marker, hash, subject, when] = line.split("\t");
1027
+ return {
1028
+ sign: marker === ">" ? "+" : marker === "<" ? "−" : void 0,
1029
+ hash,
1030
+ subject: subject ?? "",
1031
+ when: when ?? ""
1032
+ };
1033
+ }).filter((c) => c.hash !== void 0);
1034
+ const totalOut = await runGit(toplevel, ["rev-list", "--count", baseRef + "...HEAD"]);
1035
+ const total = Number.parseInt((totalOut.stdout ?? "").trim(), 10);
1036
+ if (Number.isFinite(total)) info.branchCommitsTotal = total;
1037
+ }
1038
+ } else {
1039
+ const plainOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", "HEAD"]);
1040
+ if (plainOut.stdout !== null && plainOut.stdout.trim() !== "") {
1041
+ info.branchCommits = plainOut.stdout.trim().split("\n").map((line) => {
1042
+ const [hash, subject, when] = line.split("\t");
1043
+ return { hash, subject: subject ?? "", when: when ?? "" };
1044
+ }).filter((c) => c.hash !== void 0);
1045
+ }
860
1046
  }
861
- // stash count, only surfaced when nonzero
862
- const stashOut = await runGit(toplevel, ["stash", "list"]);
863
1047
  if (stashOut.stdout !== null) {
864
1048
  const count = stashOut.stdout.split("\n").filter((l) => l.trim() !== "").length;
865
1049
  if (count > 0) info.stashCount = count;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Git status badges for DeepSeek Harness: an input-row chip on every install, plus sidebar session-row badges (git status, PR/CI action tokens) after one manual step: `npx dsh-git-badge apply`, then restart dsh web. Event-driven freshness over SSE.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/skill/gh/SKILL.md CHANGED
@@ -59,6 +59,41 @@ unstaged / untracked / unmerged), plus any in-progress operation marker
59
59
  - **`/gh checks <n>`** — `gh pr checks <n>` and summarise: what fails, what is
60
60
  pending, and the single most useful next step.
61
61
 
62
+ - **`/gh sync`** — the branch has diverged (local commits AND upstream
63
+ changes): `git fetch`, then rebase the local commits onto
64
+ `upstream/<branch>`, then `git push --force-with-lease`. The force-push
65
+ ALWAYS needs an explicit user yes first, with a one-line summary of what
66
+ would move.
67
+
68
+ - **`/gh merge <n>`** — squash-merge pull request `<n>` (`gh pr merge <n>
69
+ --squash`), the repo's stated convention. Confirm with the user before
70
+ merging; afterwards report the merge and offer to update the local main.
71
+
72
+ - **`/gh stash pop`** — with stashes present: show the top entry's subject,
73
+ confirm with the user, then `git stash pop`. If pop conflicts, report the
74
+ conflict verbatim and note that the stash is NOT dropped — suggest
75
+ `git stash apply` as the retry that keeps it.
76
+
77
+ - **`/gh checkout <branch>`** — switch to an existing local branch. Establish
78
+ state first: uncommitted changes may block or travel with the checkout — say
79
+ what is dirty and confirm the switch when work would carry over. Unknown
80
+ branch → say so and list the closest local names.
81
+
82
+ - **`/gh order`** — reconfigure which action class the badge ranks first. The
83
+ order is one line of JSON at `~/.dsh/git-badge-next.json`:
84
+ `{ "order": ["operation", "unmerged", "sync", "publish", "merge", "commit",
85
+ "checks"] }` — any subset, any order; unlisted categories rank after in
86
+ default order. Read the file (it may not exist yet), apply the requested
87
+ change, show the resulting order, and never invent categories. Typical
88
+ invocations: "/gh order commit first", "/gh order never suggest checks
89
+ before push".
90
+
91
+ ## Handoff
92
+
93
+ One verb per invocation is this skill's whole job. Anything multi-step around a
94
+ pull request — review, merge, verify it landed, clean up the branch — belongs to
95
+ the `gh-pr` skill; hand off rather than improvise a lifecycle.
96
+
62
97
  ## Hard rules
63
98
 
64
99
  - Never `git reset --hard`, `git clean`, `git checkout -- .`, `git push