dsh-git-badge 0.14.1 → 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,130 @@ 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
+
13
137
  ## [0.14.1] - 2026-09-15
14
138
 
15
139
  ### Changed
package/lib/client.js CHANGED
@@ -233,41 +233,42 @@ window.__ModuleLoader__.load({
233
233
 
234
234
  /**
235
235
  * The /gh picker's sub-actions — the skill invocations the checkout
236
- * justifies, most urgent first. Same ranking the server's nextStep uses,
237
- * but spoken in skill arguments; the generic "next" entry is skipped when
238
- * a specific rule already names the same situation (its why would be
239
- * 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.
240
240
  */
241
241
  function ghSkillActions(info) {
242
242
  if (info === void 0 || info === null || info.git !== true) return [];
243
243
  const subs = [];
244
244
  const seen = new Set();
245
- const add = (args, why) => {
246
- if (seen.has(args)) return;
245
+ const add = (args, why, what) => {
246
+ if (args === void 0 || args === null || args === "" || seen.has(args)) return;
247
247
  seen.add(args);
248
- subs.push({ args, why });
248
+ subs.push({ args, why, what });
249
249
  };
250
+ const next = info.next;
251
+ if (next !== void 0 && next !== null) add(next.args, next.why, next.what);
250
252
  const ahead = info.ahead || 0;
251
253
  const behind = info.behind || 0;
252
254
  const staged = info.stagedFiles || 0;
253
255
  const unstaged = info.unstagedFiles || 0;
254
256
  const untracked = info.untrackedFiles || 0;
255
- if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"));
256
- if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"));
257
- if (staged > 0) add("commit", staged + " staged");
258
- if (unstaged > 0) add("commit", unstaged + " unstaged");
259
- 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");
260
266
  const pr = info.pr;
261
267
  if (pr !== void 0 && pr !== null && pr.number !== void 0) {
262
- if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number);
263
- 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");
264
270
  } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
265
- add("pr", "branch is pushed and has no pull request");
266
- }
267
- const next = info.next;
268
- if (next !== void 0 && next !== null && typeof next.command === "string"
269
- && !subs.some((entry) => entry.why === next.why)) {
270
- add("next", next.why);
271
+ add("pr", "branch is pushed and has no pull request", "open a pull request for this branch");
271
272
  }
272
273
  return subs.slice(0, 6);
273
274
  }
@@ -658,12 +659,178 @@ window.__ModuleLoader__.load({
658
659
  gap: "4px",
659
660
  fontSize: "12px",
660
661
  lineHeight: "18px",
661
- 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"
662
667
  };
668
+ /**
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).
678
+ */
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
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));
745
+ };
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", {
799
+ style: {
800
+ color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
801
+ fontFamily: "monospace",
802
+ display: "inline-block",
803
+ minWidth: "68px",
804
+ flex: "none"
805
+ },
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
+
663
819
  const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
664
820
  const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
665
821
  const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
666
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);
832
+ }
833
+
667
834
 
668
835
  /**
669
836
  * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
@@ -678,15 +845,7 @@ window.__ModuleLoader__.load({
678
845
  const rows = [];
679
846
  const add = (label, value) => {
680
847
  if (value === void 0 || value === null || value === "") return;
681
- rows.push(
682
- react_jsx_runtime.jsxs("div", {
683
- style: CARD_ROW,
684
- children: [
685
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
686
- react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
687
- ]
688
- }, label)
689
- );
848
+ rows.push(cardRow(label, value));
690
849
  };
691
850
  // The "action" row — the top line, in the card's ordinary row layout:
692
851
  // the gh skill invocation the checkout justifies, derived from the same
@@ -695,16 +854,16 @@ window.__ModuleLoader__.load({
695
854
  // this row is the read-only pointer to it.
696
855
  const topAction = ghSkillActions(data)[0];
697
856
  if (topAction !== void 0) {
698
- add("action:", "/gh " + topAction.args);
699
- }
700
- add("branch", info.branch);
701
- add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
702
- if (info.ahead !== void 0 || info.behind !== void 0) {
703
- add("sync", "\u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0));
857
+ // git-comment convention: the invocation, two spaces, then "# "
858
+ // plus what it does
859
+ add("action", "/gh " + topAction.args + " # " + topAction.what);
704
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.
705
865
  add("files", formatFileBreakdown(info));
706
866
  add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
707
- add("pull request", formatPrDetail(data.pr));
708
867
  // WHICH checkout the badge describes, when it is a worktree. The chip shows
709
868
  // the branch alone, so this row is the only place the tree is named.
710
869
  add("worktree", worktreeDetail(data));
@@ -713,27 +872,6 @@ window.__ModuleLoader__.load({
713
872
  // tree's in that case, so this is where the checkout's own state stays
714
873
  // visible. Absent until the `detail=1` response lands, like commits/stash.
715
874
  if (data.worktreeInferred === true) add("checkout", formatCheckoutDetail(data.checkout));
716
- if (Array.isArray(data.lastCommits) && data.lastCommits.length > 0) {
717
- rows.push(
718
- react_jsx_runtime.jsxs("div", {
719
- style: CARD_ROW,
720
- children: [
721
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: "commits" }),
722
- react_jsx_runtime.jsx("span", {
723
- style: { ...CARD_VALUE, display: "flex", flexDirection: "column", gap: "2px" },
724
- children: data.lastCommits.map((commit) =>
725
- react_jsx_runtime.jsxs("span", {
726
- children: [
727
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: commit.hash }),
728
- " " + commit.subject + (commit.when === "" ? "" : " \u00B7 " + commit.when)
729
- ]
730
- }, commit.hash)
731
- )
732
- })
733
- ]
734
- }, "commits")
735
- );
736
- }
737
875
  add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
738
876
  return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
739
877
  }
@@ -786,6 +924,11 @@ window.__ModuleLoader__.load({
786
924
  // re-render that no fetch will schedule, so the × bumps a counter here.
787
925
  const [, bumpNotice] = react.useState(0);
788
926
  const seamNotice = seamHint !== null && !seamNoticeDismissed ? seamHint : null;
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);
789
932
  const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
790
933
  if (info === void 0 || info.git !== true) return null;
791
934
 
@@ -803,7 +946,8 @@ window.__ModuleLoader__.load({
803
946
  if (Tooltip === void 0) return mark;
804
947
  return react_jsx_runtime.jsx(Tooltip, {
805
948
  side: "top",
806
- maxWidth: 360,
949
+ // matches the body cap: the tooltip must never be the clamp
950
+ maxWidth: 600,
807
951
  // a function label keeps the card's element tree out of every render
808
952
  // until the tooltip actually opens
809
953
  label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
@@ -813,7 +957,74 @@ window.__ModuleLoader__.load({
813
957
  // the mark is an element now rather than a leading glyph in the string, so
814
958
  // the SAME StatusMark the sidebar row draws carries the status here too;
815
959
  // the container's 4px gap supplies the space the emoji's own did
816
- 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
+ })();
817
1028
  const prToken = formatPrToken(info);
818
1029
  const prUrl = prLinkUrl(info);
819
1030
  const chip = react_jsx_runtime.jsxs("span", {
@@ -846,7 +1057,9 @@ window.__ModuleLoader__.load({
846
1057
  // stays on the whole chip — it is about the badge, not the mark.
847
1058
  hoverableMark,
848
1059
 
1060
+ branchHover,
849
1061
  react_jsx_runtime.jsx("span", { key: "text", children: text }),
1062
+ countHover,
850
1063
  prToken === ""
851
1064
  ? null
852
1065
  : prUrl === void 0
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.1",
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