dsh-git-badge 0.16.1 → 0.17.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 +107 -0
- package/README.md +2 -2
- package/lib/client.js +254 -133
- package/lib/index.js +139 -8
- package/package.json +1 -1
- package/skill/gh/SKILL.md +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,113 @@ npm. 0.6.0 is the first release recorded here.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.17.0] - 2026-09-16
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **The PR token has its own hover: the commits in that pull request.** `PR#47 ✓`
|
|
18
|
+
now shows the commits on the branch that are not on the base
|
|
19
|
+
(`git log <base>..HEAD`, capped at 10 with "… and k more"), in the same line
|
|
20
|
+
format as the branch hover's list — hash, subject, compressed age, full-width
|
|
21
|
+
with ellipsis. Nothing else rides it: the token already states the number and
|
|
22
|
+
CI state, so the one question left is what is in it. Hover-gated with the rest
|
|
23
|
+
of `detail=1`, and the total reuses the ahead count already computed.
|
|
24
|
+
- **`/gh clean [<path>...]` appears in the `/gh` menu behind a risk gate.** The
|
|
25
|
+
menu is built from status, not from this skill's prose, so the verb is offered
|
|
26
|
+
there whenever untracked files exist — always last, never as the badge's
|
|
27
|
+
suggested action, and carrying the host's own `RiskConfirmation`: Confirm
|
|
28
|
+
stays disabled until "I understand untracked files cannot be recovered" is
|
|
29
|
+
ticked. That gate names the risk; the agent then names the files
|
|
30
|
+
(`git clean -n`, quoted back) and asks again before `git clean -f`.
|
|
31
|
+
- **A `/gh clean [<path>...]` verb, confirm-first by construction.** Removing
|
|
32
|
+
untracked files is the one operation with no reflog and no stash entry, so the
|
|
33
|
+
verb's first step is always `git clean -n -- <paths>` with the output quoted
|
|
34
|
+
back verbatim (the user approves the list, not the idea), then an explicit yes,
|
|
35
|
+
then `git clean -f -- <paths>` on named paths only. `-x`, `-d` and bare
|
|
36
|
+
`git clean -f` are forbidden — ignored files are often credentials and `-d`
|
|
37
|
+
turns one path into a tree. It stays out of the badge's ranked suggestions: a
|
|
38
|
+
destructive action the agent cannot infer intent for is never suggested, only
|
|
39
|
+
available on request.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **The session row's action token only appears when the session is idle.** A turn
|
|
44
|
+
in flight is exactly when a git suggestion is noise: the checkout is still
|
|
45
|
+
moving and the agent has not finished. The row reads its run state from the
|
|
46
|
+
shell's standard kit — `useSessions`, the same selector hook the workspace UI
|
|
47
|
+
itself uses — and renders nothing while that session is `running`. The filter is
|
|
48
|
+
on render, not on the fetch, so the row's hover line is unaffected. On a shell
|
|
49
|
+
that seeds no such hook the row behaves as before, and the console says which of
|
|
50
|
+
the two it got.
|
|
51
|
+
- **The PR/CI token stops lagging behind the forge.** The forge read is
|
|
52
|
+
TTL-bounded and its refresh is request-driven, while an idle chip only asks
|
|
53
|
+
every 60s — so the old 90s `prTtlMs` silently suppressed every other poll, and
|
|
54
|
+
a pull request that appeared on GitHub with no local git event to announce it
|
|
55
|
+
could take ~2.5 minutes to show up. At 20s the TTL no longer outlives the
|
|
56
|
+
client's poll, so the first refresh after the forge changes picks it up and the
|
|
57
|
+
worst case falls to roughly the poll interval. Cost: up to one `gh pr view` per
|
|
58
|
+
open chip per minute, where it was one per ~2 minutes.
|
|
59
|
+
- **The branch hover shows only the merge axis.** `upstream` and `sync` are gone
|
|
60
|
+
— they stated the same divergence against the branch's own remote, in arrows,
|
|
61
|
+
when `merge` already states it against the base branch in words. The `merge`
|
|
62
|
+
row now ALWAYS renders: `ready to merge`, `blocked: draft | review required |
|
|
63
|
+
checks failing`, `no pull request`, each joined with `N ahead, M behind main`
|
|
64
|
+
when those counts are known. A row that can be absent is a row the reader has
|
|
65
|
+
to reconstruct.
|
|
66
|
+
- The commit cells lost their trailing colon (a later change had reintroduced it
|
|
67
|
+
after the earlier removal), so both hovers read `abc1234 subject · 2h ago`.
|
|
68
|
+
- **One action list, built by the server.** Both halves used to derive the
|
|
69
|
+
pull/push/commit/pr conditions, and the copies drifted: the card once promoted
|
|
70
|
+
a client-only extra into its action row, and `/gh pr` was offered on the
|
|
71
|
+
default branch. The node half now publishes `actions` — the ranked rules in the
|
|
72
|
+
configured order with the top entry flagged `primary` (also served as `next`,
|
|
73
|
+
on every response), then the standing options (`pr view <n>`, `pr`, and the
|
|
74
|
+
flagged `clean`). `merge <n>` becomes a standing entry whenever the PR is
|
|
75
|
+
mergeable, so it now appears alongside a primary `push`/`pull` instead of only
|
|
76
|
+
when it wins the ranking. The client renders the list verbatim and keeps only
|
|
77
|
+
presentation — labels and the clean risk gate; `actions` rides the `pr=1`
|
|
78
|
+
payload only, so sidebar rows stay lean. Net: ~65 lines of duplicated client
|
|
79
|
+
rules deleted, and the conditions are unit-tested where the data lives.
|
|
80
|
+
- **The upstream sync numbers ride the branch's hover and link.** `↑a ↓b` were
|
|
81
|
+
rendered inside the file count's names tooltip and outside the branch's compare
|
|
82
|
+
link, though they are the branch's standing against its upstream — so they now
|
|
83
|
+
sit with the branch name: same lineage hover, same compare-view link, and the
|
|
84
|
+
link's accessible name says "N ahead and M behind upstream". `✎n` keeps its own
|
|
85
|
+
names tooltip. Visible side effect: the arrows render before a paused-operation
|
|
86
|
+
token rather than after it.
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- **The PR token's hover lines its commits up like the branch hover.** Both lists
|
|
91
|
+
now come from one renderer: a fixed-width monospace hash cell, so every
|
|
92
|
+
description starts on the same column and the list reads as a table. The PR
|
|
93
|
+
list had reused the card's label style, whose `minWidth` and `flex` only apply
|
|
94
|
+
inside a flex row — on a plain inline span they did nothing, so the hash sat
|
|
95
|
+
flush against its description with the columns ragged.
|
|
96
|
+
- **The PR token's hover is reachable by hovering the token itself.** The commits
|
|
97
|
+
it lists are detail-only, and only the status mark and the branch name enabled
|
|
98
|
+
the `detail=1` fetch — so a pointer that went straight to `PR#47 ✓` had rested
|
|
99
|
+
on nothing that fetches, and its hover could not open until the mark had been
|
|
100
|
+
hovered first. The token is now its own detail gate, in both its linked and
|
|
101
|
+
plain forms.
|
|
102
|
+
- **No more "open a pull request" on `main`, and the hover card shows only the
|
|
103
|
+
server's verdict.** Two layers disagreed: the node half correctly returned
|
|
104
|
+
nothing to do for a clean, synced default branch, while the client's extras
|
|
105
|
+
still offered `/gh pr` — and the card promoted that extra into its `action:`
|
|
106
|
+
row, so a checkout with nothing to do displayed a suggestion. The node half now
|
|
107
|
+
resolves the repository's default branch (`refs/remotes/origin/HEAD`,
|
|
108
|
+
TTL-cached and refreshed out of band like the fetch, so the hot path pays
|
|
109
|
+
nothing) and flags it on the response; the `pr` extra is suppressed when the
|
|
110
|
+
branch IS that default; and the card's action row reads the server's `next`
|
|
111
|
+
directly, leaving the extras to the `/gh` picker where they belong.
|
|
112
|
+
- **The seam suite no longer writes into your real `~/.dsh/git-badge-seam`.**
|
|
113
|
+
One block spawned the postinstall hook without `SEAM_DATA_DIR`, so the hook
|
|
114
|
+
resolved its store to the machine's actual data directory: a test run
|
|
115
|
+
overwrote the real `backup-client.js`/`backup-index.js` and left a
|
|
116
|
+
`restart-pending.json` behind. It passed on CI only because the runner's
|
|
117
|
+
`$HOME` is writable — under a read-only `$HOME` it failed with EROFS. The
|
|
118
|
+
block now takes a throwaway data dir like the rest of the file.
|
|
119
|
+
|
|
13
120
|
## [0.16.1] - 2026-09-16
|
|
14
121
|
|
|
15
122
|
### Fixed
|
package/README.md
CHANGED
|
@@ -6,12 +6,12 @@ Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek
|
|
|
6
6
|
- **Hover card** — rest a pointer on the chip for what won't fit: the file breakdown (`✎3` split into staged / unstaged / untracked), the last three commits, and the stash. Fetched only on hover, so the everyday badge pays nothing for it. Works on every install.
|
|
7
7
|
- **PR / CI token** — `● main ↑0 ↓2 PR#142 ✗` when the branch has a GitHub pull request, read through your own `gh` CLI (the plugin never handles a credential). The token **links to the PR** — it opens in a new tab, and underlines when you hover or focus it. Invisible when `gh` cannot answer: not installed, not logged in, no GitHub remote, offline or rate-limited all render the unchanged chip, and a token with no URL to link to stays plain text.
|
|
8
8
|
- **Worktree-aware** — a conversation's directory is fixed when it is created, so a conversation that does its work in a linked `git worktree` would otherwise keep reporting the main checkout. The session registers its checkout (`npx dsh-git-badge-checkout <path>`, run by the git-worktree and `/gh checkout` paths), and the badge describes **that worktree** instead — its branch, counts, tree mark and PR/CI token (`🌳 chore/global-skills-tiering ↑0 ↓2 PR#391 ✓` rather than `● main`). The hover card names the tree in a `worktree` row and adds a `checkout` row with the conversation's own directory, so the main checkout's branch and dirty state stay one hover away. Registrations are advisory: git's own worktree list validates the path, entries expire after 24h, and a workspace-targeted row always reports the checkout the registry owns.
|
|
9
|
-
- **Sidebar session-row action token** — each session row shows the ONE thing that conversation needs from you, or nothing at all: `merge`, `fix CI`, `review`, `resolve`, `pull`, `push`. It is triage rather than status: `merge` appears only when GitHub itself reports `mergeStateStatus: CLEAN`, and every waiting state (draft, blocked, behind, checks still running) stays silent. Hovering the row adds a line naming the checkout and the pull request the action refers to. The token floats to the right of the row, is set heavier than the timestamp beside it, and is coloured by how much it needs you — red for a conflict or a failing build, amber for a requested review, green for `merge`, quiet grey for routine sync. Requires the `sidebar.workspaces.sessionRow` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
|
|
9
|
+
- **Sidebar session-row action token** — each session row shows the ONE thing that conversation needs from you, or nothing at all: `merge`, `fix CI`, `review`, `resolve`, `pull`, `push`. It is triage rather than status: `merge` appears only when GitHub itself reports `mergeStateStatus: CLEAN`, and every waiting state (draft, blocked, behind, checks still running) stays silent. Hovering the row adds a line naming the checkout and the pull request the action refers to. The token floats to the right of the row, is set heavier than the timestamp beside it, and is coloured by how much it needs you — red for a conflict or a failing build, amber for a requested review, green for `merge`, quiet grey for routine sync. It appears only when the session is **idle** — while a turn is running the row stays quiet, because the checkout is still moving and a suggestion would describe a state that is about to change. Requires the `sidebar.workspaces.sessionRow` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
|
|
10
10
|
- **Nothing on workspace rows.** A workspace cannot know which worktree its conversations are using, so a per-workspace badge could only guess or report the main checkout. The seam is on the session row for that reason.
|
|
11
11
|
|
|
12
12
|
The leading **mark** belongs to the input chip: a circle filled with the status colour, or a **tree** when the checkout is a linked `git worktree`. The sidebar row deliberately carries no mark — see the row bullet above, where one word does the job a colour cannot. (`●`/`🌳` stand in for it here — it is a drawn SVG so that it can take the theme's status colours.)
|
|
13
13
|
|
|
14
|
-
Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit, including inside a linked worktree the session registered. One `git status --porcelain=v2` per sample, shared by every surface pointing at the same checkout, plus at most one `gh` read per repository-and-branch per ~
|
|
14
|
+
Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit, including inside a linked worktree the session registered. One `git status --porcelain=v2` per sample, shared by every surface pointing at the same checkout, plus at most one `gh` read per repository-and-branch per ~20s. The status API takes **no path**: the caller passes a session id (chip and sidebar rows) or a workspace id, and the server resolves the directory itself.
|
|
15
15
|
|
|
16
16
|
Diagrams, the hover card's full field list and a walkthrough for git newcomers live
|
|
17
17
|
in the [repo README](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) —
|
package/lib/client.js
CHANGED
|
@@ -232,45 +232,40 @@ window.__ModuleLoader__.load({
|
|
|
232
232
|
|
|
233
233
|
|
|
234
234
|
/**
|
|
235
|
-
* The
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
235
|
+
* The actions BOTH surfaces render — the server's list, verbatim.
|
|
236
|
+
*
|
|
237
|
+
* This half used to re-derive pull/push/commit/pr from the raw counts,
|
|
238
|
+
* which meant two implementations of the same conditions drifting apart:
|
|
239
|
+
* the card once promoted a client-only extra into its action row, and
|
|
240
|
+
* `/gh pr` was offered on the default branch. The node half owns the
|
|
241
|
+
* rules and the ranking now; the client adds presentation only — the risk
|
|
242
|
+
* gate for `clean`, and the labels.
|
|
243
|
+
*
|
|
244
|
+
* Falls back to the single `next` for a payload from an older node half.
|
|
240
245
|
*/
|
|
241
246
|
function ghSkillActions(info) {
|
|
242
247
|
if (info === void 0 || info === null || info.git !== true) return [];
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
248
|
+
if (Array.isArray(info.actions)) return info.actions;
|
|
249
|
+
return info.next === void 0 || info.next === null ? [] : [info.next];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The risk gate the menu shows for `/gh clean`, in the host's own
|
|
254
|
+
* confirmation shape (RiskConfirmation: title, description, an acknowledge
|
|
255
|
+
* checkbox, two button labels). Confirm stays disabled until the box is
|
|
256
|
+
* ticked — that is the "are you sure?", before the invocation is even sent.
|
|
257
|
+
* The agent then re-establishes the exact list with `git clean -n` and asks
|
|
258
|
+
* again: two gates, because this one names the risk and that one names the
|
|
259
|
+
* files.
|
|
260
|
+
*/
|
|
261
|
+
function cleanGate() {
|
|
262
|
+
return {
|
|
263
|
+
title: "Remove untracked files?",
|
|
264
|
+
description: "Untracked files have no reflog and no stash entry — once removed they cannot be recovered. The agent will show the exact list (`git clean -n`) and ask again before removing anything.",
|
|
265
|
+
acknowledgeLabel: "I understand untracked files cannot be recovered",
|
|
266
|
+
cancelLabel: "Cancel",
|
|
267
|
+
confirmLabel: "Continue"
|
|
249
268
|
};
|
|
250
|
-
const next = info.next;
|
|
251
|
-
if (next !== void 0 && next !== null) add(next.args, next.why, next.what);
|
|
252
|
-
const ahead = info.ahead || 0;
|
|
253
|
-
const behind = info.behind || 0;
|
|
254
|
-
const staged = info.stagedFiles || 0;
|
|
255
|
-
const unstaged = info.unstagedFiles || 0;
|
|
256
|
-
const untracked = info.untrackedFiles || 0;
|
|
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");
|
|
266
|
-
const pr = info.pr;
|
|
267
|
-
if (pr !== void 0 && pr !== null && pr.number !== void 0) {
|
|
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");
|
|
270
|
-
} else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
|
|
271
|
-
add("pr", "branch is pushed and has no pull request", "open a pull request for this branch");
|
|
272
|
-
}
|
|
273
|
-
return subs.slice(0, 6);
|
|
274
269
|
}
|
|
275
270
|
|
|
276
271
|
/**
|
|
@@ -305,16 +300,29 @@ window.__ModuleLoader__.load({
|
|
|
305
300
|
* - clean workspaces stay quiet: arrows only when nonzero, no ✎ at 0
|
|
306
301
|
* - no upstream: arrows omitted entirely
|
|
307
302
|
*/
|
|
308
|
-
|
|
309
|
-
|
|
303
|
+
/**
|
|
304
|
+
* The upstream sync arrows (" ↑1 ↓2"), or "". Split out from the file
|
|
305
|
+
* count so each rides the surface that OWNS it: the arrows belong to the
|
|
306
|
+
* branch (they are the branch's standing against its upstream, and the
|
|
307
|
+
* branch name's hover and compare link cover them), while ✎n belongs to the
|
|
308
|
+
* count's names tooltip.
|
|
309
|
+
*/
|
|
310
|
+
function formatSync(info) {
|
|
310
311
|
const hasUpstream = info.ahead !== void 0 || info.behind !== void 0;
|
|
311
312
|
const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
|
|
312
|
-
if (hasUpstream
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
313
|
+
if (!hasUpstream || (files === 0 && !(info.ahead > 0) && !(info.behind > 0))) return "";
|
|
314
|
+
return " \u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The ✎n file count (" ✎3"), or "". */
|
|
318
|
+
function formatFileCount(info) {
|
|
319
|
+
const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
|
|
320
|
+
return files > 0 ? " \u270E" + files : "";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Both halves, in chip order — kept for callers that want the whole suffix. */
|
|
324
|
+
function formatGitSuffix(info) {
|
|
325
|
+
return formatSync(info) + formatFileCount(info);
|
|
318
326
|
}
|
|
319
327
|
|
|
320
328
|
/**
|
|
@@ -544,9 +552,57 @@ window.__ModuleLoader__.load({
|
|
|
544
552
|
*/
|
|
545
553
|
const ACTION_STYLE = { flex: "none", marginLeft: "auto", marginRight: "8px", fontWeight: 500 };
|
|
546
554
|
|
|
547
|
-
/**
|
|
548
|
-
|
|
555
|
+
/**
|
|
556
|
+
* The row's own run state, from the shell's standard kit.
|
|
557
|
+
*
|
|
558
|
+
* The renderer that assembles slots hands EVERY entry the sessions store as
|
|
559
|
+
* a selector hook (`useSessions`) — the same kit that supplies
|
|
560
|
+
* `useWorkspaces` — which is how a row can tell an idle session from one
|
|
561
|
+
* mid-turn without the seam having to carry it. A shell that does not seed
|
|
562
|
+
* that kit leaves the prop undefined: then we cannot know, and the badge
|
|
563
|
+
* behaves exactly as it did before rather than guessing "idle".
|
|
564
|
+
*
|
|
565
|
+
* One helper and one call site, so the hook order is identical on every path
|
|
566
|
+
* (the fallback is a plain function, never a hook).
|
|
567
|
+
*/
|
|
568
|
+
function useSessionRunning(useSessions, sessionId) {
|
|
569
|
+
const select = typeof useSessions === "function" ? useSessions : () => void 0;
|
|
570
|
+
return select((state) => state?.byId?.[sessionId]?.running) === true;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Report the idle gate ONCE, the way the boot diagnostics report the seam and
|
|
575
|
+
* the Tooltip primitive. Whether the shell actually handed a slot the
|
|
576
|
+
* sessions store is invisible from anywhere else, and a gate that silently
|
|
577
|
+
* never fires is indistinguishable from a gate that never needed to.
|
|
578
|
+
*/
|
|
579
|
+
let idleGateReported = false;
|
|
580
|
+
function reportIdleGate(present) {
|
|
581
|
+
if (idleGateReported) return;
|
|
582
|
+
idleGateReported = true;
|
|
583
|
+
console.info(
|
|
584
|
+
"[dsh-git-badge] session-row idle gate = " +
|
|
585
|
+
(present
|
|
586
|
+
? "on (the standard kit supplies useSessions)."
|
|
587
|
+
: "off (no useSessions prop) — the token cannot tell a running turn, so it shows as before.")
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The action badge: floated, weighted, and coloured by severity — and shown
|
|
593
|
+
* only when the session is IDLE. A turn in flight is precisely when a git
|
|
594
|
+
* suggestion is noise: the checkout is still moving and the agent has not
|
|
595
|
+
* finished, so the action describes a state that is about to change. Nothing
|
|
596
|
+
* about the action itself changes; the gate is the whole feature.
|
|
597
|
+
*
|
|
598
|
+
* The filter is on RENDER, not on the fetch: hiding the row must not take the
|
|
599
|
+
* hover card's detail line down with it — both read the same cached status.
|
|
600
|
+
*/
|
|
601
|
+
function SessionGitBadge({ sessionId, useSessions }) {
|
|
549
602
|
const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true });
|
|
603
|
+
const running = useSessionRunning(useSessions, sessionId);
|
|
604
|
+
reportIdleGate(typeof useSessions === "function");
|
|
605
|
+
if (running) return null;
|
|
550
606
|
if (sessionId === void 0 || info === void 0 || info.git !== true) return null;
|
|
551
607
|
const action = actionToken(info);
|
|
552
608
|
if (action === "") return null;
|
|
@@ -651,6 +707,64 @@ window.__ModuleLoader__.load({
|
|
|
651
707
|
return name;
|
|
652
708
|
}
|
|
653
709
|
|
|
710
|
+
/**
|
|
711
|
+
* ONE commit line, shared by the branch hover and the PR token's hover so
|
|
712
|
+
* the two cannot drift apart again: a fixed-width monospace hash cell, so
|
|
713
|
+
* every description starts on the same column and the list reads as a
|
|
714
|
+
* table, then `subject · age`. `sign` belongs to the branch hover — "+"
|
|
715
|
+
* (only on this branch) or "−" (only on main); the PR list passes none,
|
|
716
|
+
* because all of its commits are `base..HEAD`.
|
|
717
|
+
*
|
|
718
|
+
* The cell needs `display: inline-block` for `minWidth` to mean anything: an
|
|
719
|
+
* inline span ignores it, which is exactly how the PR list came to render a
|
|
720
|
+
* hash with NO gap at all — it reused the card's label style, whose
|
|
721
|
+
* `minWidth` and `flex` only do anything inside a flex row.
|
|
722
|
+
*/
|
|
723
|
+
function CommitLine({ commit, sign = "" }) {
|
|
724
|
+
return react_jsx_runtime.jsxs("span", {
|
|
725
|
+
style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
|
|
726
|
+
children: [
|
|
727
|
+
react_jsx_runtime.jsx("span", {
|
|
728
|
+
style: {
|
|
729
|
+
color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
|
|
730
|
+
fontFamily: "monospace",
|
|
731
|
+
display: "inline-block",
|
|
732
|
+
minWidth: "68px",
|
|
733
|
+
flex: "none"
|
|
734
|
+
},
|
|
735
|
+
children: sign + commit.hash
|
|
736
|
+
}),
|
|
737
|
+
commit.subject + (commit.when === "" ? "" : " \u00B7 " + shortWhen(commit.when))
|
|
738
|
+
]
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* The PR token's own hover body — the commits IN THIS PULL REQUEST
|
|
744
|
+
* (`base..HEAD`), and nothing else: the token already says the number and
|
|
745
|
+
* CI state, so the one question left is "what is in it?". Same line format
|
|
746
|
+
* as the branch hover's commit list (see CommitLine — one renderer, so the
|
|
747
|
+
* columns line up in both by construction), and the card's own width
|
|
748
|
+
* behaviour: full-width lines, nowrap, ellipsis.
|
|
749
|
+
*/
|
|
750
|
+
function PrCommitsList({ info }) {
|
|
751
|
+
const commits = info?.prCommits;
|
|
752
|
+
if (!Array.isArray(commits) || commits.length === 0) {
|
|
753
|
+
return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no commits beyond the base branch" });
|
|
754
|
+
}
|
|
755
|
+
return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children: [
|
|
756
|
+
react_jsx_runtime.jsxs("div", {
|
|
757
|
+
style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
|
|
758
|
+
children: [
|
|
759
|
+
...commits.map((commit) => react_jsx_runtime.jsx(CommitLine, { commit }, commit.hash)),
|
|
760
|
+
(typeof info.prCommitsTotal === "number" && info.prCommitsTotal > commits.length
|
|
761
|
+
? "\u2026 and " + (info.prCommitsTotal - commits.length) + " more"
|
|
762
|
+
: null)
|
|
763
|
+
]
|
|
764
|
+
})
|
|
765
|
+
] });
|
|
766
|
+
}
|
|
767
|
+
|
|
654
768
|
const CARD_CONTAINER = {
|
|
655
769
|
display: "flex",
|
|
656
770
|
flexDirection: "column",
|
|
@@ -741,27 +855,11 @@ window.__ModuleLoader__.load({
|
|
|
741
855
|
if (value === void 0 || value === null || value === "") return;
|
|
742
856
|
rows.push(cardRow(label, value));
|
|
743
857
|
};
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// `# branch.ab` header), and falling back to 0/0 would claim "in
|
|
750
|
-
// sync" — a lie the upstream row directly contradicts. Omit it; the
|
|
751
|
-
// "none configured" row above carries the fact.
|
|
752
|
-
if (info?.upstream !== void 0) {
|
|
753
|
-
add("sync", ahead === 0 && behind === 0
|
|
754
|
-
? "in sync with upstream"
|
|
755
|
-
: "\u2191" + ahead + " \u2193" + behind);
|
|
756
|
-
}
|
|
757
|
-
// The merge-state row, in WORDS (deliberately not arrows — the sync
|
|
758
|
-
// row above already uses ↑↓ for the push axis, and two arrow pairs
|
|
759
|
-
// with different references would be read as one). "merge" is its
|
|
760
|
-
// label because it is the merge axis — the counterpart of "sync",
|
|
761
|
-
// the push axis — and it stays correct whatever the default branch
|
|
762
|
-
// is named. A BLOCKED verdict becomes the reason (review required,
|
|
763
|
-
// checks failing, draft) so a dead-end word names the next action;
|
|
764
|
-
// CLEAN stays silent — the action row already offers /gh merge.
|
|
858
|
+
// ONLY the merge axis, and it ALWAYS renders. The old upstream and sync
|
|
859
|
+
// rows spoke about the push axis (the branch's own remote), which the
|
|
860
|
+
// merge row subsumes: "1 ahead, 0 behind main" is the same fact stated
|
|
861
|
+
// against the branch that matters, in words rather than arrows. A row
|
|
862
|
+
// that can be absent is a row the reader has to reconstruct.
|
|
765
863
|
const pr = info?.pr;
|
|
766
864
|
const mergeParts = [];
|
|
767
865
|
if (pr !== void 0 && pr !== null && pr.number !== void 0) {
|
|
@@ -771,12 +869,15 @@ window.__ModuleLoader__.load({
|
|
|
771
869
|
: pr.state === "failing" ? "blocked: checks failing"
|
|
772
870
|
: "blocked");
|
|
773
871
|
} else if (pr.state === "failing") mergeParts.push("blocked: checks failing");
|
|
774
|
-
else if (pr.mergeState
|
|
872
|
+
else if (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED")) mergeParts.push("ready to merge");
|
|
873
|
+
else if (pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
|
|
874
|
+
} else {
|
|
875
|
+
mergeParts.push("no pull request");
|
|
775
876
|
}
|
|
776
877
|
if (info?.mainAhead !== void 0 || info?.mainBehind !== void 0) {
|
|
777
878
|
mergeParts.push((info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind main");
|
|
778
879
|
}
|
|
779
|
-
|
|
880
|
+
add("merge", mergeParts.join(" \u00B7 "));
|
|
780
881
|
const commits = info?.branchCommits;
|
|
781
882
|
if (Array.isArray(commits) && commits.length > 0) {
|
|
782
883
|
// Full-width block, not the label/value two-column row: commit lines
|
|
@@ -788,23 +889,9 @@ window.__ModuleLoader__.load({
|
|
|
788
889
|
react_jsx_runtime.jsxs("div", {
|
|
789
890
|
style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
|
|
790
891
|
children:
|
|
791
|
-
commits.map((commit) => react_jsx_runtime.
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
// fixed-width monospace hash cell: every description starts
|
|
795
|
-
// on the same column, so the list reads as a table
|
|
796
|
-
react_jsx_runtime.jsx("span", {
|
|
797
|
-
style: {
|
|
798
|
-
color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
|
|
799
|
-
fontFamily: "monospace",
|
|
800
|
-
display: "inline-block",
|
|
801
|
-
minWidth: "68px",
|
|
802
|
-
flex: "none"
|
|
803
|
-
},
|
|
804
|
-
children: (commit.sign === "+" ? "+" : commit.sign === "\u2212" ? "\u2212" : "") + commit.hash + ":"
|
|
805
|
-
}),
|
|
806
|
-
commit.subject + (commit.when === "" ? "" : " \u00B7 " + shortWhen(commit.when))
|
|
807
|
-
]
|
|
892
|
+
commits.map((commit) => react_jsx_runtime.jsx(CommitLine, {
|
|
893
|
+
commit,
|
|
894
|
+
sign: commit.sign === "+" ? "+" : commit.sign === "\u2212" ? "\u2212" : ""
|
|
808
895
|
}, commit.hash))
|
|
809
896
|
}, "branch-commits")
|
|
810
897
|
);
|
|
@@ -891,11 +978,13 @@ window.__ModuleLoader__.load({
|
|
|
891
978
|
rows.push(cardRow(label, value));
|
|
892
979
|
};
|
|
893
980
|
// The "action" row — the top line, in the card's ordinary row layout:
|
|
894
|
-
// the
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
|
|
981
|
+
// the SERVER's ranked suggestion, and nothing else. The client's
|
|
982
|
+
// extras (secondary picker entries) must never promote themselves into
|
|
983
|
+
// this line: when the server says there is nothing to do — clean and
|
|
984
|
+
// synced — the row is omitted, and "no suggestion" stays a suggestion.
|
|
985
|
+
// Plain text, no affordance: the actionable surface is the (+) menu's
|
|
986
|
+
// /gh picker, and this row is the read-only pointer to it.
|
|
987
|
+
const topAction = data.next;
|
|
899
988
|
if (topAction !== void 0) {
|
|
900
989
|
// git-comment convention: the invocation, two spaces, then "# "
|
|
901
990
|
// plus what it does
|
|
@@ -1011,12 +1100,18 @@ window.__ModuleLoader__.load({
|
|
|
1011
1100
|
// only (the node half rebuilt the URL from parsed parts); new tab,
|
|
1012
1101
|
// never navigating the conversation away.
|
|
1013
1102
|
const isLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
|
|
1103
|
+
// The sync arrows ride WITH the branch: they are the branch's standing
|
|
1104
|
+
// against its upstream, so the same hover (lineage) and the same link
|
|
1105
|
+
// (compare view, where the arrows are the diff) cover them. The ✎n
|
|
1106
|
+
// count stays its own surface — it answers a different question.
|
|
1107
|
+
const sync = formatSync(info);
|
|
1108
|
+
const label = info.branch + sync;
|
|
1014
1109
|
const branch = isLink
|
|
1015
1110
|
? react_jsx_runtime.jsx("a", {
|
|
1016
1111
|
href: detail.compareUrl,
|
|
1017
1112
|
target: "_blank",
|
|
1018
1113
|
rel: "noopener noreferrer",
|
|
1019
|
-
"aria-label": "Compare " + info.branch + " with main on GitHub",
|
|
1114
|
+
"aria-label": "Compare " + info.branch + " with main on GitHub" + (sync === "" ? "" : ", " + (info.ahead || 0) + " ahead and " + (info.behind || 0) + " behind upstream"),
|
|
1020
1115
|
onPointerEnter: () => { setHovered(true); setBranchLinkHover(true); },
|
|
1021
1116
|
onPointerLeave: () => setBranchLinkHover(false),
|
|
1022
1117
|
onFocus: () => { setHovered(true); setBranchLinkHover(true); },
|
|
@@ -1026,12 +1121,12 @@ window.__ModuleLoader__.load({
|
|
|
1026
1121
|
textDecoration: branchLinkHover ? "underline" : "none",
|
|
1027
1122
|
cursor: "pointer"
|
|
1028
1123
|
},
|
|
1029
|
-
children:
|
|
1124
|
+
children: label
|
|
1030
1125
|
})
|
|
1031
1126
|
: react_jsx_runtime.jsx("span", {
|
|
1032
1127
|
onPointerEnter: () => setHovered(true),
|
|
1033
1128
|
style: { cursor: "default" },
|
|
1034
|
-
children:
|
|
1129
|
+
children: label
|
|
1035
1130
|
});
|
|
1036
1131
|
if (Tooltip === void 0 || detail === void 0 || detail === null) {
|
|
1037
1132
|
// mid-fetch the branch shows plain; the lineage tooltip is
|
|
@@ -1051,10 +1146,12 @@ window.__ModuleLoader__.load({
|
|
|
1051
1146
|
// either triggers it once — and degrades to a plain count on a shell
|
|
1052
1147
|
// without the Tooltip primitive.
|
|
1053
1148
|
const countHover = (() => {
|
|
1149
|
+
// ✎n only: the sync arrows moved to the branch's surface above
|
|
1150
|
+
const countText = formatFileCount(info);
|
|
1054
1151
|
const count = react_jsx_runtime.jsx("span", {
|
|
1055
1152
|
onPointerEnter: () => setHovered(true),
|
|
1056
1153
|
style: { cursor: "default" },
|
|
1057
|
-
children:
|
|
1154
|
+
children: countText
|
|
1058
1155
|
});
|
|
1059
1156
|
const hasNames = (Array.isArray(detail?.untrackedNames) && detail.untrackedNames.length > 0)
|
|
1060
1157
|
|| (Array.isArray(detail?.unstagedNames) && detail.unstagedNames.length > 0);
|
|
@@ -1070,6 +1167,61 @@ window.__ModuleLoader__.load({
|
|
|
1070
1167
|
})();
|
|
1071
1168
|
const prToken = formatPrToken(info);
|
|
1072
1169
|
const prUrl = prLinkUrl(info);
|
|
1170
|
+
// The PR token, with its own hover: the commits IN THIS PR (see
|
|
1171
|
+
// PrCommitsList). Built here so the link/plain cases and the tooltip
|
|
1172
|
+
// compose once instead of nesting a third conditional in the children.
|
|
1173
|
+
// The token is its OWN detail gate: resting on it (or focusing it)
|
|
1174
|
+
// enables the same `detail=1` fetch the mark and the branch do. Without
|
|
1175
|
+
// that, a pointer going straight to the token had rested on nothing that
|
|
1176
|
+
// fetches, so `prCommits` was never requested and this hover could not
|
|
1177
|
+
// open at all — the data it renders is detail-only.
|
|
1178
|
+
const prTokenHover = (() => {
|
|
1179
|
+
const token = prUrl === void 0
|
|
1180
|
+
? react_jsx_runtime.jsx("span", {
|
|
1181
|
+
// the glyph is not the only channel: the token says what the
|
|
1182
|
+
// CI state IS, for anyone who cannot see it
|
|
1183
|
+
"aria-label": prTokenLabel(info),
|
|
1184
|
+
onPointerEnter: () => setHovered(true),
|
|
1185
|
+
style: { cursor: "default" },
|
|
1186
|
+
children: prToken
|
|
1187
|
+
})
|
|
1188
|
+
: react_jsx_runtime.jsx("a", {
|
|
1189
|
+
href: prUrl,
|
|
1190
|
+
// a new tab, because navigating THIS one away from the app
|
|
1191
|
+
// would lose the conversation; noopener/noreferrer keep the
|
|
1192
|
+
// opened tab from getting a handle on it
|
|
1193
|
+
target: "_blank",
|
|
1194
|
+
rel: "noopener noreferrer",
|
|
1195
|
+
"aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
|
|
1196
|
+
// the chip's own colour rather than the browser's link blue /
|
|
1197
|
+
// visited purple, so the token still reads as one row. The
|
|
1198
|
+
// affordance is the pointer cursor plus an underline on hover
|
|
1199
|
+
// or focus, set HERE rather than left to the host stylesheet:
|
|
1200
|
+
// a shell that resets anchors would otherwise drop it silently.
|
|
1201
|
+
style: {
|
|
1202
|
+
color: "inherit",
|
|
1203
|
+
textDecoration: linkHover ? "underline" : "none",
|
|
1204
|
+
cursor: "pointer"
|
|
1205
|
+
},
|
|
1206
|
+
// focus joins hover — the token is keyboard-reachable now, and a
|
|
1207
|
+
// keyboard user needs the same "this is a link" signal
|
|
1208
|
+
onPointerEnter: () => { setHovered(true); setLinkHover(true); },
|
|
1209
|
+
onPointerLeave: () => setLinkHover(false),
|
|
1210
|
+
onFocus: () => { setHovered(true); setLinkHover(true); },
|
|
1211
|
+
onBlur: () => setLinkHover(false),
|
|
1212
|
+
children: prToken
|
|
1213
|
+
});
|
|
1214
|
+
if (Tooltip === void 0 || detail === void 0 || detail === null
|
|
1215
|
+
|| !Array.isArray(detail.prCommits) || detail.prCommits.length === 0) {
|
|
1216
|
+
return token;
|
|
1217
|
+
}
|
|
1218
|
+
return react_jsx_runtime.jsx(Tooltip, {
|
|
1219
|
+
side: "top",
|
|
1220
|
+
maxWidth: 480,
|
|
1221
|
+
label: () => react_jsx_runtime.jsx(PrCommitsList, { info: detail }),
|
|
1222
|
+
children: token
|
|
1223
|
+
});
|
|
1224
|
+
})();
|
|
1073
1225
|
const chip = react_jsx_runtime.jsxs("span", {
|
|
1074
1226
|
style: {
|
|
1075
1227
|
display: "inline-flex",
|
|
@@ -1103,43 +1255,7 @@ window.__ModuleLoader__.load({
|
|
|
1103
1255
|
branchHover,
|
|
1104
1256
|
react_jsx_runtime.jsx("span", { key: "text", children: text }),
|
|
1105
1257
|
countHover,
|
|
1106
|
-
|
|
1107
|
-
? null
|
|
1108
|
-
: prUrl === void 0
|
|
1109
|
-
? react_jsx_runtime.jsx("span", {
|
|
1110
|
-
key: "pr",
|
|
1111
|
-
// the glyph is not the only channel: the token says what the
|
|
1112
|
-
// CI state IS, for anyone who cannot see it
|
|
1113
|
-
"aria-label": prTokenLabel(info),
|
|
1114
|
-
children: prToken
|
|
1115
|
-
})
|
|
1116
|
-
: react_jsx_runtime.jsx("a", {
|
|
1117
|
-
key: "pr",
|
|
1118
|
-
href: prUrl,
|
|
1119
|
-
// a new tab, because navigating THIS one away from the app
|
|
1120
|
-
// would lose the conversation; noopener/noreferrer keep the
|
|
1121
|
-
// opened tab from getting a handle on it
|
|
1122
|
-
target: "_blank",
|
|
1123
|
-
rel: "noopener noreferrer",
|
|
1124
|
-
"aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
|
|
1125
|
-
// the chip's own colour rather than the browser's link blue /
|
|
1126
|
-
// visited purple, so the token still reads as one row. The
|
|
1127
|
-
// affordance is the pointer cursor plus an underline on hover
|
|
1128
|
-
// or focus, set HERE rather than left to the host stylesheet:
|
|
1129
|
-
// a shell that resets anchors would otherwise drop it silently.
|
|
1130
|
-
style: {
|
|
1131
|
-
color: "inherit",
|
|
1132
|
-
textDecoration: linkHover ? "underline" : "none",
|
|
1133
|
-
cursor: "pointer"
|
|
1134
|
-
},
|
|
1135
|
-
// focus joins hover — the token is keyboard-reachable now, and a
|
|
1136
|
-
// keyboard user needs the same "this is a link" signal
|
|
1137
|
-
onPointerEnter: () => setLinkHover(true),
|
|
1138
|
-
onPointerLeave: () => setLinkHover(false),
|
|
1139
|
-
onFocus: () => setLinkHover(true),
|
|
1140
|
-
onBlur: () => setLinkHover(false),
|
|
1141
|
-
children: prToken
|
|
1142
|
-
}),
|
|
1258
|
+
prTokenHover,
|
|
1143
1259
|
/**
|
|
1144
1260
|
* The seam-absent notice: the market-install notification for the
|
|
1145
1261
|
* state a blocked postinstall leaves behind — chip working, sidebar
|
|
@@ -1424,7 +1540,12 @@ window.__ModuleLoader__.load({
|
|
|
1424
1540
|
info = await fetch("/api/git-badge?" + query).then((r) => r.json());
|
|
1425
1541
|
}
|
|
1426
1542
|
const actions = ghSkillActions(info);
|
|
1427
|
-
return actions.map((action) => ({
|
|
1543
|
+
return actions.map((action) => ({
|
|
1544
|
+
label: "/gh " + action.args,
|
|
1545
|
+
detail: action.why,
|
|
1546
|
+
args: action.args,
|
|
1547
|
+
...action.danger === true ? { confirmation: cleanGate() } : {}
|
|
1548
|
+
}));
|
|
1428
1549
|
} catch (error) {
|
|
1429
1550
|
console.error("[dsh-git-badge] /gh options failed:", error);
|
|
1430
1551
|
return [];
|
|
@@ -1667,7 +1788,7 @@ window.__ModuleLoader__.load({
|
|
|
1667
1788
|
// Additive; the host reads apply/inject and ignores the rest. The suite
|
|
1668
1789
|
// drives these to assert the REQUEST contract — which surface asks for the
|
|
1669
1790
|
// expensive extras — without a browser, a fetch or a network.
|
|
1670
|
-
exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, moveCategory };
|
|
1791
|
+
exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, cleanGate, moveCategory };
|
|
1671
1792
|
return module.exports;
|
|
1672
1793
|
}
|
|
1673
1794
|
});
|
package/lib/index.js
CHANGED
|
@@ -95,6 +95,8 @@ const config = {
|
|
|
95
95
|
fetchTimeoutMs: 8000,
|
|
96
96
|
/** minimum interval between background fetches per toplevel */
|
|
97
97
|
fetchTtlMs: 60000,
|
|
98
|
+
/** how long a resolved default-branch name is trusted (per toplevel) */
|
|
99
|
+
defaultBranchTtlMs: 60000,
|
|
98
100
|
/** fs-event burst collapse window */
|
|
99
101
|
debounceMs: 200,
|
|
100
102
|
/**
|
|
@@ -130,8 +132,16 @@ const config = {
|
|
|
130
132
|
* process — see the PR region.
|
|
131
133
|
*/
|
|
132
134
|
prStatus: "auto",
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Minimum interval between forge refreshes per repository. It must stay BELOW
|
|
137
|
+
* the client's idle poll (FALLBACK_POLL_MS, 60s in lib/client.js): the refresh
|
|
138
|
+
* is request-driven, so a TTL longer than that poll silently suppresses every
|
|
139
|
+
* OTHER request — and a pull request that appears on the forge with no local
|
|
140
|
+
* git event to announce it then waits out TTL + poll before it shows. At 90s
|
|
141
|
+
* that worst case was ~2.5 minutes; 20s bounds it at roughly one poll, for up
|
|
142
|
+
* to one `gh pr view` per open chip per minute.
|
|
143
|
+
*/
|
|
144
|
+
prTtlMs: 20000,
|
|
135
145
|
/**
|
|
136
146
|
* Forge call budget. Unlike git this is a network round trip to GitHub, so
|
|
137
147
|
* it gets a more generous budget than the status sample — but it is bounded
|
|
@@ -210,6 +220,32 @@ const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)"
|
|
|
210
220
|
* auto-fetch), with a TTL rather than a timer: fetch only happens when
|
|
211
221
|
* someone is actually looking at the badge.
|
|
212
222
|
*/
|
|
223
|
+
/**
|
|
224
|
+
* toplevel -> { at, value } for the repository's default branch name ("main"),
|
|
225
|
+
* resolved from `refs/remotes/origin/HEAD`. Read on the HOT path and refreshed
|
|
226
|
+
* out of band, exactly like the fetch: a status call must never pay a process
|
|
227
|
+
* for it, so the first request after a cache miss simply answers without the
|
|
228
|
+
* flag and the next one carries it.
|
|
229
|
+
*/
|
|
230
|
+
const defaultBranchState = new Map();
|
|
231
|
+
|
|
232
|
+
/** The cached default branch, kicking a refresh when stale. Never throws. */
|
|
233
|
+
function defaultBranchFor(toplevel, notify) {
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const cached = defaultBranchState.get(toplevel);
|
|
236
|
+
if (cached === void 0 || now - cached.at >= config.defaultBranchTtlMs) {
|
|
237
|
+
// out of band: this answer is served from what we already know
|
|
238
|
+
void (async () => {
|
|
239
|
+
const out = await runGit(toplevel, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
240
|
+
const name = out.stdout === null ? "" : out.stdout.trim().replace(/^origin\//u, "");
|
|
241
|
+
const previous = defaultBranchState.get(toplevel)?.value;
|
|
242
|
+
defaultBranchState.set(toplevel, { at: Date.now(), value: name === "" ? void 0 : name });
|
|
243
|
+
if (previous !== defaultBranchState.get(toplevel).value) notify?.();
|
|
244
|
+
})();
|
|
245
|
+
}
|
|
246
|
+
return cached?.value;
|
|
247
|
+
}
|
|
248
|
+
|
|
213
249
|
/** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
|
|
214
250
|
const fetchState = new Map();
|
|
215
251
|
|
|
@@ -730,7 +766,7 @@ function nextOrder() {
|
|
|
730
766
|
*
|
|
731
767
|
* @returns {{ args: string, command: string|undefined, why: string, what: string } | null}
|
|
732
768
|
*/
|
|
733
|
-
function
|
|
769
|
+
function nextStepRules(info) {
|
|
734
770
|
if (info === void 0 || info === null || info.git !== true) return null;
|
|
735
771
|
const unmerged = info.unmergedFiles || 0;
|
|
736
772
|
const plural = (n) => (n === 1 ? "" : "s");
|
|
@@ -786,15 +822,80 @@ function nextStep(info) {
|
|
|
786
822
|
if (pr !== void 0 && pr.number !== void 0 && pr.state === "failing") {
|
|
787
823
|
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`);
|
|
788
824
|
}
|
|
789
|
-
|
|
825
|
+
|
|
826
|
+
// STANDING options: never ranked, never the primary suggestion — they are
|
|
827
|
+
// things you may do, not things the checkout is asking for. `pr view` exists
|
|
828
|
+
// whenever a pull request does; `pr` (create) only for a pushed branch that
|
|
829
|
+
// is not the default; `clean` last and flagged, because a destructive verb
|
|
830
|
+
// whose intent git status cannot reveal must never be suggested.
|
|
831
|
+
const standing = [];
|
|
832
|
+
if (pr !== void 0 && pr.number !== void 0) {
|
|
833
|
+
standing.push({
|
|
834
|
+
args: `pr view ${pr.number}`,
|
|
835
|
+
why: `open pull request #${pr.number}`,
|
|
836
|
+
what: `show pull request ${pr.number} on GitHub`
|
|
837
|
+
});
|
|
838
|
+
} else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && dirty === 0 && info.defaultBranch !== true) {
|
|
839
|
+
standing.push({ args: "pr", why: "branch is pushed and has no pull request", what: "open a pull request for this branch" });
|
|
840
|
+
}
|
|
841
|
+
if (untracked > 0) {
|
|
842
|
+
standing.push({
|
|
843
|
+
args: "clean",
|
|
844
|
+
why: `${untracked} untracked file${plural(untracked)}`,
|
|
845
|
+
what: "remove untracked files — lists them and asks first",
|
|
846
|
+
danger: true
|
|
847
|
+
});
|
|
848
|
+
}
|
|
790
849
|
|
|
791
850
|
const order = nextOrder();
|
|
792
851
|
const rank = (cat) => {
|
|
793
852
|
const at = order.indexOf(cat);
|
|
794
853
|
return at === -1 ? order.length : at;
|
|
795
854
|
};
|
|
796
|
-
const
|
|
797
|
-
return {
|
|
855
|
+
const ranked = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat)).slice(0, 5);
|
|
856
|
+
return { ranked, standing };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Every action the checkout justifies, in order — the ONE list both surfaces
|
|
861
|
+
* render: the card shows the primary entry, the (+) picker lists them all.
|
|
862
|
+
*
|
|
863
|
+
* The server owns the whole list because both halves previously carried their
|
|
864
|
+
* own copy of these conditions and drifted: the card promoted a client-only
|
|
865
|
+
* extra into its action row, and `/gh pr` was offered on the default branch.
|
|
866
|
+
* One authority, one list, no reconciliation.
|
|
867
|
+
*
|
|
868
|
+
* Exactly one entry can be `primary` (the top RANKED rule) or none at all —
|
|
869
|
+
* a clean, synced checkout has actions (look at the PR, clean up) but no
|
|
870
|
+
* suggestion, because "no suggestion" is also a suggestion.
|
|
871
|
+
*
|
|
872
|
+
* @returns {{ actions: Array<{args, why, what, command?, danger?, primary?}>, next: object|null }}
|
|
873
|
+
*/
|
|
874
|
+
function nextActions(info) {
|
|
875
|
+
const built = nextStepRules(info);
|
|
876
|
+
if (built === null) return { actions: [], next: null };
|
|
877
|
+
const { ranked, standing } = built;
|
|
878
|
+
const actions = [
|
|
879
|
+
...ranked.map((rule, index) => ({
|
|
880
|
+
args: rule.args,
|
|
881
|
+
why: rule.why,
|
|
882
|
+
what: rule.what,
|
|
883
|
+
...(rule.command === void 0 ? {} : { command: rule.command }),
|
|
884
|
+
...(index === 0 ? { primary: true } : {})
|
|
885
|
+
})),
|
|
886
|
+
...standing
|
|
887
|
+
];
|
|
888
|
+
const primary = actions.find((action) => action.primary === true) ?? null;
|
|
889
|
+
return { actions, next: primary };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The single ranked suggestion, or null — the card's action row and the legacy
|
|
894
|
+
* shape. It is the primary entry of {@link nextActions}, never a standing
|
|
895
|
+
* option.
|
|
896
|
+
*/
|
|
897
|
+
function nextStep(info) {
|
|
898
|
+
return nextActions(info).next;
|
|
798
899
|
}
|
|
799
900
|
|
|
800
901
|
/**
|
|
@@ -897,6 +998,15 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
897
998
|
ahead: parsed.ahead,
|
|
898
999
|
behind: parsed.behind
|
|
899
1000
|
};
|
|
1001
|
+
// Is this the repository's DEFAULT branch? main/master with no pull request
|
|
1002
|
+
// is that branch's normal state, and "open a pull request for this branch" is
|
|
1003
|
+
// nonsense there, so the surfaces suppress it. Cached and refreshed out of
|
|
1004
|
+
// band: the flag may be absent on the very first read, present on the next.
|
|
1005
|
+
const defaultBranch = defaultBranchFor(toplevel, notifyChange);
|
|
1006
|
+
if (defaultBranch !== void 0 && defaultBranch === parsed.branch) info.defaultBranch = true;
|
|
1007
|
+
// Resolved inside the detail block; the PR-token hover below reuses it, so it
|
|
1008
|
+
// is declared here rather than scoped to that block.
|
|
1009
|
+
let baseRef = null;
|
|
900
1010
|
if (wantDetail) {
|
|
901
1011
|
// The ✎n NAMES — the one part of the count the numbers cannot answer
|
|
902
1012
|
// ("what are these?"). Hover-gated with the rest of detail=1, so no
|
|
@@ -944,7 +1054,7 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
944
1054
|
if (remoteUrl.stdout !== null && remoteUrl.stdout.trim() !== "") {
|
|
945
1055
|
info.repoUrl = remoteToWebUrl(remoteUrl.stdout);
|
|
946
1056
|
}
|
|
947
|
-
|
|
1057
|
+
baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
|
|
948
1058
|
? "origin/main"
|
|
949
1059
|
: probeMaster.stdout !== null && probeMaster.stdout.trim() !== "" ? "origin/master" : null;
|
|
950
1060
|
if (baseRef !== null && info.repoUrl !== void 0) {
|
|
@@ -1009,8 +1119,28 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1009
1119
|
const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
|
|
1010
1120
|
if (pr !== void 0) info.pr = pr;
|
|
1011
1121
|
}
|
|
1012
|
-
|
|
1122
|
+
if (baseRef !== null && info.pr !== void 0 && info.pr !== null && info.pr.number !== void 0
|
|
1123
|
+
&& info.pr.open !== false && (info.mainAhead || 0) > 0) {
|
|
1124
|
+
// The PR's OWN commits (base..HEAD) — the token's hover answers "what is in
|
|
1125
|
+
// this pull request", so it lists exactly that: not the branch's recent
|
|
1126
|
+
// history, nothing else. Hover-gated with the rest of detail=1, and the
|
|
1127
|
+
// total is the ahead count already computed above (no second rev-list).
|
|
1128
|
+
const prOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", baseRef + "..HEAD"]);
|
|
1129
|
+
if (prOut.stdout !== null && prOut.stdout.trim() !== "") {
|
|
1130
|
+
info.prCommits = prOut.stdout.trim().split("\n").map((line) => {
|
|
1131
|
+
const [hash, subject, when] = line.split("\t");
|
|
1132
|
+
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
1133
|
+
}).filter((c) => c.hash !== void 0);
|
|
1134
|
+
info.prCommitsTotal = info.mainAhead;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
// The ranked suggestion rides EVERY response (it is pure derivation, and a
|
|
1138
|
+
// sidebar row may one day use it instead of its own copy of these rules);
|
|
1139
|
+
// the full action list is sent only for `pr=1`, the surfaces that have a
|
|
1140
|
+
// picker, so a row's payload stays lean.
|
|
1141
|
+
const { actions, next } = nextActions(info);
|
|
1013
1142
|
if (next !== null) info.next = next;
|
|
1143
|
+
if (wantPr && actions.length > 0) info.actions = actions;
|
|
1014
1144
|
return info;
|
|
1015
1145
|
}
|
|
1016
1146
|
|
|
@@ -1649,6 +1779,7 @@ export {
|
|
|
1649
1779
|
OPERATION_MARKERS,
|
|
1650
1780
|
operationMarker,
|
|
1651
1781
|
nextStep,
|
|
1782
|
+
nextActions,
|
|
1652
1783
|
outerGitDir,
|
|
1653
1784
|
parseStatusV2,
|
|
1654
1785
|
parseWorktreeList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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
|
@@ -87,6 +87,27 @@ unstaged / untracked / unmerged), plus any in-progress operation marker
|
|
|
87
87
|
A session's cwd is fixed at creation, so this registration is the ONLY signal
|
|
88
88
|
the badge has.
|
|
89
89
|
|
|
90
|
+
- **`/gh clean [<path>...]`** — remove untracked files, and only with the user's
|
|
91
|
+
explicit yes for the exact list. It appears in the `/gh` menu whenever
|
|
92
|
+
untracked files exist, always last, behind the host's risk gate: picking it
|
|
93
|
+
opens a confirmation whose box must be ticked before the invocation is even
|
|
94
|
+
sent. That gate names the RISK, not the files — so the explicit paths and the
|
|
95
|
+
second yes below are still required. This is the one verb whose first step is a
|
|
96
|
+
DRY RUN, always:
|
|
97
|
+
|
|
98
|
+
1. `git clean -n -- <paths>` and quote its output back verbatim — the user is
|
|
99
|
+
approving that list, not the idea of cleaning;
|
|
100
|
+
2. ask; if the answer is anything but an unambiguous yes, stop and change
|
|
101
|
+
nothing;
|
|
102
|
+
3. `git clean -f -- <paths>` — named paths only.
|
|
103
|
+
|
|
104
|
+
Never `-x` (ignored files are often credentials), never `-d` (whole
|
|
105
|
+
directories), never a bare `git clean -f`, and never a path the user did not
|
|
106
|
+
name or approve. With no argument, list what `git clean -n` reports and ask
|
|
107
|
+
which paths to remove rather than picking for them. Untracked files have no
|
|
108
|
+
reflog and no stash entry: what this removes cannot be recovered, so a
|
|
109
|
+
mistaken cleanup is unrecoverable in a way a mistaken commit is not.
|
|
110
|
+
|
|
90
111
|
- **`/gh order`** — reconfigure which action class the badge ranks first. The
|
|
91
112
|
order lives in the `git-badge` settings namespace, edited in the UI at
|
|
92
113
|
**Settings → Plugins → Git Badge** (an ordered list with up/down controls).
|
|
@@ -108,7 +129,8 @@ the `gh-pr` skill; hand off rather than improvise a lifecycle.
|
|
|
108
129
|
## Hard rules
|
|
109
130
|
|
|
110
131
|
- Never `git reset --hard`, `git clean`, `git checkout -- .`, `git push
|
|
111
|
-
--force` (plain), or any history rewrite without an explicit user yes
|
|
132
|
+
--force` (plain), or any history rewrite without an explicit user yes —
|
|
133
|
+
`/gh clean`'s dry run and verbatim quote come before any `git clean -f`.
|
|
112
134
|
- `GIT_TERMINAL_PROMPT=0` on network commands so a missing credential fails
|
|
113
135
|
fast instead of hanging.
|
|
114
136
|
- One operation per invocation; report the outcome, then stop.
|