dsh-git-badge 0.5.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/LICENSE +21 -0
- package/README.md +22 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +253 -0
- package/lib/index.js +295 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin McIsaac
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# dsh-git-badge
|
|
2
|
+
|
|
3
|
+
Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
|
|
4
|
+
|
|
5
|
+
- **Input-row chip** β `π’ main` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
|
|
6
|
+
- **Sidebar row badges** β full Claude-Code-statusline-style badges (`| π‘ main β0 β2 β3`) on workspace rows. Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
|
|
7
|
+
|
|
8
|
+
Event-driven freshness: working trees are watched and updates push over SSE β badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample; the API only answers registered workspace paths.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
dsh plugin --profile web add dsh-git-badge
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Restart the web process, refresh the browser. Requires Node β₯ 20 and `git` on PATH.
|
|
17
|
+
|
|
18
|
+
Seam details: [github.com/Kevin-McIsaac/dsh-workspace-git-badge](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) (SEAM.md / PR.md).
|
|
19
|
+
|
|
20
|
+
## License
|
|
21
|
+
|
|
22
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-git-badge β client half.
|
|
3
|
+
*
|
|
4
|
+
* Surfaces:
|
|
5
|
+
* - sidebar.workspaces.row (seam): full row badge β emoji, branch, sync, βfiles
|
|
6
|
+
* - conversation.input.left (upstream): compact chip β emoji + branch only
|
|
7
|
+
* The hover card is intentionally untouched.
|
|
8
|
+
*/
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: "dsh-git-badge",
|
|
11
|
+
factory: (require) => {
|
|
12
|
+
var module = { exports: {} };
|
|
13
|
+
var exports = module.exports;
|
|
14
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
15
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
16
|
+
let react = require("react");
|
|
17
|
+
|
|
18
|
+
/** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
|
|
19
|
+
const GIT_CACHE = new Map();
|
|
20
|
+
/** In-flight fetches per cacheKey: bursts collapse into one request. */
|
|
21
|
+
const GIT_INFLIGHT = new Map();
|
|
22
|
+
/** Slow safety-net poll: refreshes even if the SSE stream is silently dead. */
|
|
23
|
+
const FALLBACK_POLL_MS = 60000;
|
|
24
|
+
|
|
25
|
+
//#region SSE change feed (one EventSource per page, ref-counted)
|
|
26
|
+
const eventListeners = new Set();
|
|
27
|
+
let eventSource = null;
|
|
28
|
+
let eventRefs = 0;
|
|
29
|
+
|
|
30
|
+
function subscribeGitEvents(onChange) {
|
|
31
|
+
eventListeners.add(onChange);
|
|
32
|
+
eventRefs += 1;
|
|
33
|
+
if (eventSource === null) {
|
|
34
|
+
eventSource = new EventSource("/api/git-badge/events");
|
|
35
|
+
eventSource.onmessage = (message) => {
|
|
36
|
+
let path;
|
|
37
|
+
try {
|
|
38
|
+
path = JSON.parse(message.data).path;
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// invalidate every cache entry for this workspace, then refetch
|
|
43
|
+
for (const key of [...GIT_CACHE.keys()]) {
|
|
44
|
+
if (key === path) GIT_CACHE.delete(key);
|
|
45
|
+
}
|
|
46
|
+
for (const fn of [...eventListeners]) {
|
|
47
|
+
try {
|
|
48
|
+
fn(path);
|
|
49
|
+
} catch {
|
|
50
|
+
/* one bad subscriber must not starve the rest */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return () => {
|
|
56
|
+
eventListeners.delete(onChange);
|
|
57
|
+
eventRefs -= 1;
|
|
58
|
+
if (eventRefs === 0 && eventSource !== null) {
|
|
59
|
+
eventSource.close();
|
|
60
|
+
eventSource = null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Shared git-status hook. Fetches once on mount and then only when the
|
|
68
|
+
* node half's watcher reports a change for this workspace (SSE), plus a
|
|
69
|
+
* slow safety-net poll in case the stream dies silently. Returns
|
|
70
|
+
* undefined while loading and for non-git paths.
|
|
71
|
+
*/
|
|
72
|
+
function useGitStatus(cwd) {
|
|
73
|
+
const cacheKey = cwd;
|
|
74
|
+
const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
|
|
75
|
+
const [info, setInfo] = react.useState(hit !== void 0 ? hit.data : void 0);
|
|
76
|
+
react.useEffect(() => {
|
|
77
|
+
if (cacheKey === void 0) return;
|
|
78
|
+
let alive = true;
|
|
79
|
+
const apply = (data) => {
|
|
80
|
+
// A degraded response (git timeout/failure) must never clobber a
|
|
81
|
+
// good cached badge β keep the last-known state until a real
|
|
82
|
+
// event or the fallback poll succeeds.
|
|
83
|
+
if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
|
|
84
|
+
GIT_CACHE.set(cacheKey, { at: Date.now(), data });
|
|
85
|
+
if (alive) setInfo(data);
|
|
86
|
+
};
|
|
87
|
+
const load = () => {
|
|
88
|
+
// dedupe: an in-flight fetch for this key serves all callers
|
|
89
|
+
let pending = GIT_INFLIGHT.get(cacheKey);
|
|
90
|
+
if (pending === void 0) {
|
|
91
|
+
pending = fetch("/api/git-badge?path=" + encodeURIComponent(cwd))
|
|
92
|
+
.then((r) => r.json())
|
|
93
|
+
.finally(() => GIT_INFLIGHT.delete(cacheKey));
|
|
94
|
+
GIT_INFLIGHT.set(cacheKey, pending);
|
|
95
|
+
}
|
|
96
|
+
pending.then(apply).catch(() => {});
|
|
97
|
+
};
|
|
98
|
+
load();
|
|
99
|
+
// this workspace's watcher events β refetch
|
|
100
|
+
const unsubscribe = subscribeGitEvents((path) => {
|
|
101
|
+
if (path === cwd) load();
|
|
102
|
+
});
|
|
103
|
+
const fallback = setInterval(load, FALLBACK_POLL_MS);
|
|
104
|
+
return () => {
|
|
105
|
+
alive = false;
|
|
106
|
+
unsubscribe();
|
|
107
|
+
clearInterval(fallback);
|
|
108
|
+
};
|
|
109
|
+
}, [cacheKey]);
|
|
110
|
+
return info;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Shared sync/dirty suffix for all surfaces. Rule set:
|
|
115
|
+
* - dirty files render as βn (pencil = worktree files, distinct from
|
|
116
|
+
* the β/β commit-sync axis)
|
|
117
|
+
* - while dirty, BOTH sync counts render including zeros (β0 β2 β3),
|
|
118
|
+
* so every number is positionally attributable
|
|
119
|
+
* - clean workspaces stay quiet: arrows only when nonzero, no β at 0
|
|
120
|
+
* - no upstream: arrows omitted entirely
|
|
121
|
+
*/
|
|
122
|
+
function formatGitSuffix(info) {
|
|
123
|
+
const parts = [];
|
|
124
|
+
const hasUpstream = info.ahead !== void 0 || info.behind !== void 0;
|
|
125
|
+
const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
|
|
126
|
+
if (hasUpstream && (files > 0 || info.ahead > 0 || info.behind > 0)) {
|
|
127
|
+
parts.push("\u2191" + (info.ahead || 0));
|
|
128
|
+
parts.push("\u2193" + (info.behind || 0));
|
|
129
|
+
}
|
|
130
|
+
if (files > 0) parts.push("\u270E" + files);
|
|
131
|
+
return parts.length > 0 ? " " + parts.join(" ") : "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const META_STYLE = {
|
|
135
|
+
color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
|
|
136
|
+
fontSize: "12px",
|
|
137
|
+
lineHeight: "20px",
|
|
138
|
+
flex: "none",
|
|
139
|
+
whiteSpace: "nowrap"
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Row badge, Claude Code statusline style:
|
|
144
|
+
* the_paragliding_app | π’ main β1 β2
|
|
145
|
+
* Always renders the workspace name (so the row keeps its identity);
|
|
146
|
+
* appends the muted `| emoji branch sync` part only for git workspaces.
|
|
147
|
+
*/
|
|
148
|
+
function WorkspaceGitBadge({ label, cwd }) {
|
|
149
|
+
const info = useGitStatus(cwd, false);
|
|
150
|
+
const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
|
|
151
|
+
if (cwd !== void 0 && info !== void 0 && info.git === true) {
|
|
152
|
+
children.push(
|
|
153
|
+
react_jsx_runtime.jsx("span", { style: { ...META_STYLE, margin: "0 7px" }, children: "|" }),
|
|
154
|
+
react_jsx_runtime.jsx("span", { style: { ...META_STYLE, marginRight: "4px" }, children: info.dirty ? "π‘" : "π’" }),
|
|
155
|
+
react_jsx_runtime.jsx("span", { style: META_STYLE, children: info.branch + formatGitSuffix(info) })
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0 }, children });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
//#region composer chip (fallback + companion surface: conversation.composer.dock)
|
|
162
|
+
/** Client root ctx, captured at apply() for the chip's service lookups. */
|
|
163
|
+
let clientCtx = null;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the workspace cwd attached to a session: projects the
|
|
167
|
+
* workspaces service's snapshot store (items carry path + sessionIds)
|
|
168
|
+
* and re-resolves on change. Returns undefined while unknown.
|
|
169
|
+
*/
|
|
170
|
+
function useSessionWorkspaceCwd(sessionId) {
|
|
171
|
+
const workspaces = clientCtx === null ? null : clientCtx.get("workspaces");
|
|
172
|
+
const resolve = () => {
|
|
173
|
+
if (sessionId === void 0 || workspaces === void 0) return void 0;
|
|
174
|
+
const item = workspaces.list.getSnapshot().items.find((w) => w.sessionIds !== void 0 && w.sessionIds.includes(sessionId));
|
|
175
|
+
return item === void 0 ? void 0 : item.path;
|
|
176
|
+
};
|
|
177
|
+
const [cwd, setCwd] = react.useState(resolve);
|
|
178
|
+
react.useEffect(() => {
|
|
179
|
+
if (sessionId === void 0 || workspaces === void 0) return;
|
|
180
|
+
setCwd(resolve());
|
|
181
|
+
return workspaces.list.subscribe(() => {
|
|
182
|
+
setCwd(resolve());
|
|
183
|
+
});
|
|
184
|
+
}, [sessionId, workspaces]);
|
|
185
|
+
return cwd;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Chip line docked at the composer: git state of the workspace the
|
|
190
|
+
* CURRENT conversation is attached to. Works on unpatched installs
|
|
191
|
+
* (conversation.composer.dock is an upstream additive slot), and stays
|
|
192
|
+
* useful next to the sidebar rows on patched/upstream-seam installs
|
|
193
|
+
* because it is context-anchored ("where am I") rather than surveying.
|
|
194
|
+
*/
|
|
195
|
+
function ComposerGitChip({ sessionId }) {
|
|
196
|
+
const cwd = useSessionWorkspaceCwd(sessionId);
|
|
197
|
+
const info = useGitStatus(cwd, true);
|
|
198
|
+
if (cwd === void 0 || info === void 0 || info.git !== true) return null;
|
|
199
|
+
const text = (info.dirty ? "\uD83D\uDFE1 " : "\uD83D\uDFE2 ") + info.branch + formatGitSuffix(info);
|
|
200
|
+
return react_jsx_runtime.jsx("span", {
|
|
201
|
+
style: {
|
|
202
|
+
display: "inline-flex",
|
|
203
|
+
alignItems: "center",
|
|
204
|
+
flex: "none",
|
|
205
|
+
gap: "4px",
|
|
206
|
+
color: "var(--dsw-alias-label-secondary, #5b6570)",
|
|
207
|
+
fontSize: "12px",
|
|
208
|
+
lineHeight: "24px",
|
|
209
|
+
whiteSpace: "nowrap",
|
|
210
|
+
cursor: "default"
|
|
211
|
+
},
|
|
212
|
+
children: text
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
216
|
+
|
|
217
|
+
const inject = ["slots", "workspaces"];
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Register the badge into both seams. The seam owner hands each entry the
|
|
221
|
+
* row owner share as props; the badge destructures { workspaceId, cwd,
|
|
222
|
+
* label } from it.
|
|
223
|
+
*/
|
|
224
|
+
function apply(ctx) {
|
|
225
|
+
clientCtx = ctx;
|
|
226
|
+
// inject() re-evaluates when a seam's declaration appears, so boot
|
|
227
|
+
// order relative to the workspace browser does not matter. On an
|
|
228
|
+
// unpatched install the row seams never get declared, so those two
|
|
229
|
+
// registrations simply never render anything.
|
|
230
|
+
ctx.slots.inject("sidebar.workspaces.row", () => ctx.slots.register({
|
|
231
|
+
name: "sidebar.workspaces.row",
|
|
232
|
+
id: "git-badge"
|
|
233
|
+
}, WorkspaceGitBadge));
|
|
234
|
+
// Input-row chip: upstream additive slot rendered in the input bar's
|
|
235
|
+
// leading cluster, right after the access picker β the git state sits
|
|
236
|
+
// with the controls that govern the conversation. Present on every
|
|
237
|
+
// install. (Alternative surface if ever needed:
|
|
238
|
+
// conversation.input.dock = the row below the input, shared with the
|
|
239
|
+
// usage-stats entry.)
|
|
240
|
+
ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
|
|
241
|
+
name: "conversation.input.left",
|
|
242
|
+
id: "git-badge-chip",
|
|
243
|
+
inject: (sessionId) => ({ sessionId })
|
|
244
|
+
}, ComposerGitChip));
|
|
245
|
+
const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
|
|
246
|
+
console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)" : "off (seam absent β sidebar badges need the sidebar.workspaces.row seam)") + ".");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
exports.apply = apply;
|
|
250
|
+
exports.inject = inject;
|
|
251
|
+
return module.exports;
|
|
252
|
+
}
|
|
253
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-git-badge β node half.
|
|
3
|
+
*
|
|
4
|
+
* Hardened git-status service behind two routes:
|
|
5
|
+
*
|
|
6
|
+
* GET /api/git-badge?path=<workspace path>[&detail=1]
|
|
7
|
+
* - path allowlist: only directories registered as DSH workspaces
|
|
8
|
+
* (WorkspaceRegistry records) are ever queried β no filesystem probing
|
|
9
|
+
* of arbitrary paths.
|
|
10
|
+
* - one git invocation per sample: `git --no-optional-locks status
|
|
11
|
+
* --porcelain=v2 --branch` yields branch, ahead/behind, and the
|
|
12
|
+
* changed / untracked counts in a single call (`--no-optional-locks`
|
|
13
|
+
* guarantees the read never contends with the user's own git
|
|
14
|
+
* operations on the index).
|
|
15
|
+
* - `detail=1` adds one `log -1` for the hover card; row refresh never
|
|
16
|
+
* pays for it.
|
|
17
|
+
*
|
|
18
|
+
* GET /api/git-badge/events (text/event-stream)
|
|
19
|
+
* - pushes `{ path }` dirty notifications the moment a workspace's git
|
|
20
|
+
* state can have changed. Freshness is EVENT-DRIVEN: each registered
|
|
21
|
+
* workspace root is watched recursively (.git included), so a commit /
|
|
22
|
+
* checkout / stage / worktree edit pushes one SSE message and every
|
|
23
|
+
* mounted row refetches immediately. There is no fixed polling
|
|
24
|
+
* interval anywhere in the pipeline.
|
|
25
|
+
*
|
|
26
|
+
* When upstream lands a workspace-metadata service, this half is deleted and
|
|
27
|
+
* the client half re-points at it β the seam contract does not change.
|
|
28
|
+
*/
|
|
29
|
+
import { execFile } from "node:child_process";
|
|
30
|
+
import { realpath } from "node:fs/promises";
|
|
31
|
+
import { watch, existsSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
|
|
34
|
+
const inject = ["webServer", "workspaceRegistry"];
|
|
35
|
+
const name = "dsh-git-badge";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run git in dir. Resolves { stdout } on success; on failure { stdout: null }
|
|
39
|
+
* plus exactly one of: timeout (execFile timeout kill), missing (no git
|
|
40
|
+
* binary), or exitCode (git ran and rejected β e.g. "not a repository").
|
|
41
|
+
* The exitCode/timeout distinction separates a definitive answer (non-repo)
|
|
42
|
+
* from a transient one (degraded).
|
|
43
|
+
*/
|
|
44
|
+
function runGit(dir, args) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
execFile("git", args, { cwd: dir, timeout: 3000 }, (error, stdout) => {
|
|
47
|
+
if (error === void 0 || error === null) resolve({ stdout: String(stdout) });
|
|
48
|
+
else if (error.killed) resolve({ stdout: null, timeout: true });
|
|
49
|
+
else if (error.code === "ENOENT") resolve({ stdout: null, missing: true });
|
|
50
|
+
else resolve({ stdout: null, exitCode: error.code });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Marker response for a git invocation that failed or timed out (transient). */
|
|
56
|
+
const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse `git status --porcelain=v2 --branch` output.
|
|
60
|
+
* Headers: `# branch.head <name>` (or `(detached)`), `# branch.upstream
|
|
61
|
+
* <name>`, `# branch.ab +ahead -behind`. Records per path: `1`/`2` carry the
|
|
62
|
+
* two-column XY status (X = index/staged, Y = worktree), `u` unmerged,
|
|
63
|
+
* `?` untracked.
|
|
64
|
+
*/
|
|
65
|
+
function parseStatusV2(out) {
|
|
66
|
+
let branch = "HEAD (detached)";
|
|
67
|
+
let upstream;
|
|
68
|
+
let ahead;
|
|
69
|
+
let behind;
|
|
70
|
+
let staged = 0;
|
|
71
|
+
let unstaged = 0;
|
|
72
|
+
let unmerged = 0;
|
|
73
|
+
let untracked = 0;
|
|
74
|
+
for (const line of out.split("\n")) {
|
|
75
|
+
if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
|
|
76
|
+
else if (line.startsWith("# branch.upstream ")) upstream = line.slice(18).trim();
|
|
77
|
+
else if (line.startsWith("# branch.ab ")) {
|
|
78
|
+
const [, plus, minus] = line.slice(12).trim().split(" ");
|
|
79
|
+
const a = Number.parseInt((plus ?? "").slice(1), 10);
|
|
80
|
+
const b = Number.parseInt((minus ?? "").slice(1), 10);
|
|
81
|
+
if (Number.isFinite(a)) ahead = a;
|
|
82
|
+
if (Number.isFinite(b)) behind = b;
|
|
83
|
+
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
84
|
+
// XY columns: X = index (staged), Y = worktree (unstaged)
|
|
85
|
+
if (line[2] !== ".") staged += 1;
|
|
86
|
+
if (line[3] !== ".") unstaged += 1;
|
|
87
|
+
} else if (line.startsWith("u ")) unmerged += 1;
|
|
88
|
+
else if (line.startsWith("? ")) untracked += 1;
|
|
89
|
+
}
|
|
90
|
+
return { branch, upstream, ahead, behind, staged, unstaged, unmerged, untracked };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Git status for dir; { git: false } when dir is not a repository,
|
|
95
|
+
* GIT_DEGRADED on transient git failure.
|
|
96
|
+
*
|
|
97
|
+
* Subdirectory workspaces: status is computed from `--show-toplevel`, so a
|
|
98
|
+
* workspace pointing INSIDE a larger repository reports that repository's
|
|
99
|
+
* branch, dirty state, and ahead/behind counts (what git itself considers
|
|
100
|
+
* dirty), not the subdirectory in isolation. Intentional.
|
|
101
|
+
*/
|
|
102
|
+
async function gitStatus(dir, wantDetail) {
|
|
103
|
+
const top = await runGit(dir, ["rev-parse", "--show-toplevel"]);
|
|
104
|
+
if (top.stdout === null) {
|
|
105
|
+
// git ran and rejected ("not a repository") is a definitive answer;
|
|
106
|
+
// timeout / missing binary is transient
|
|
107
|
+
return top.exitCode !== void 0 ? { git: false } : GIT_DEGRADED;
|
|
108
|
+
}
|
|
109
|
+
const toplevel = top.stdout.trim();
|
|
110
|
+
if (toplevel === "") return { git: false };
|
|
111
|
+
const statusOut = await runGit(toplevel, ["--no-optional-locks", "status", "--porcelain=v2", "--branch"]);
|
|
112
|
+
if (statusOut.stdout === null) return GIT_DEGRADED;
|
|
113
|
+
const parsed = parseStatusV2(statusOut.stdout);
|
|
114
|
+
const dirtyFiles = parsed.staged + parsed.unstaged + parsed.unmerged + parsed.untracked;
|
|
115
|
+
const info = {
|
|
116
|
+
git: true,
|
|
117
|
+
branch: parsed.branch,
|
|
118
|
+
upstream: parsed.upstream,
|
|
119
|
+
dirty: dirtyFiles > 0,
|
|
120
|
+
changedFiles: parsed.staged + parsed.unstaged,
|
|
121
|
+
stagedFiles: parsed.staged,
|
|
122
|
+
unstagedFiles: parsed.unstaged,
|
|
123
|
+
unmergedFiles: parsed.unmerged,
|
|
124
|
+
untrackedFiles: parsed.untracked,
|
|
125
|
+
ahead: parsed.ahead,
|
|
126
|
+
behind: parsed.behind
|
|
127
|
+
};
|
|
128
|
+
if (wantDetail) {
|
|
129
|
+
// last 3 commits (subject + relative age) for the hover card
|
|
130
|
+
const logOut = await runGit(toplevel, ["log", "-3", "--format=%h%x09%s%x09%cr"]);
|
|
131
|
+
if (logOut.stdout !== null && logOut.stdout.trim() !== "") {
|
|
132
|
+
info.lastCommits = logOut.stdout.trim().split("\n").map((line) => {
|
|
133
|
+
const [hash, subject, when] = line.split("\t");
|
|
134
|
+
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
135
|
+
}).filter((c) => c.hash !== void 0);
|
|
136
|
+
}
|
|
137
|
+
// stash count, only surfaced when nonzero
|
|
138
|
+
const stashOut = await runGit(toplevel, ["stash", "list"]);
|
|
139
|
+
if (stashOut.stdout !== null) {
|
|
140
|
+
const count = stashOut.stdout.split("\n").filter((l) => l.trim() !== "").length;
|
|
141
|
+
if (count > 0) info.stashCount = count;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return info;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//#region git-state watcher
|
|
148
|
+
/**
|
|
149
|
+
* One recursive watcher per workspace `.git` directory. Any event under it
|
|
150
|
+
* (HEAD swap, index write, ref update) marks the workspace dirty; a 200ms
|
|
151
|
+
* debounce collapses burst events (a single `git commit` touches index,
|
|
152
|
+
* refs, COMMIT_EDITMSG, objectsβ¦) into one notification.
|
|
153
|
+
*/
|
|
154
|
+
const watchers = new Map();
|
|
155
|
+
const changeListeners = new Set();
|
|
156
|
+
const WATCH_RETRY_MS = 60000;
|
|
157
|
+
const DEBOUNCE_MS = 200;
|
|
158
|
+
|
|
159
|
+
function notifyChange(key) {
|
|
160
|
+
for (const fn of changeListeners) {
|
|
161
|
+
try {
|
|
162
|
+
fn(key);
|
|
163
|
+
} catch {
|
|
164
|
+
/* a dead SSE subscriber must never break the others */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function unwatchWorkspace(key) {
|
|
170
|
+
const record = watchers.get(key);
|
|
171
|
+
if (record === void 0) return;
|
|
172
|
+
watchers.delete(key);
|
|
173
|
+
if (record.watcher !== null) record.watcher.close();
|
|
174
|
+
if (record.timer !== void 0) clearTimeout(record.timer);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function watchWorkspace(root, key) {
|
|
178
|
+
const existing = watchers.get(key);
|
|
179
|
+
if (existing !== void 0) {
|
|
180
|
+
// live watcher, or a failed attempt still inside its retry backoff
|
|
181
|
+
if (existing.watcher !== null || Date.now() - existing.failedAt < WATCH_RETRY_MS) return;
|
|
182
|
+
watchers.delete(key);
|
|
183
|
+
}
|
|
184
|
+
// only git workspaces need a badge; a missing .git skips the watcher but
|
|
185
|
+
// retries on the normal backoff (cheap existsSync) so a later `git init`
|
|
186
|
+
// in the workspace is picked up
|
|
187
|
+
if (!existsSync(join(root, ".git"))) {
|
|
188
|
+
watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
// Watch the whole worktree, .git included: worktree edits (the most
|
|
193
|
+
// common dirty signal) and metadata ops (commit / checkout / stage)
|
|
194
|
+
// all surface here. The 200ms debounce collapses save bursts; if the
|
|
195
|
+
// tree is too large for the inotify budget the error handler drops
|
|
196
|
+
// back to the 60s client poll.
|
|
197
|
+
const watcher = watch(root, { recursive: true }, () => {
|
|
198
|
+
const record = watchers.get(key);
|
|
199
|
+
if (record === void 0) return;
|
|
200
|
+
clearTimeout(record.timer);
|
|
201
|
+
record.timer = setTimeout(() => notifyChange(key), DEBOUNCE_MS);
|
|
202
|
+
});
|
|
203
|
+
watcher.on("error", () => unwatchWorkspace(key));
|
|
204
|
+
watchers.set(key, { watcher, timer: void 0 });
|
|
205
|
+
} catch {
|
|
206
|
+
// watch refused (permissions, watch budget) β remember and back off
|
|
207
|
+
watchers.set(key, { watcher: null, failedAt: Date.now(), timer: void 0 });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Reconcile the watcher set with the current workspace registry. */
|
|
212
|
+
function syncWatchers(ctx) {
|
|
213
|
+
const wanted = new Set();
|
|
214
|
+
for (const entity of ctx.workspaceRegistry.list()) {
|
|
215
|
+
const p = entity?.record?.path ?? entity?.path;
|
|
216
|
+
if (typeof p !== "string") continue;
|
|
217
|
+
wanted.add(p);
|
|
218
|
+
watchWorkspace(p, p);
|
|
219
|
+
}
|
|
220
|
+
for (const key of [...watchers.keys()]) if (!wanted.has(key)) unwatchWorkspace(key);
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
|
|
224
|
+
/** Host plugin body β register the status route and the SSE change feed. */
|
|
225
|
+
function apply(ctx) {
|
|
226
|
+
ctx.effect(() => ctx.webServer.register({
|
|
227
|
+
kind: "exact",
|
|
228
|
+
path: "/api/git-badge",
|
|
229
|
+
handler: async (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
const url = new URL(req.url, "http://localhost");
|
|
232
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
233
|
+
if (raw === "") throw new Error("missing path");
|
|
234
|
+
// Allowlist: the requested path must be (or resolve to) a registered
|
|
235
|
+
// workspace directory. Anything else is refused without inspection.
|
|
236
|
+
const registered = new Set();
|
|
237
|
+
for (const entity of ctx.workspaceRegistry.list()) {
|
|
238
|
+
const p = entity?.record?.path ?? entity?.path;
|
|
239
|
+
if (typeof p === "string") registered.add(p);
|
|
240
|
+
}
|
|
241
|
+
const resolved = await realpath(raw).catch(() => null);
|
|
242
|
+
if (resolved === null || !registered.has(raw) && !registered.has(resolved)) {
|
|
243
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
244
|
+
res.end(JSON.stringify({ git: false, error: "path is not a registered workspace" }));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const detail = url.searchParams.get("detail") === "1";
|
|
248
|
+
const body = JSON.stringify(await gitStatus(resolved, detail));
|
|
249
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
250
|
+
res.end(body);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
253
|
+
res.end(JSON.stringify({ git: false, error: String(error?.message ?? error) }));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}));
|
|
257
|
+
ctx.effect(() => ctx.webServer.register({
|
|
258
|
+
kind: "exact",
|
|
259
|
+
path: "/api/git-badge/events",
|
|
260
|
+
handler: async (req, res) => {
|
|
261
|
+
// keep the watcher set aligned with the live registry on every connect
|
|
262
|
+
syncWatchers(ctx);
|
|
263
|
+
res.writeHead(200, {
|
|
264
|
+
"content-type": "text/event-stream",
|
|
265
|
+
"cache-control": "no-cache",
|
|
266
|
+
connection: "keep-alive"
|
|
267
|
+
});
|
|
268
|
+
// flush immediately: staged headers only hit the wire on first write,
|
|
269
|
+
// and the first real event may be minutes away
|
|
270
|
+
res.write(": connected\n\n");
|
|
271
|
+
const send = (key) => {
|
|
272
|
+
try {
|
|
273
|
+
res.write(`data: ${JSON.stringify({ path: key })}\n\n`);
|
|
274
|
+
} catch {
|
|
275
|
+
/* socket gone; the close handler cleans up */
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
changeListeners.add(send);
|
|
279
|
+
// comment-only heartbeat keeps proxies from idling the stream out
|
|
280
|
+
const heartbeat = setInterval(() => {
|
|
281
|
+
try {
|
|
282
|
+
res.write(": hb\n\n");
|
|
283
|
+
} catch {
|
|
284
|
+
/* ignore */
|
|
285
|
+
}
|
|
286
|
+
}, 25000);
|
|
287
|
+
req.on("close", () => {
|
|
288
|
+
changeListeners.delete(send);
|
|
289
|
+
clearInterval(heartbeat);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export { apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-git-badge",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Git status badges for DeepSeek Harness: sidebar workspace rows (via the sidebar.workspaces.row seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"dsh": {
|
|
13
|
+
"bundle": {
|
|
14
|
+
"patch": "./cordis.patch.yml"
|
|
15
|
+
},
|
|
16
|
+
"client": {
|
|
17
|
+
"inject": [
|
|
18
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
19
|
+
],
|
|
20
|
+
"platform": "web"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"dsh",
|
|
25
|
+
"dsh-plugin",
|
|
26
|
+
"dsh-bundle",
|
|
27
|
+
"git",
|
|
28
|
+
"status",
|
|
29
|
+
"badge",
|
|
30
|
+
"sidebar"
|
|
31
|
+
],
|
|
32
|
+
"author": "Kevin McIsaac",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Kevin-McIsaac/dsh-workspace-git-badge.git",
|
|
37
|
+
"directory": "plugin"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"lib/index.js",
|
|
44
|
+
"lib/client.js",
|
|
45
|
+
"cordis.patch.yml",
|
|
46
|
+
"package.json",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
],
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": ">=4",
|
|
52
|
+
"@deepseek-ai/dsh-client-runtime": ">=0.1",
|
|
53
|
+
"react": ">=18"
|
|
54
|
+
}
|
|
55
|
+
}
|