dsh-git-badge 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +2 -2
- package/lib/client.js +331 -12
- package/lib/index.js +155 -139
- package/package.json +8 -3
- package/seam/checkout.js +82 -0
- package/seam/store.js +66 -1
- package/skill/gh/SKILL.md +16 -5
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,47 @@ npm. 0.6.0 is the first release recorded here.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.0] - 2026-09-16
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **A session now tells the badge which checkout it is working in.** dsh cannot
|
|
18
|
+
supply that fact — a session's `cwd` is immutable creation metadata and always
|
|
19
|
+
the main checkout — so the agent registers it explicitly:
|
|
20
|
+
`npx dsh-git-badge-checkout <path>` (shipped bin), run by the `git-worktree`
|
|
21
|
+
skill after `git worktree add` and by `/gh checkout` when a switch lands in or
|
|
22
|
+
out of a linked worktree. The node half prefers the registration, and
|
|
23
|
+
validates it: git's own worktree list must know the path, the entry expires
|
|
24
|
+
after 24h, and the name/branch come from git, never from the file. The
|
|
25
|
+
registration is self-clearing — removing it (or letting it expire) retires the
|
|
26
|
+
extra watcher and the badge returns to the session's own directory.
|
|
27
|
+
|
|
28
|
+
- **A settings card for the ranking order: Settings → Plugins → Git Badge.**
|
|
29
|
+
The order now lives in this plugin's own `git-badge` settings namespace —
|
|
30
|
+
declared with a schema, validated and stored by the host, observed live — and
|
|
31
|
+
the card reorders the seven categories with up/down controls, Save/Discard,
|
|
32
|
+
and Reset to default. No file polling, no restart: a change applies to the
|
|
33
|
+
next status read. A pre-existing `~/.dsh/git-badge-next.json` seeds the
|
|
34
|
+
namespace as its base (and remains the fallback on hosts without the settings
|
|
35
|
+
stack), so an order configured before the card existed survives the upgrade.
|
|
36
|
+
`/gh order` still works for agent-driven changes.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **The ranking card sits in an expander, like the host's other plugin cards.**
|
|
41
|
+
Collapsed by default with the title and a one-line description; the list,
|
|
42
|
+
reorder controls, and Save/Discard/Reset live in the body, which auto-collapses
|
|
43
|
+
once a save settles clean. The card chrome reproduces the host's plugin-card
|
|
44
|
+
values inline (its CSS module is not exported to plugins), so it reads as one
|
|
45
|
+
of the set rather than loose content between it.
|
|
46
|
+
|
|
47
|
+
- **PR inference is gone.** The badge no longer guesses a worktree from the
|
|
48
|
+
branch with an open pull request. The guess was invisible to the session that
|
|
49
|
+
made it and silently wrong whenever two trees were in play; a `worktreeInferred`
|
|
50
|
+
marker, the repository-wide `gh pr list` read that powered it, and the
|
|
51
|
+
inference wording in the hover all go with it. A session follows its
|
|
52
|
+
registration or stays on its own checkout — nothing else.
|
|
53
|
+
|
|
13
54
|
## [0.15.0] - 2026-09-15
|
|
14
55
|
|
|
15
56
|
### Changed
|
package/README.md
CHANGED
|
@@ -5,13 +5,13 @@ Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek
|
|
|
5
5
|
- **Input-row chip** — `● main ↑0 ↓2 ✎3` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
|
|
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
|
-
- **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.
|
|
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
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.
|
|
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
|
|
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 ~90s. 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
|
@@ -436,9 +436,9 @@ window.__ModuleLoader__.load({
|
|
|
436
436
|
"aria-label":
|
|
437
437
|
(isWorktree ? "git worktree: " : "git: ") +
|
|
438
438
|
STATUS_LABEL[status] +
|
|
439
|
-
//
|
|
440
|
-
// accessible name says
|
|
441
|
-
(info.
|
|
439
|
+
// a followed checkout is not where the conversation lives, and the
|
|
440
|
+
// accessible name says so — the session registered it explicitly
|
|
441
|
+
(info.worktreeFollowed === true ? " (registered for this session)" : ""),
|
|
442
442
|
focusable: "false",
|
|
443
443
|
style: { flex: "none", display: "block", color: META_STYLE.color }
|
|
444
444
|
};
|
|
@@ -585,7 +585,7 @@ window.__ModuleLoader__.load({
|
|
|
585
585
|
const checkout = [name === "" ? String(info.branch) : name + " on " + info.branch];
|
|
586
586
|
// the node half followed this tree because it is the one whose branch
|
|
587
587
|
// has the open PR — say so, because nothing on the row can
|
|
588
|
-
|
|
588
|
+
|
|
589
589
|
bits.push("checkout: " + checkout.join(" \u00B7 "));
|
|
590
590
|
}
|
|
591
591
|
const pr = formatPrDetail(info.pr);
|
|
@@ -623,7 +623,7 @@ window.__ModuleLoader__.load({
|
|
|
623
623
|
|
|
624
624
|
/**
|
|
625
625
|
* The `checkout` row: the directory the CONVERSATION itself names, present
|
|
626
|
-
* only when the badge is describing
|
|
626
|
+
* only when the badge is describing a FOLLOWED worktree instead. The chip's
|
|
627
627
|
* branch and counts are the tree's in that case, so without this row the
|
|
628
628
|
* checkout's own state is nowhere on screen. Branch, then files (or "clean"),
|
|
629
629
|
* then sync counts only when they are nonzero — the chip's own rule.
|
|
@@ -642,15 +642,13 @@ window.__ModuleLoader__.load({
|
|
|
642
642
|
* The `worktree` row: WHICH linked worktree the badge is describing. The chip
|
|
643
643
|
* names only the branch (see the note above formatOperationToken), so the
|
|
644
644
|
* card is the one place a tree is identified — the mark's shape can only say
|
|
645
|
-
* *that* the checkout is a worktree, never which.
|
|
646
|
-
* why, because "which tree" and "why this tree" are different questions and
|
|
647
|
-
* the second is the one a reader will ask.
|
|
645
|
+
* *that* the checkout is a worktree, never which.
|
|
648
646
|
*/
|
|
649
647
|
function worktreeDetail(info) {
|
|
650
648
|
if (info === void 0 || info === null || info.isWorktree !== true) return void 0;
|
|
651
649
|
const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
|
|
652
650
|
if (name === "") return void 0;
|
|
653
|
-
return
|
|
651
|
+
return name;
|
|
654
652
|
}
|
|
655
653
|
|
|
656
654
|
const CARD_CONTAINER = {
|
|
@@ -820,6 +818,51 @@ window.__ModuleLoader__.load({
|
|
|
820
818
|
const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
|
|
821
819
|
const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
|
|
822
820
|
|
|
821
|
+
// The host's plugin-card chrome, reproduced inline: its CSS module is not
|
|
822
|
+
// exported to plugins, so these values mirror PluginCard.module.css so our
|
|
823
|
+
// card sits in the same list as its siblings.
|
|
824
|
+
const CARD_BASE = {
|
|
825
|
+
border: ".5px solid var(--dsw-alias-border-l4, #e4e8eb)",
|
|
826
|
+
borderRadius: "16px",
|
|
827
|
+
listStyle: "none",
|
|
828
|
+
transition: "border-color .16s, background .16s"
|
|
829
|
+
};
|
|
830
|
+
const CARD_CLOSED = { ...CARD_BASE, background: "var(--dsw-alias-bg-layer-3, #ffffff)" };
|
|
831
|
+
const CARD_OPEN = {
|
|
832
|
+
...CARD_BASE,
|
|
833
|
+
background: "var(--dsw-alias-bg-layer-2, #f7f8f9)",
|
|
834
|
+
borderColor: "var(--dsw-alias-label-dimmed, #b8bfc4)"
|
|
835
|
+
};
|
|
836
|
+
const CARD_HEADER = {
|
|
837
|
+
appearance: "none",
|
|
838
|
+
width: "100%",
|
|
839
|
+
font: "inherit",
|
|
840
|
+
color: "inherit",
|
|
841
|
+
textAlign: "left",
|
|
842
|
+
cursor: "pointer",
|
|
843
|
+
background: "none",
|
|
844
|
+
border: 0,
|
|
845
|
+
borderRadius: "12px",
|
|
846
|
+
display: "flex",
|
|
847
|
+
alignItems: "center",
|
|
848
|
+
gap: "12px",
|
|
849
|
+
padding: "14px 16px"
|
|
850
|
+
};
|
|
851
|
+
const CARD_NAME = { color: "var(--dsw-alias-label-primary, #1f2328)", fontSize: "15px", fontWeight: "600", lineHeight: "1.4" };
|
|
852
|
+
const CARD_DESCRIPTION = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", fontSize: "13px", lineHeight: "1.5" };
|
|
853
|
+
|
|
854
|
+
const CARD_BUTTON = {
|
|
855
|
+
cursor: "pointer",
|
|
856
|
+
color: "inherit",
|
|
857
|
+
background: "none",
|
|
858
|
+
border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
|
|
859
|
+
borderRadius: "4px",
|
|
860
|
+
fontSize: "11px",
|
|
861
|
+
lineHeight: "18px",
|
|
862
|
+
padding: "0 6px",
|
|
863
|
+
flex: "none"
|
|
864
|
+
};
|
|
865
|
+
|
|
823
866
|
/** One label/value row — the shape every hover body shares. */
|
|
824
867
|
function cardRow(label, value) {
|
|
825
868
|
return react_jsx_runtime.jsxs("div", {
|
|
@@ -868,10 +911,10 @@ window.__ModuleLoader__.load({
|
|
|
868
911
|
// the branch alone, so this row is the only place the tree is named.
|
|
869
912
|
add("worktree", worktreeDetail(data));
|
|
870
913
|
// The conversation's OWN directory, present only when the badge is
|
|
871
|
-
// describing
|
|
914
|
+
// describing a followed worktree — the chip's branch and counts are the
|
|
872
915
|
// tree's in that case, so this is where the checkout's own state stays
|
|
873
916
|
// visible. Absent until the `detail=1` response lands, like commits/stash.
|
|
874
|
-
if (data.
|
|
917
|
+
if (data.worktreeFollowed === true) add("checkout", formatCheckoutDetail(data.checkout));
|
|
875
918
|
add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
|
|
876
919
|
return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
|
|
877
920
|
}
|
|
@@ -1172,6 +1215,162 @@ window.__ModuleLoader__.load({
|
|
|
1172
1215
|
// `workspaces` is no longer required: both surfaces target an id and the
|
|
1173
1216
|
// node half resolves the workspace, so the client never needs a service
|
|
1174
1217
|
// lookup. Fewer declared services also means fewer ways to fail to load.
|
|
1218
|
+
/** The built-in ranking order — what "Reset to default" restores. */
|
|
1219
|
+
const DEFAULT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Move one category within the ranking order — the card's only reorder
|
|
1223
|
+
* primitive, pure so the suite can drive it without a browser. Out-of-range
|
|
1224
|
+
* moves are no-ops (the buttons disable at the ends, this is the belt).
|
|
1225
|
+
*/
|
|
1226
|
+
function moveCategory(order, index, delta) {
|
|
1227
|
+
const next = [...order];
|
|
1228
|
+
const to = index + delta;
|
|
1229
|
+
if (index < 0 || index >= next.length || to < 0 || to >= next.length) return next;
|
|
1230
|
+
const [moved] = next.splice(index, 1);
|
|
1231
|
+
next.splice(to, 0, moved);
|
|
1232
|
+
return next;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Human labels for the ranking categories, in default order. */
|
|
1236
|
+
const CATEGORY_LABELS = {
|
|
1237
|
+
operation: ["operation", "resume a paused merge/rebase"],
|
|
1238
|
+
unmerged: ["unmerged", "resolve conflict markers"],
|
|
1239
|
+
sync: ["sync", "pull, or rebase + confirmed force-push"],
|
|
1240
|
+
publish: ["publish", "push local commits"],
|
|
1241
|
+
merge: ["merge", "a merge-ready pull request"],
|
|
1242
|
+
commit: ["commit", "stage and commit working changes"],
|
|
1243
|
+
checks: ["checks", "watch failing CI"]
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* The settings card's body — Settings → Plugins → Git Badge. Reads the
|
|
1248
|
+
* bound settings scope through the store the registration injects and
|
|
1249
|
+
* writes back one field ("order") via the scope's mutate path. Styled like
|
|
1250
|
+
* this plugin's other surfaces rather than the host's card primitives,
|
|
1251
|
+
* which are not exported to plugins.
|
|
1252
|
+
*/
|
|
1253
|
+
function GitBadgeOrderCard(props) {
|
|
1254
|
+
const state = props.useGitBadgeOrderCard((snapshot) => snapshot);
|
|
1255
|
+
const t = props.t;
|
|
1256
|
+
// Local expander state, mirroring the host's plugin cards: collapsed by
|
|
1257
|
+
// default, and auto-collapsed again once a save settles clean (so the
|
|
1258
|
+
// card confirms itself the way its siblings do).
|
|
1259
|
+
const [open, setOpen] = react.useState(false);
|
|
1260
|
+
const saveStarted = react.useRef(false);
|
|
1261
|
+
react.useEffect(() => {
|
|
1262
|
+
if (state.saving) {
|
|
1263
|
+
saveStarted.current = true;
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (!saveStarted.current) return;
|
|
1267
|
+
saveStarted.current = false;
|
|
1268
|
+
if (!state.dirty && !state.failed) setOpen(false);
|
|
1269
|
+
}, [state.dirty, state.failed, state.saving]);
|
|
1270
|
+
if (!state.available) return null;
|
|
1271
|
+
const rows = state.order.map((cat, index) => {
|
|
1272
|
+
const [label, hint] = CATEGORY_LABELS[cat] ?? [cat, ""];
|
|
1273
|
+
return react_jsx_runtime.jsxs("div", {
|
|
1274
|
+
style: { display: "flex", alignItems: "center", gap: "8px", padding: "2px 0" },
|
|
1275
|
+
children: [
|
|
1276
|
+
react_jsx_runtime.jsx("span", {
|
|
1277
|
+
style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", fontFamily: "monospace", minWidth: "16px", textAlign: "right" },
|
|
1278
|
+
children: String(index + 1)
|
|
1279
|
+
}),
|
|
1280
|
+
react_jsx_runtime.jsx("span", { style: { minWidth: "84px" }, children: label }),
|
|
1281
|
+
react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "1", minWidth: 0 }, children: hint }),
|
|
1282
|
+
react_jsx_runtime.jsx("button", {
|
|
1283
|
+
type: "button",
|
|
1284
|
+
"aria-label": t("moveUp") + " " + label,
|
|
1285
|
+
disabled: state.saving || !state.writable || index === 0,
|
|
1286
|
+
onClick: () => props.move(index, -1),
|
|
1287
|
+
style: CARD_BUTTON,
|
|
1288
|
+
children: "\u2191"
|
|
1289
|
+
}),
|
|
1290
|
+
react_jsx_runtime.jsx("button", {
|
|
1291
|
+
type: "button",
|
|
1292
|
+
"aria-label": t("moveDown") + " " + label,
|
|
1293
|
+
disabled: state.saving || !state.writable || index === state.order.length - 1,
|
|
1294
|
+
onClick: () => props.move(index, 1),
|
|
1295
|
+
style: CARD_BUTTON,
|
|
1296
|
+
children: "\u2193"
|
|
1297
|
+
})
|
|
1298
|
+
]
|
|
1299
|
+
}, cat);
|
|
1300
|
+
});
|
|
1301
|
+
// The card chrome matches the host's own plugin cards (its CSS module is
|
|
1302
|
+
// not exported to plugins, so the values are reproduced inline): an <li>
|
|
1303
|
+
// with the bordered card, a header button that expands, and a body that
|
|
1304
|
+
// carries the list and the footer actions.
|
|
1305
|
+
return react_jsx_runtime.jsxs("li", {
|
|
1306
|
+
style: open ? CARD_OPEN : CARD_CLOSED,
|
|
1307
|
+
children: [
|
|
1308
|
+
react_jsx_runtime.jsxs("button", {
|
|
1309
|
+
type: "button",
|
|
1310
|
+
"aria-expanded": open,
|
|
1311
|
+
"aria-label": t(open ? "collapse" : "expand") + ": " + t("title"),
|
|
1312
|
+
onClick: () => setOpen(!open),
|
|
1313
|
+
style: CARD_HEADER,
|
|
1314
|
+
children: [
|
|
1315
|
+
react_jsx_runtime.jsxs("span", {
|
|
1316
|
+
style: { display: "flex", flexDirection: "column", flex: "1", gap: "4px", minWidth: 0 },
|
|
1317
|
+
children: [
|
|
1318
|
+
react_jsx_runtime.jsx("span", { style: CARD_NAME, children: t("title") }),
|
|
1319
|
+
react_jsx_runtime.jsx("span", { style: CARD_DESCRIPTION, children: t("description") })
|
|
1320
|
+
]
|
|
1321
|
+
}),
|
|
1322
|
+
react_jsx_runtime.jsx("span", {
|
|
1323
|
+
"aria-hidden": true,
|
|
1324
|
+
style: open
|
|
1325
|
+
? { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", transform: "rotate(180deg)", transition: "transform .16s" }
|
|
1326
|
+
: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", transition: "transform .16s" },
|
|
1327
|
+
children: (primitives !== null && primitives.IconChevronDownOutline14 !== void 0
|
|
1328
|
+
? react_jsx_runtime.jsx(primitives.IconChevronDownOutline14, {})
|
|
1329
|
+
: "\u25BE")
|
|
1330
|
+
})
|
|
1331
|
+
]
|
|
1332
|
+
}),
|
|
1333
|
+
open
|
|
1334
|
+
? react_jsx_runtime.jsxs("div", {
|
|
1335
|
+
style: { borderTop: ".5px solid var(--dsw-alias-border-l2, #e4e8eb)", margin: "0 16px", paddingBottom: "8px" },
|
|
1336
|
+
children: [
|
|
1337
|
+
react_jsx_runtime.jsx("div", { style: { padding: "10px 0 6px" }, children: t("intro") }),
|
|
1338
|
+
react_jsx_runtime.jsx("div", { style: { display: "flex", flexDirection: "column" }, children: rows }),
|
|
1339
|
+
react_jsx_runtime.jsxs("div", {
|
|
1340
|
+
style: { display: "flex", gap: "8px", alignItems: "center", justifyContent: "flex-end", padding: "12px 0 4px" },
|
|
1341
|
+
children: [
|
|
1342
|
+
state.failed ? react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-error, #d13438)", flex: "1", fontSize: "12px" }, children: t("failed") }) : null,
|
|
1343
|
+
state.writable ? null : react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "1", fontSize: "12px" }, children: t("readOnly") }),
|
|
1344
|
+
react_jsx_runtime.jsx("button", {
|
|
1345
|
+
type: "button",
|
|
1346
|
+
disabled: !state.dirty || state.saving,
|
|
1347
|
+
onClick: props.discard,
|
|
1348
|
+
style: CARD_BUTTON,
|
|
1349
|
+
children: t("discard")
|
|
1350
|
+
}),
|
|
1351
|
+
react_jsx_runtime.jsx("button", {
|
|
1352
|
+
type: "button",
|
|
1353
|
+
disabled: !state.dirty || state.saving || !state.writable,
|
|
1354
|
+
onClick: props.save,
|
|
1355
|
+
style: CARD_BUTTON,
|
|
1356
|
+
children: state.saving ? t("saving") : t("save")
|
|
1357
|
+
}),
|
|
1358
|
+
react_jsx_runtime.jsx("button", {
|
|
1359
|
+
type: "button",
|
|
1360
|
+
disabled: state.saving || !state.writable,
|
|
1361
|
+
onClick: props.reset,
|
|
1362
|
+
style: CARD_BUTTON,
|
|
1363
|
+
children: t("reset")
|
|
1364
|
+
})
|
|
1365
|
+
]
|
|
1366
|
+
})
|
|
1367
|
+
]
|
|
1368
|
+
})
|
|
1369
|
+
: null
|
|
1370
|
+
]
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1175
1374
|
const inject = ["slots", "inputTriggers"];
|
|
1176
1375
|
|
|
1177
1376
|
/**
|
|
@@ -1267,6 +1466,126 @@ window.__ModuleLoader__.load({
|
|
|
1267
1466
|
});
|
|
1268
1467
|
}
|
|
1269
1468
|
|
|
1469
|
+
// The settings card (Settings → Plugins → Git Badge): the order lives
|
|
1470
|
+
// in this plugin's `git-badge` settings namespace, which the node half
|
|
1471
|
+
// registers; this half renders the card that edits it through the bound
|
|
1472
|
+
// scope — reads from the scope snapshot, writes one field via mutate.
|
|
1473
|
+
// Guarded: a host without settingsScope loses the card, never the
|
|
1474
|
+
// badges.
|
|
1475
|
+
if (typeof ctx.inject === "function") {
|
|
1476
|
+
try {
|
|
1477
|
+
ctx.inject(["settingsScope", "slots", "locale"], (scopeCtx) => {
|
|
1478
|
+
const NS = "dsh-git-badge";
|
|
1479
|
+
scopeCtx.effect(() => scopeCtx.locale.register(NS, {
|
|
1480
|
+
en: {
|
|
1481
|
+
title: "Git Badge",
|
|
1482
|
+
description: "Which action class the badge suggests first.",
|
|
1483
|
+
expand: "Expand",
|
|
1484
|
+
collapse: "Collapse",
|
|
1485
|
+
intro: "The badge suggests one next action; this is the order it considers the classes in.",
|
|
1486
|
+
save: "Save",
|
|
1487
|
+
discard: "Discard",
|
|
1488
|
+
reset: "Reset to default",
|
|
1489
|
+
saving: "Saving\u2026",
|
|
1490
|
+
failed: "save failed \u2014 try again",
|
|
1491
|
+
readOnly: "read-only on this host",
|
|
1492
|
+
moveUp: "Move up",
|
|
1493
|
+
moveDown: "Move down"
|
|
1494
|
+
}
|
|
1495
|
+
}), "dsh-git-badge: settings locale");
|
|
1496
|
+
const scope = scopeCtx.settingsScope.bind({ namespace: "git-badge" });
|
|
1497
|
+
// draft === null means "mirror the stored order"; any reorder
|
|
1498
|
+
// stages a copy, and only a confirmed write clears it — a
|
|
1499
|
+
// rejected write leaves the draft for a retry rather than
|
|
1500
|
+
// pretending it saved.
|
|
1501
|
+
let draft = null;
|
|
1502
|
+
let saving = false;
|
|
1503
|
+
let failed = false;
|
|
1504
|
+
const subscribers = new Set();
|
|
1505
|
+
let snapshot = null;
|
|
1506
|
+
const storedOrder = () => {
|
|
1507
|
+
const view = scope.getSnapshot();
|
|
1508
|
+
return Array.isArray(view?.value?.order) ? view.value.order : DEFAULT_ORDER;
|
|
1509
|
+
};
|
|
1510
|
+
const build = () => {
|
|
1511
|
+
const view = scope.getSnapshot();
|
|
1512
|
+
const stored = storedOrder();
|
|
1513
|
+
const order = draft ?? stored;
|
|
1514
|
+
return {
|
|
1515
|
+
available: view?.status === "ready",
|
|
1516
|
+
writable: view?.writable === true,
|
|
1517
|
+
order,
|
|
1518
|
+
dirty: draft !== null && draft.join() !== stored.join(),
|
|
1519
|
+
saving,
|
|
1520
|
+
failed
|
|
1521
|
+
};
|
|
1522
|
+
};
|
|
1523
|
+
const publish = () => {
|
|
1524
|
+
snapshot = build();
|
|
1525
|
+
for (const listener of subscribers) listener();
|
|
1526
|
+
};
|
|
1527
|
+
scopeCtx.effect(() => {
|
|
1528
|
+
const off = scope.subscribe(publish);
|
|
1529
|
+
return () => {
|
|
1530
|
+
if (typeof off === "function") off();
|
|
1531
|
+
};
|
|
1532
|
+
}, "dsh-git-badge: settings scope");
|
|
1533
|
+
publish();
|
|
1534
|
+
const store = {
|
|
1535
|
+
getSnapshot: () => snapshot ?? build(),
|
|
1536
|
+
subscribe: (listener) => {
|
|
1537
|
+
subscribers.add(listener);
|
|
1538
|
+
return () => subscribers.delete(listener);
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
const save = async () => {
|
|
1542
|
+
if (draft === null || saving) return;
|
|
1543
|
+
const wanted = [...draft];
|
|
1544
|
+
saving = true;
|
|
1545
|
+
failed = false;
|
|
1546
|
+
publish();
|
|
1547
|
+
try {
|
|
1548
|
+
await scope.mutate([{ op: "set", path: ["order"], value: wanted }]);
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
console.error("[dsh-git-badge] order save failed:", error);
|
|
1551
|
+
}
|
|
1552
|
+
saving = false;
|
|
1553
|
+
if (storedOrder().join() === wanted.join()) draft = null;
|
|
1554
|
+
else failed = true;
|
|
1555
|
+
publish();
|
|
1556
|
+
};
|
|
1557
|
+
scopeCtx.slots.inject("settings.plugin.item", function* () {
|
|
1558
|
+
yield scopeCtx.slots.register({
|
|
1559
|
+
name: "settings.plugin.item",
|
|
1560
|
+
key: "git-badge",
|
|
1561
|
+
locale: NS,
|
|
1562
|
+
inject: () => ({
|
|
1563
|
+
hooks: { gitBadgeOrderCard: store },
|
|
1564
|
+
move: (index, delta) => {
|
|
1565
|
+
draft = moveCategory(draft ?? [...storedOrder()], index, delta);
|
|
1566
|
+
failed = false;
|
|
1567
|
+
publish();
|
|
1568
|
+
},
|
|
1569
|
+
save: () => void save(),
|
|
1570
|
+
discard: () => {
|
|
1571
|
+
draft = null;
|
|
1572
|
+
failed = false;
|
|
1573
|
+
publish();
|
|
1574
|
+
},
|
|
1575
|
+
reset: () => {
|
|
1576
|
+
draft = [...DEFAULT_ORDER];
|
|
1577
|
+
failed = false;
|
|
1578
|
+
publish();
|
|
1579
|
+
}
|
|
1580
|
+
})
|
|
1581
|
+
}, GitBadgeOrderCard);
|
|
1582
|
+
});
|
|
1583
|
+
});
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
console.error("[dsh-git-badge] settings card unavailable:", error);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1270
1589
|
// inject() re-evaluates when a seam's declaration appears, so boot
|
|
1271
1590
|
// order relative to the workspace browser does not matter. On an
|
|
1272
1591
|
// unpatched install the seam is never declared, so this callback never
|
|
@@ -1348,7 +1667,7 @@ window.__ModuleLoader__.load({
|
|
|
1348
1667
|
// Additive; the host reads apply/inject and ignores the rest. The suite
|
|
1349
1668
|
// drives these to assert the REQUEST contract — which surface asks for the
|
|
1350
1669
|
// expensive extras — without a browser, a fetch or a network.
|
|
1351
|
-
exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions };
|
|
1670
|
+
exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, moveCategory };
|
|
1352
1671
|
return module.exports;
|
|
1353
1672
|
}
|
|
1354
1673
|
});
|
package/lib/index.js
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
import { execFile } from "node:child_process";
|
|
70
70
|
import { watch, existsSync, readFileSync } from "node:fs";
|
|
71
71
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
72
|
-
import { clearRestartMarker, readRestartMarker } from "../seam/store.js";
|
|
72
|
+
import { SESSION_CHECKOUT_TTL_MS, clearRestartMarker, readRestartMarker, readSessionCheckouts } from "../seam/store.js";
|
|
73
73
|
|
|
74
74
|
const inject = ["webServer", "workspaceRegistry"];
|
|
75
75
|
const name = "dsh-git-badge";
|
|
@@ -82,6 +82,13 @@ const name = "dsh-git-badge";
|
|
|
82
82
|
* single documented seam.
|
|
83
83
|
*/
|
|
84
84
|
const config = {
|
|
85
|
+
/**
|
|
86
|
+
* Test seam: the session-checkout registrations file. Null (the default)
|
|
87
|
+
* resolves it under the plugin's data dir (SEAM_DATA_DIR-aware store.js).
|
|
88
|
+
*/
|
|
89
|
+
sessionCheckoutsFile: null,
|
|
90
|
+
/** Test seam: resolves the schema lib the settings namespace is built from. */
|
|
91
|
+
settingsSchemaLoader: async () => (await import("@deepseek-ai/schemastery")).default,
|
|
85
92
|
/** budget for every git invocation; an expired call is killed */
|
|
86
93
|
gitTimeoutMs: 3000,
|
|
87
94
|
/** network fetch budget; worst-case route latency, paid once per TTL */
|
|
@@ -380,43 +387,6 @@ async function readPrStatus(toplevel, branch) {
|
|
|
380
387
|
return summarizePr(json);
|
|
381
388
|
}
|
|
382
389
|
|
|
383
|
-
/** toplevel -> { lastAttemptAt, inFlight, value: Map<branch, pr> }. */
|
|
384
|
-
const prListState = new Map();
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Every OPEN pull request in `toplevel`'s repository, keyed by its head branch.
|
|
388
|
-
* ONE `gh pr list` answers the whole repository, which is what makes worktree
|
|
389
|
-
* selection affordable: N worktrees cost one forge call, not N.
|
|
390
|
-
*
|
|
391
|
-
* Degrades exactly like readPrStatus, and for the same reason: a non-array body,
|
|
392
|
-
* unparseable output, a logged-out `gh`, a non-GitHub `origin` and a timeout all
|
|
393
|
-
* resolve an empty map. "No PRs" and "no forge" are the same answer here — both
|
|
394
|
-
* mean no worktree can be shown to be the one the session is working in.
|
|
395
|
-
*/
|
|
396
|
-
async function readOpenPrs(toplevel) {
|
|
397
|
-
if (!(await originIsGitHub(toplevel))) return new Map();
|
|
398
|
-
const out = await runPrCli("gh", ["pr", "list", "--state", "open", "--limit", String(config.prListLimit), "--json", "number,headRefName,state,isDraft,reviewDecision,statusCheckRollup,url"], {
|
|
399
|
-
cwd: toplevel,
|
|
400
|
-
timeout: config.prTimeoutMs,
|
|
401
|
-
env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
|
|
402
|
-
});
|
|
403
|
-
if (out.stdout === null) return new Map();
|
|
404
|
-
let json;
|
|
405
|
-
try {
|
|
406
|
-
json = JSON.parse(out.stdout);
|
|
407
|
-
} catch {
|
|
408
|
-
return new Map();
|
|
409
|
-
}
|
|
410
|
-
if (!Array.isArray(json)) return new Map();
|
|
411
|
-
const byBranch = new Map();
|
|
412
|
-
for (const row of json) {
|
|
413
|
-
const branch = row?.headRefName;
|
|
414
|
-
if (typeof branch !== "string" || branch === "") continue;
|
|
415
|
-
const pr = summarizePr(row);
|
|
416
|
-
if (pr !== void 0) byBranch.set(branch, pr);
|
|
417
|
-
}
|
|
418
|
-
return byBranch;
|
|
419
|
-
}
|
|
420
390
|
|
|
421
391
|
/** Order-insensitive identity for an open-PR set, so "did it change?" is exact. */
|
|
422
392
|
function prListKey(byBranch) {
|
|
@@ -424,63 +394,6 @@ function prListKey(byBranch) {
|
|
|
424
394
|
return JSON.stringify([...byBranch].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
425
395
|
}
|
|
426
396
|
|
|
427
|
-
/**
|
|
428
|
-
* Open PRs for toplevel: the value to serve NOW (possibly undefined), spawning an
|
|
429
|
-
* out-of-band refresh when the TTL has lapsed — the same shape and the same
|
|
430
|
-
* reasoning as prStatusFor, because the forge is never route latency.
|
|
431
|
-
*
|
|
432
|
-
* `notify` fires only when the set CHANGES: a changed set can change which
|
|
433
|
-
* worktree a session badge follows, so the client has to be told to refetch
|
|
434
|
-
* rather than waiting for its 60s safety-net poll.
|
|
435
|
-
*/
|
|
436
|
-
function openPrsFor(toplevel, notify) {
|
|
437
|
-
if (config.prStatus === "off") return void 0;
|
|
438
|
-
const now = Date.now();
|
|
439
|
-
const state = prListState.get(toplevel);
|
|
440
|
-
if (state !== void 0) {
|
|
441
|
-
// Fresh, or a refresh is already running: serve what we have either way.
|
|
442
|
-
if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
|
|
443
|
-
}
|
|
444
|
-
const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
|
|
445
|
-
const inFlight = (async () => {
|
|
446
|
-
const value = await readOpenPrs(toplevel).catch(() => void 0);
|
|
447
|
-
if (value !== void 0) {
|
|
448
|
-
const changed = prListKey(record.value) !== prListKey(value);
|
|
449
|
-
record.value = value;
|
|
450
|
-
if (changed) notify(toplevel);
|
|
451
|
-
}
|
|
452
|
-
record.lastAttemptAt = Date.now();
|
|
453
|
-
record.inFlight = null;
|
|
454
|
-
return value;
|
|
455
|
-
})();
|
|
456
|
-
record.inFlight = inFlight;
|
|
457
|
-
prListState.set(toplevel, record);
|
|
458
|
-
return record.value;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* The one linked worktree a session badge may follow: exactly one entry other
|
|
463
|
-
* than the checkout itself that is checked out on a branch with an OPEN PR.
|
|
464
|
-
*
|
|
465
|
-
* Exactly one, because the plugin cannot know which tree a session uses — DSH
|
|
466
|
-
* records no session→worktree link at all — so an ambiguous repository stays on
|
|
467
|
-
* its own checkout rather than guessing. A second open PR is a reason to say
|
|
468
|
-
* nothing, not a reason to pick one. Detached, bare and prunable entries are
|
|
469
|
-
* never candidates: they have no branch a PR could belong to.
|
|
470
|
-
*/
|
|
471
|
-
function selectSessionWorktree(worktrees, openPrs, checkoutPath) {
|
|
472
|
-
if (openPrs === void 0 || openPrs.size === 0) return void 0;
|
|
473
|
-
const candidates = worktrees.filter((wt) =>
|
|
474
|
-
wt.path !== checkoutPath &&
|
|
475
|
-
wt.detached !== true &&
|
|
476
|
-
wt.bare !== true &&
|
|
477
|
-
wt.prunable !== true &&
|
|
478
|
-
typeof wt.branch === "string" &&
|
|
479
|
-
openPrs.has(wt.branch)
|
|
480
|
-
);
|
|
481
|
-
return candidates.length === 1 ? candidates[0] : void 0;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
397
|
/**
|
|
485
398
|
* PR state for toplevel: the value to serve NOW (possibly undefined, possibly
|
|
486
399
|
* stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
|
|
@@ -741,19 +654,57 @@ const OPERATION_NEXT = {
|
|
|
741
654
|
*/
|
|
742
655
|
const DEFAULT_NEXT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
|
|
743
656
|
|
|
744
|
-
/**
|
|
745
|
-
function
|
|
657
|
+
/** Normalise any order list: known categories first, then the rest in default order. */
|
|
658
|
+
function normalizeOrder(order) {
|
|
659
|
+
if (!Array.isArray(order)) return DEFAULT_NEXT_ORDER;
|
|
660
|
+
const known = order.filter((cat) => DEFAULT_NEXT_ORDER.includes(cat));
|
|
661
|
+
return [...known, ...DEFAULT_NEXT_ORDER.filter((cat) => !known.includes(cat))];
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The settings namespace this plugin owns — its order lives there, live. */
|
|
665
|
+
const SETTINGS_NS = "git-badge";
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The namespace schema, built from the host-provided schema lib — imported
|
|
669
|
+
* LAZILY because this plugin's module graph must load without it (the test
|
|
670
|
+
* suite runs from a bare checkout, and a host without the settings stack should
|
|
671
|
+
* lose only the settings card, never the badge).
|
|
672
|
+
*/
|
|
673
|
+
function orderSettingsSchema(Schema) {
|
|
674
|
+
return Schema.object({
|
|
675
|
+
order: Schema.array(Schema.union(DEFAULT_NEXT_ORDER)).default(DEFAULT_NEXT_ORDER)
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The pre-settings override file. Still honoured as the namespace's BASE (a
|
|
681
|
+
* composition default, so a user who configured the JSON keeps their order
|
|
682
|
+
* after the upgrade) and as a fallback when the settings service is absent.
|
|
683
|
+
*/
|
|
684
|
+
function legacyOrderFile() {
|
|
746
685
|
try {
|
|
747
686
|
const home = process.env.DSH_HOME || join(process.env.HOME || "", ".dsh");
|
|
748
687
|
const raw = JSON.parse(readFileSync(join(home, "git-badge-next.json"), "utf8"));
|
|
749
|
-
|
|
750
|
-
const known = raw.order.filter((cat) => DEFAULT_NEXT_ORDER.includes(cat));
|
|
751
|
-
return [...known, ...DEFAULT_NEXT_ORDER.filter((cat) => !known.includes(cat))];
|
|
688
|
+
return normalizeOrder(raw?.order);
|
|
752
689
|
} catch {
|
|
753
690
|
return DEFAULT_NEXT_ORDER;
|
|
754
691
|
}
|
|
755
692
|
}
|
|
756
693
|
|
|
694
|
+
/**
|
|
695
|
+
* The live order — one in-memory value, updated by the settings scope. It is
|
|
696
|
+
* authoritative only once the namespace actually registered; until then (and on
|
|
697
|
+
* a host without the settings stack, or in a unit test that never calls apply)
|
|
698
|
+
* the legacy file is read per call, which is the pre-settings behaviour.
|
|
699
|
+
*/
|
|
700
|
+
let currentOrder = DEFAULT_NEXT_ORDER;
|
|
701
|
+
let settingsOrderActive = false;
|
|
702
|
+
|
|
703
|
+
/** The ranking order every status read uses. */
|
|
704
|
+
function nextOrder() {
|
|
705
|
+
return settingsOrderActive ? currentOrder : legacyOrderFile();
|
|
706
|
+
}
|
|
707
|
+
|
|
757
708
|
/**
|
|
758
709
|
* The hover card's "action" row and the (+) menu's picker: every sub-action the
|
|
759
710
|
* checkout justifies, each as a `gh`-skill invocation ({ args, why, what } —
|
|
@@ -1333,10 +1284,10 @@ function watchWorkspace(root, key, workspaceId) {
|
|
|
1333
1284
|
}
|
|
1334
1285
|
|
|
1335
1286
|
/**
|
|
1336
|
-
* Workspace path -> { worktreePath, workspaceId, owned } for the
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1287
|
+
* Workspace path -> { worktreePath, workspaceId, owned } for the worktree a
|
|
1288
|
+
* session badge is currently following. One per workspace: a session follows at
|
|
1289
|
+
* most one tree, so following a different tree (or none) retires the previous
|
|
1290
|
+
* entry here.
|
|
1340
1291
|
*/
|
|
1341
1292
|
const selectedWorktrees = new Map();
|
|
1342
1293
|
|
|
@@ -1450,48 +1401,117 @@ async function resolveWorkspace(ctx, params) {
|
|
|
1450
1401
|
return { status: 404, error: { code: "session-not-found", message: "no registered workspace owns that session" } };
|
|
1451
1402
|
}
|
|
1452
1403
|
|
|
1404
|
+
/**
|
|
1405
|
+
* The checkout a session registered for itself, or undefined.
|
|
1406
|
+
*
|
|
1407
|
+
* This is the ONLY source for "which worktree is this session using", and it has
|
|
1408
|
+
* to be explicit: DSH records no session→worktree link (a session's cwd is
|
|
1409
|
+
* immutable creation metadata and always the main checkout), so nothing in the
|
|
1410
|
+
* host can answer it. The agent writes the registration with
|
|
1411
|
+
* `dsh-git-badge-checkout <path>`; the file is advisory, so it is validated
|
|
1412
|
+
* here before it is believed — the path must be a worktree git itself lists,
|
|
1413
|
+
* the entry must be fresher than SESSION_CHECKOUT_TTL_MS, and the name/branch
|
|
1414
|
+
* come from git rather than from the file. A stale, hand-edited or removed
|
|
1415
|
+
* registration can therefore only fail to resolve, never point the badge at a
|
|
1416
|
+
* directory that is not a worktree of this repository.
|
|
1417
|
+
*/
|
|
1418
|
+
function sessionCheckouts() {
|
|
1419
|
+
if (typeof config.sessionCheckoutsFile === "string") {
|
|
1420
|
+
try {
|
|
1421
|
+
const raw = JSON.parse(readFileSync(config.sessionCheckoutsFile, "utf8"));
|
|
1422
|
+
return raw !== null && typeof raw === "object" && typeof raw.sessions === "object" && raw.sessions !== null
|
|
1423
|
+
? raw.sessions
|
|
1424
|
+
: {};
|
|
1425
|
+
} catch {
|
|
1426
|
+
return {};
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return readSessionCheckouts();
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async function registeredCheckout(target, sessionId) {
|
|
1433
|
+
if (typeof sessionId !== "string" || sessionId === "") return void 0;
|
|
1434
|
+
const entry = sessionCheckouts()[sessionId];
|
|
1435
|
+
if (entry === void 0 || typeof entry?.path !== "string") return void 0;
|
|
1436
|
+
const at = Date.parse(String(entry.at ?? ""));
|
|
1437
|
+
if (!Number.isFinite(at) || Date.now() - at > SESSION_CHECKOUT_TTL_MS) return void 0;
|
|
1438
|
+
const { toplevel, worktrees } = await worktreesFor(target.path);
|
|
1439
|
+
// Git lists the main worktree first, and a registration only means something
|
|
1440
|
+
// when the session's own directory IS that main checkout: a session already
|
|
1441
|
+
// inside a linked worktree is its own answer and is never swapped sideways.
|
|
1442
|
+
const main = worktrees[0];
|
|
1443
|
+
if (toplevel === "" || main === void 0 || main.path !== toplevel || target.path !== toplevel || worktrees.length <= 1) return void 0;
|
|
1444
|
+
const found = worktrees.find((tree) => tree.path === entry.path);
|
|
1445
|
+
if (found === void 0 || found.path === toplevel) return void 0;
|
|
1446
|
+
return { path: found.path, name: found.name, branch: found.branch };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1453
1449
|
/**
|
|
1454
1450
|
* The directory a request is ABOUT.
|
|
1455
1451
|
*
|
|
1456
|
-
* A SESSION-targeted chip
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
* place that knows which tree was chosen.
|
|
1452
|
+
* A SESSION-targeted chip follows the checkout that session REGISTERED — the one
|
|
1453
|
+
* signal that knows where the agent is actually working. Without a registration
|
|
1454
|
+
* the request stays on the session's own directory: the plugin never guesses a
|
|
1455
|
+
* worktree from pull requests (an earlier build inferred one from the branch
|
|
1456
|
+
* with the open PR; the guess was invisible to the session that made it and
|
|
1457
|
+
* silently wrong whenever two trees were in play).
|
|
1463
1458
|
*
|
|
1464
1459
|
* A WORKSPACE-targeted row never follows a worktree: a row surveys the checkout
|
|
1465
1460
|
* the registry owns, and substituting a tree would make the row lie about the
|
|
1466
1461
|
* branch it is on.
|
|
1467
|
-
*
|
|
1468
|
-
* Gated three ways so the common case costs nothing: the caller must name a
|
|
1469
|
-
* session, must be asking about PR state at all (selection is defined by an open
|
|
1470
|
-
* PR, so a caller not asking cannot benefit), and the repository must actually
|
|
1471
|
-
* have a linked worktree. Only then is the forge consulted.
|
|
1472
1462
|
*/
|
|
1473
|
-
async function effectiveTarget(target,
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
selectWorktreeWatch(target.path, target.id, void 0);
|
|
1483
|
-
return { id: target.id, path: target.path };
|
|
1463
|
+
async function effectiveTarget(target, sessionId) {
|
|
1464
|
+
const bySession = typeof sessionId === "string" && sessionId !== "";
|
|
1465
|
+
if (config.worktreeStatus !== "off") {
|
|
1466
|
+
const registered = bySession ? await registeredCheckout(target, sessionId) : void 0;
|
|
1467
|
+
if (registered !== void 0) {
|
|
1468
|
+
selectWorktreeWatch(target.path, target.id, registered.path);
|
|
1469
|
+
// The name and branch travel; the PATH never does (AGENTS.md rule 7).
|
|
1470
|
+
return { id: target.id, path: registered.path, worktree: { name: registered.name, branch: registered.branch } };
|
|
1471
|
+
}
|
|
1484
1472
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
selectWorktreeWatch(target.path, target.id,
|
|
1488
|
-
|
|
1489
|
-
// The name and branch travel; the PATH never does (AGENTS.md rule 7).
|
|
1490
|
-
return { id: target.id, path: chosen.path, worktree: { name: chosen.name, branch: chosen.branch } };
|
|
1473
|
+
// No registration (or a stale one): retire any follow this workspace had, so
|
|
1474
|
+
// clearing the registration actually stops the extra watcher.
|
|
1475
|
+
selectWorktreeWatch(target.path, target.id, void 0);
|
|
1476
|
+
return { id: target.id, path: target.path };
|
|
1491
1477
|
}
|
|
1492
1478
|
|
|
1493
1479
|
/** Host plugin body — register the status route and the SSE change feed. */
|
|
1494
1480
|
function apply(ctx) {
|
|
1481
|
+
// The ranking order lives in this plugin's own settings namespace: declared
|
|
1482
|
+
// with a schema so the host validates it, stored in the user's settings
|
|
1483
|
+
// document, and observed live — a change applies to the next status read
|
|
1484
|
+
// with no file poll and no restart. The pre-settings JSON file, when it
|
|
1485
|
+
// exists, seeds the namespace as its composition BASE so an order configured
|
|
1486
|
+
// before this existed survives the upgrade; it is also the fallback when the
|
|
1487
|
+
// settings service is absent (a host composed without dsh-settings).
|
|
1488
|
+
currentOrder = legacyOrderFile();
|
|
1489
|
+
if (typeof ctx.inject === "function") {
|
|
1490
|
+
try {
|
|
1491
|
+
ctx.inject(["settings"], async (settingsCtx) => {
|
|
1492
|
+
let Schema;
|
|
1493
|
+
try {
|
|
1494
|
+
Schema = await config.settingsSchemaLoader();
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
console.error("[dsh-git-badge] schema lib unavailable; keeping the legacy order file:", error);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const scope = settingsCtx.settings.register(SETTINGS_NS, orderSettingsSchema(Schema), {
|
|
1500
|
+
base: { order: legacyOrderFile() }
|
|
1501
|
+
});
|
|
1502
|
+
currentOrder = normalizeOrder(scope.get()?.order);
|
|
1503
|
+
settingsOrderActive = true;
|
|
1504
|
+
settingsCtx.effect(() => scope.watch((next) => {
|
|
1505
|
+
currentOrder = normalizeOrder(next?.order);
|
|
1506
|
+
}), "dsh-git-badge: order settings");
|
|
1507
|
+
settingsCtx.effect(() => () => {
|
|
1508
|
+
settingsOrderActive = false;
|
|
1509
|
+
}, "dsh-git-badge: order settings teardown");
|
|
1510
|
+
});
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
console.error("[dsh-git-badge] settings namespace unavailable, using the legacy order file:", error);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1495
1515
|
// A fresh boot composed the files on disk into the new bundles — any
|
|
1496
1516
|
// restart-pending marker was written by an install/apply against the PREVIOUS
|
|
1497
1517
|
// boot and its change is now live. Clearing here is what makes the marker
|
|
@@ -1516,7 +1536,7 @@ function apply(ctx) {
|
|
|
1516
1536
|
// session's own directory — see effectiveTarget. A workspace-targeted
|
|
1517
1537
|
// row never leaves the checkout the registry owns.
|
|
1518
1538
|
const session = url.searchParams.get("session");
|
|
1519
|
-
const effective = await effectiveTarget(target, session
|
|
1539
|
+
const effective = await effectiveTarget(target, session ?? void 0);
|
|
1520
1540
|
const info = await gitStatus(effective.path, detail, pr);
|
|
1521
1541
|
const body = { ...info, workspace: target.id };
|
|
1522
1542
|
if (effective.worktree !== void 0 && info.git === true) {
|
|
@@ -1524,7 +1544,7 @@ function apply(ctx) {
|
|
|
1524
1544
|
// name. Saying so is the whole honesty of the feature: the chip's
|
|
1525
1545
|
// mark says "worktree", its name says which, and this flag tells the
|
|
1526
1546
|
// card why the checkout below differs from the branch beside it.
|
|
1527
|
-
body.
|
|
1547
|
+
body.worktreeFollowed = true;
|
|
1528
1548
|
if (detail) {
|
|
1529
1549
|
// The session's OWN directory, for the card's `checkout` row, so the
|
|
1530
1550
|
// main checkout stays visible once the badge follows a tree. Gated
|
|
@@ -1645,10 +1665,6 @@ export {
|
|
|
1645
1665
|
summarizeChecks,
|
|
1646
1666
|
prStatusFor,
|
|
1647
1667
|
prState,
|
|
1648
|
-
readOpenPrs,
|
|
1649
|
-
openPrsFor,
|
|
1650
|
-
prListState,
|
|
1651
|
-
selectSessionWorktree,
|
|
1652
1668
|
selectWorktreeWatch,
|
|
1653
1669
|
selectedWorktrees,
|
|
1654
1670
|
statusInFlight,
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"dsh-git-badge-seam": "seam/apply.js"
|
|
8
|
+
"dsh-git-badge-seam": "seam/apply.js",
|
|
9
|
+
"dsh-git-badge-checkout": "seam/checkout.js"
|
|
9
10
|
},
|
|
10
11
|
"exports": {
|
|
11
12
|
".": "./lib/index.js",
|
|
@@ -56,10 +57,14 @@
|
|
|
56
57
|
"package.json",
|
|
57
58
|
"seam/anchors.js",
|
|
58
59
|
"seam/apply.js",
|
|
60
|
+
"seam/checkout.js",
|
|
59
61
|
"seam/postinstall.js",
|
|
60
62
|
"seam/skill.js",
|
|
61
63
|
"seam/store.js",
|
|
62
64
|
"seam/stub-index.js",
|
|
63
65
|
"skill/gh/SKILL.md"
|
|
64
|
-
]
|
|
66
|
+
],
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
69
|
+
}
|
|
65
70
|
}
|
package/seam/checkout.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-git-badge-checkout — tell the badge which checkout THIS session is working
|
|
4
|
+
* in.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS: a dsh session's cwd is immutable creation metadata — it is
|
|
7
|
+
* the main checkout forever, even after the agent does its work in a linked
|
|
8
|
+
* worktree. The badge therefore cannot know, on its own, that a session moved
|
|
9
|
+
* into `.wt/<name>`; it can only infer a worktree from an open pull request.
|
|
10
|
+
* This command closes that gap: the agent registers the checkout explicitly, and
|
|
11
|
+
* the node half prefers the registration over inference.
|
|
12
|
+
*
|
|
13
|
+
* The registration is advisory and self-expiring (24h) — never let it fail the
|
|
14
|
+
* work that earned it:
|
|
15
|
+
*
|
|
16
|
+
* usage: npx dsh-git-badge-checkout <path> register this session
|
|
17
|
+
* npx dsh-git-badge-checkout --clear forget this session
|
|
18
|
+
*
|
|
19
|
+
* The path is validated as a real worktree of its repository before it is
|
|
20
|
+
* written, so a typo cannot point the badge at an unrelated directory.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { basename, resolve } from "node:path";
|
|
24
|
+
import { clearSessionCheckout, writeSessionCheckout } from "./store.js";
|
|
25
|
+
|
|
26
|
+
function usage(message) {
|
|
27
|
+
if (message !== void 0) console.error(message);
|
|
28
|
+
console.error("usage: dsh-git-badge-checkout <path> | --clear");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Worktree paths git lists for the repo containing `dir`. */
|
|
32
|
+
function worktreePaths(dir) {
|
|
33
|
+
const out = execFileSync("git", ["-C", dir, "worktree", "list", "--porcelain"], { encoding: "utf8" });
|
|
34
|
+
return out
|
|
35
|
+
.split("\n")
|
|
36
|
+
.filter((line) => line.startsWith("worktree "))
|
|
37
|
+
.map((line) => resolve(line.slice("worktree ".length).trim()));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function branchOf(dir) {
|
|
41
|
+
try {
|
|
42
|
+
return execFileSync("git", ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();
|
|
43
|
+
} catch {
|
|
44
|
+
return "unknown";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sessionId = process.env.DSH_SESSION_ID;
|
|
49
|
+
if (sessionId === void 0 || sessionId === "") {
|
|
50
|
+
usage("no DSH_SESSION_ID in the environment — run this from inside a dsh session.");
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const arg = process.argv[2];
|
|
55
|
+
if (arg === "--clear") {
|
|
56
|
+
clearSessionCheckout(sessionId);
|
|
57
|
+
console.log("[dsh-git-badge] checkout registration cleared for this session.");
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
if (arg === void 0 || arg === "") {
|
|
61
|
+
usage();
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const path = resolve(arg);
|
|
66
|
+
let paths;
|
|
67
|
+
try {
|
|
68
|
+
paths = worktreePaths(path);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`[dsh-git-badge] ${path} is not a git worktree: ${String(error?.message ?? error)}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
if (!paths.includes(path)) {
|
|
74
|
+
console.error(`[dsh-git-badge] ${path} is not a worktree of its repository (git lists: ${paths.join(", ")})`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!writeSessionCheckout(sessionId, path)) {
|
|
79
|
+
console.error("[dsh-git-badge] could not write the registration; the badge keeps its inferred checkout.");
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
console.log(`[dsh-git-badge] this session's checkout is now ${basename(path)} (${branchOf(path)}).`);
|
package/seam/store.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* Two out-of-band stores live here, both keyed files under dataDir():
|
|
3
|
+
*
|
|
4
|
+
* restart-pending.json install/applied a host patch → the UI offers a restart
|
|
5
|
+
* session-checkouts.json which checkout a SESSION is working in — the one
|
|
6
|
+
* fact dsh cannot supply (a session's cwd is immutable
|
|
7
|
+
* creation metadata and always the main checkout, so a
|
|
8
|
+
* session working in a linked worktree is invisible
|
|
9
|
+
* without an explicit registration)
|
|
10
|
+
*
|
|
2
11
|
* The restart-pending marker — the bridge from install-time (node side) to the
|
|
3
12
|
* running UI.
|
|
4
13
|
*
|
|
@@ -68,4 +77,60 @@ export function clearRestartMarker() {
|
|
|
68
77
|
} catch {
|
|
69
78
|
// Absent is the goal; any error leaves the marker to be cleared later.
|
|
70
79
|
}
|
|
71
|
-
}
|
|
80
|
+
}
|
|
81
|
+
/** How long a session-checkout registration stays trustworthy. */
|
|
82
|
+
export const SESSION_CHECKOUT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
83
|
+
|
|
84
|
+
function sessionCheckoutsPath() {
|
|
85
|
+
return join(dataDir(), "session-checkouts.json");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every registration, `{ sessionId: { path, at } }`. Never throws. */
|
|
89
|
+
export function readSessionCheckouts() {
|
|
90
|
+
try {
|
|
91
|
+
if (!existsSync(sessionCheckoutsPath())) return {};
|
|
92
|
+
const raw = JSON.parse(readFileSync(sessionCheckoutsPath(), "utf8"));
|
|
93
|
+
return raw !== null && typeof raw === "object" && typeof raw.sessions === "object" && raw.sessions !== null
|
|
94
|
+
? raw.sessions
|
|
95
|
+
: {};
|
|
96
|
+
} catch {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Register the checkout a session is working in. Written by the agent (the
|
|
103
|
+
* git-worktree and /gh checkout paths), read by the node half.
|
|
104
|
+
*/
|
|
105
|
+
export function writeSessionCheckout(sessionId, path) {
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(dataDir(), { recursive: true });
|
|
108
|
+
const sessions = readSessionCheckouts();
|
|
109
|
+
sessions[sessionId] = { path, at: new Date().toISOString() };
|
|
110
|
+
writeFileSync(
|
|
111
|
+
sessionCheckoutsPath(),
|
|
112
|
+
JSON.stringify({ schema: "dsh-git-badge/session-checkout/v1", sessions }),
|
|
113
|
+
"utf8",
|
|
114
|
+
);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Forget one session's registration (worktree removed, or the session left). */
|
|
122
|
+
export function clearSessionCheckout(sessionId) {
|
|
123
|
+
try {
|
|
124
|
+
const sessions = readSessionCheckouts();
|
|
125
|
+
if (!(sessionId in sessions)) return true;
|
|
126
|
+
delete sessions[sessionId];
|
|
127
|
+
writeFileSync(
|
|
128
|
+
sessionCheckoutsPath(),
|
|
129
|
+
JSON.stringify({ schema: "dsh-git-badge/session-checkout/v1", sessions }),
|
|
130
|
+
"utf8",
|
|
131
|
+
);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/skill/gh/SKILL.md
CHANGED
|
@@ -79,12 +79,23 @@ unstaged / untracked / unmerged), plus any in-progress operation marker
|
|
|
79
79
|
what is dirty and confirm the switch when work would carry over. Unknown
|
|
80
80
|
branch → say so and list the closest local names.
|
|
81
81
|
|
|
82
|
+
If the switch lands you in — or out of — a linked worktree, register where you
|
|
83
|
+
are so the session badge follows the right checkout:
|
|
84
|
+
`npx dsh-git-badge-checkout "$(git rev-parse --show-toplevel)"` (best effort;
|
|
85
|
+
ignore failure). Clearing back to the main checkout is the same command run
|
|
86
|
+
against the main worktree — the badge treats the main checkout as the default.
|
|
87
|
+
A session's cwd is fixed at creation, so this registration is the ONLY signal
|
|
88
|
+
the badge has.
|
|
89
|
+
|
|
82
90
|
- **`/gh order`** — reconfigure which action class the badge ranks first. The
|
|
83
|
-
order
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
change
|
|
91
|
+
order lives in the `git-badge` settings namespace, edited in the UI at
|
|
92
|
+
**Settings → Plugins → Git Badge** (an ordered list with up/down controls).
|
|
93
|
+
When the request comes through the agent instead, read the current value from
|
|
94
|
+
the settings document (`~/.dsh/settings.yaml`, the `git-badge:` section) and
|
|
95
|
+
change that `order` list — any subset, any order; unlisted categories rank
|
|
96
|
+
after in default order. Never invent categories. The older
|
|
97
|
+
`~/.dsh/git-badge-next.json` is only a fallback for hosts without the
|
|
98
|
+
settings stack; do not edit it when the namespace is present. Typical
|
|
88
99
|
invocations: "/gh order commit first", "/gh order never suggest checks
|
|
89
100
|
before push".
|
|
90
101
|
|