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 +137 -0
- package/lib/client.js +279 -244
- package/lib/index.js +237 -53
- package/package.json +1 -1
- package/skill/gh/SKILL.md +35 -0
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.
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
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
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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:
|
|
763
|
-
},
|
|
764
|
-
"
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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 "
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
//
|
|
898
|
-
//
|
|
899
|
-
// the
|
|
900
|
-
//
|
|
901
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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,
|
|
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] !== ".")
|
|
547
|
-
|
|
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("? "))
|
|
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
|
|
682
|
-
*
|
|
683
|
-
*
|
|
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
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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.
|
|
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
|
-
|
|
710
|
-
|
|
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
|
-
|
|
805
|
+
add("unmerged", "next", `${unmerged} unmerged file${plural(unmerged)} to resolve`, "resolve the unmerged files (with you)", "git status");
|
|
718
806
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (behind > 0) {
|
|
722
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
731
|
-
|
|
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 (
|
|
734
|
-
const
|
|
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
|
-
|
|
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 (
|
|
747
|
-
|
|
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
|
-
//
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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.
|
|
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
|