dsh-git-badge 0.17.0 → 0.17.1
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 -7
- package/README.md +1 -1
- package/lib/client.js +91 -67
- package/lib/index.js +98 -120
- package/package.json +2 -4
- package/seam/apply.js +34 -154
- package/seam/checkout.js +3 -5
- package/seam/patch.js +142 -0
- package/seam/postinstall.js +38 -50
- package/seam/store.js +8 -4
package/lib/index.js
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* on the first refresh after each TTL window. `GIT_TERMINAL_PROMPT=0`, the
|
|
45
45
|
* shared timeout and the TTL bound keep an offline or slow remote harmless;
|
|
46
46
|
* on failure the stale-ref answer simply stands.
|
|
47
|
-
* - `detail=1` adds
|
|
47
|
+
* - `detail=1` adds a `log -10` plus a stash count, and `pr=1` adds the
|
|
48
48
|
* branch's GitHub PR/CI state read through the user's own `gh` CLI. Both
|
|
49
49
|
* serve the input chip only — the hover card and the PR token — so a
|
|
50
50
|
* sidebar row refresh pays for neither. The PR read is TTL-bounded and
|
|
@@ -99,15 +99,6 @@ const config = {
|
|
|
99
99
|
defaultBranchTtlMs: 60000,
|
|
100
100
|
/** fs-event burst collapse window */
|
|
101
101
|
debounceMs: 200,
|
|
102
|
-
/**
|
|
103
|
-
* Coalescing window for identical status reads. 0 (the default) collapses only
|
|
104
|
-
* CONCURRENT identical reads — which is exactly what a burst of sidebar
|
|
105
|
-
* session rows mounting together produces — and leaves sequential reads
|
|
106
|
-
* honest, so a caller that mutates a repository and asks again always sees the
|
|
107
|
-
* new state. Raise it to also absorb serial bursts, at the cost of serving a
|
|
108
|
-
* change up to that many milliseconds late.
|
|
109
|
-
*/
|
|
110
|
-
statusCacheMs: 0,
|
|
111
102
|
/** backoff before re-attempting a failed watcher (or a missing .git) */
|
|
112
103
|
watchRetryMs: 60000,
|
|
113
104
|
/**
|
|
@@ -209,6 +200,41 @@ function runGit(dir, args, opts) {
|
|
|
209
200
|
/** Marker response for a git invocation that failed or timed out (transient). */
|
|
210
201
|
const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
|
|
211
202
|
|
|
203
|
+
/**
|
|
204
|
+
* One in-flight run per key, with optional TTL rate-limiting — the shape every
|
|
205
|
+
* cache in this half needs, written once. A caller that arrives while a run is
|
|
206
|
+
* in flight shares its promise instead of stampeding, and one whose record is
|
|
207
|
+
* fresher than `ttlMs` is served from the record instead of starting a new
|
|
208
|
+
* run. The store maps key -> { lastAttemptAt, inFlight, value }: `value` is
|
|
209
|
+
* the last settled answer, kept only when `keepValue` so a lapsed caller can
|
|
210
|
+
* be served the PREVIOUS answer while a refresh runs; `run(record)` receives
|
|
211
|
+
* the record (its `value` is that previous answer) and resolves to the next
|
|
212
|
+
* one. TTL-less stores drop the record when the run settles; a failed run is
|
|
213
|
+
* not remembered except as a TTL'd store's attempt time, which rate-limits
|
|
214
|
+
* the retry. `serve` decides what a busy-or-fresh call returns (default: the
|
|
215
|
+
* in-flight promise, or the kept value).
|
|
216
|
+
*/
|
|
217
|
+
function singleFlight(store, key, { ttlMs = 0, keepValue = false, serve }, run) {
|
|
218
|
+
const previous = store.get(key);
|
|
219
|
+
if (previous !== void 0 && (previous.inFlight !== null || Date.now() - previous.lastAttemptAt < ttlMs)) {
|
|
220
|
+
return serve !== void 0 ? serve(previous) : keepValue ? previous.value : previous.inFlight;
|
|
221
|
+
}
|
|
222
|
+
const record = { lastAttemptAt: Date.now(), inFlight: null, value: keepValue ? previous?.value : void 0 };
|
|
223
|
+
record.inFlight = (async () => {
|
|
224
|
+
try {
|
|
225
|
+
const value = await run(record);
|
|
226
|
+
if (keepValue) record.value = value;
|
|
227
|
+
return value;
|
|
228
|
+
} finally {
|
|
229
|
+
record.lastAttemptAt = Date.now();
|
|
230
|
+
record.inFlight = null;
|
|
231
|
+
if (ttlMs === 0) store.delete(key);
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
store.set(key, record);
|
|
235
|
+
return keepValue ? record.value : record.inFlight;
|
|
236
|
+
}
|
|
237
|
+
|
|
212
238
|
//#region TTL-bounded background fetch
|
|
213
239
|
/**
|
|
214
240
|
* ahead/behind come from the local remote-tracking ref, which only moves on
|
|
@@ -231,19 +257,17 @@ const defaultBranchState = new Map();
|
|
|
231
257
|
|
|
232
258
|
/** The cached default branch, kicking a refresh when stale. Never throws. */
|
|
233
259
|
function defaultBranchFor(toplevel, notify) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
return cached?.value;
|
|
260
|
+
return singleFlight(defaultBranchState, toplevel, {
|
|
261
|
+
ttlMs: config.defaultBranchTtlMs,
|
|
262
|
+
keepValue: true,
|
|
263
|
+
serve: (state) => state.value
|
|
264
|
+
}, async (record) => {
|
|
265
|
+
const out = await runGit(toplevel, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
266
|
+
const name = out.stdout === null ? "" : out.stdout.trim().replace(/^origin\//u, "");
|
|
267
|
+
const value = name === "" ? void 0 : name;
|
|
268
|
+
if (record.value !== value) notify?.();
|
|
269
|
+
return value;
|
|
270
|
+
});
|
|
247
271
|
}
|
|
248
272
|
|
|
249
273
|
/** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
|
|
@@ -257,13 +281,12 @@ const fetchState = new Map();
|
|
|
257
281
|
* means concurrent requests share one fetch instead of stampeding.
|
|
258
282
|
*/
|
|
259
283
|
async function maybeFetch(toplevel) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
const inFlight = (async () => {
|
|
284
|
+
return singleFlight(fetchState, toplevel, {
|
|
285
|
+
ttlMs: config.fetchTtlMs,
|
|
286
|
+
// a lapsed-but-idle caller is served "nothing fetched"; only an in-flight
|
|
287
|
+
// fetch is worth sharing
|
|
288
|
+
serve: (state) => (state.inFlight ?? false)
|
|
289
|
+
}, async () => {
|
|
267
290
|
// --no-tags --prune: refs-only refresh, cheapest correct form.
|
|
268
291
|
// No remote name: `git fetch` uses the branch's configured upstream,
|
|
269
292
|
// and a bare `fetch --all` would probe every remote for every badge.
|
|
@@ -275,13 +298,8 @@ async function maybeFetch(toplevel) {
|
|
|
275
298
|
// route latency, paid at most once per fetchTtlMs per workspace.
|
|
276
299
|
timeout: config.fetchTimeoutMs
|
|
277
300
|
});
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return ok;
|
|
281
|
-
})();
|
|
282
|
-
if (state === void 0) fetchState.set(toplevel, { lastAttemptAt: now, inFlight });
|
|
283
|
-
else state.inFlight = inFlight;
|
|
284
|
-
return inFlight;
|
|
301
|
+
return out.stdout !== null;
|
|
302
|
+
});
|
|
285
303
|
}
|
|
286
304
|
//#endregion
|
|
287
305
|
|
|
@@ -441,26 +459,19 @@ function prListKey(byBranch) {
|
|
|
441
459
|
*/
|
|
442
460
|
function prStatusFor(toplevel, branch, notify) {
|
|
443
461
|
if (config.prStatus === "off") return void 0;
|
|
444
|
-
const now = Date.now();
|
|
445
462
|
const key = toplevel + "\u0000" + branch;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
463
|
+
return singleFlight(prState, key, {
|
|
464
|
+
ttlMs: config.prTtlMs,
|
|
465
|
+
keepValue: true,
|
|
466
|
+
// the previous answer is served while a refresh runs, so a forge round
|
|
467
|
+
// trip is never route latency
|
|
468
|
+
serve: (state) => state.value
|
|
469
|
+
}, async (record) => {
|
|
453
470
|
const value = await readPrStatus(toplevel, branch).catch(() => void 0);
|
|
454
471
|
const changed = JSON.stringify(record.value ?? null) !== JSON.stringify(value ?? null);
|
|
455
|
-
record.lastAttemptAt = Date.now();
|
|
456
|
-
record.inFlight = null;
|
|
457
|
-
record.value = value;
|
|
458
472
|
if (changed) notify(toplevel);
|
|
459
473
|
return value;
|
|
460
|
-
})
|
|
461
|
-
record.inFlight = inFlight;
|
|
462
|
-
prState.set(key, record);
|
|
463
|
-
return record.value;
|
|
474
|
+
});
|
|
464
475
|
}
|
|
465
476
|
//#endregion
|
|
466
477
|
|
|
@@ -622,13 +633,7 @@ const worktreeInFlight = new Map();
|
|
|
622
633
|
* local git calls, and a worktree created a moment ago must be visible at once.
|
|
623
634
|
*/
|
|
624
635
|
function worktreesFor(dir) {
|
|
625
|
-
|
|
626
|
-
if (pending !== void 0) return pending;
|
|
627
|
-
const run = readWorktrees(dir).finally(() => {
|
|
628
|
-
worktreeInFlight.delete(dir);
|
|
629
|
-
});
|
|
630
|
-
worktreeInFlight.set(dir, run);
|
|
631
|
-
return run;
|
|
636
|
+
return singleFlight(worktreeInFlight, dir, {}, () => readWorktrees(dir));
|
|
632
637
|
}
|
|
633
638
|
|
|
634
639
|
/**
|
|
@@ -728,17 +733,24 @@ function legacyOrderFile() {
|
|
|
728
733
|
}
|
|
729
734
|
|
|
730
735
|
/**
|
|
731
|
-
* The live order — one in-memory value
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
736
|
+
* The live order — one in-memory value. apply() seeds it from the pre-settings
|
|
737
|
+
* JSON file once at boot; the settings scope then owns it, so a change applies
|
|
738
|
+
* to the next status read with no file poll and no restart. A host without the
|
|
739
|
+
* settings stack (and a unit test that never calls apply) simply keeps the
|
|
740
|
+
* default. Reading the file once per boot instead of per status read keeps a
|
|
741
|
+
* sync disk read off the hot path; the file's contract is a boot-time seed,
|
|
742
|
+
* which is all it ever was once the namespace exists.
|
|
735
743
|
*/
|
|
736
744
|
let currentOrder = DEFAULT_NEXT_ORDER;
|
|
737
|
-
|
|
745
|
+
|
|
746
|
+
/** Seed the live order from the pre-settings JSON file — apply()'s boot step. */
|
|
747
|
+
function seedOrder() {
|
|
748
|
+
currentOrder = legacyOrderFile();
|
|
749
|
+
}
|
|
738
750
|
|
|
739
751
|
/** The ranking order every status read uses. */
|
|
740
752
|
function nextOrder() {
|
|
741
|
-
return
|
|
753
|
+
return currentOrder;
|
|
742
754
|
}
|
|
743
755
|
|
|
744
756
|
/**
|
|
@@ -889,15 +901,6 @@ function nextActions(info) {
|
|
|
889
901
|
return { actions, next: primary };
|
|
890
902
|
}
|
|
891
903
|
|
|
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;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
904
|
/**
|
|
902
905
|
* Git status for dir; { git: false } when dir is not a repository,
|
|
903
906
|
* GIT_DEGRADED on transient git failure.
|
|
@@ -1041,10 +1044,11 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1041
1044
|
// `remote get-url origin` with ssh syntax converted; absent when there is
|
|
1042
1045
|
// no origin or the URL is not recognisably a hosted repo.
|
|
1043
1046
|
// These reads are independent — one batch, so hover latency is the
|
|
1044
|
-
// longest call rather than their sum.
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
|
|
1047
|
+
// longest call rather than their sum. `logHead` is the plain HEAD log
|
|
1048
|
+
// the no-base-branch fallback below serves as the branch's commit list;
|
|
1049
|
+
// the only dependent calls (the left-right count and the signed log both
|
|
1050
|
+
// need a probed base ref) run after the batch and tolerate failure.
|
|
1051
|
+
const [remoteUrl, probeMain, probeMaster, logHead, stashOut] = await Promise.all([
|
|
1048
1052
|
runGit(toplevel, ["remote", "get-url", "origin"]),
|
|
1049
1053
|
runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/main"]),
|
|
1050
1054
|
runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/master"]),
|
|
@@ -1097,9 +1101,9 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1097
1101
|
if (Number.isFinite(total)) info.branchCommitsTotal = total;
|
|
1098
1102
|
}
|
|
1099
1103
|
} else {
|
|
1100
|
-
|
|
1101
|
-
if (
|
|
1102
|
-
info.branchCommits =
|
|
1104
|
+
// already fetched in the batch above — no second invocation
|
|
1105
|
+
if (logHead.stdout !== null && logHead.stdout.trim() !== "") {
|
|
1106
|
+
info.branchCommits = logHead.stdout.trim().split("\n").map((line) => {
|
|
1103
1107
|
const [hash, subject, when] = line.split("\t");
|
|
1104
1108
|
return { hash, subject: subject ?? "", when: when ?? "" };
|
|
1105
1109
|
}).filter((c) => c.hash !== void 0);
|
|
@@ -1146,38 +1150,19 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
|
|
|
1146
1150
|
|
|
1147
1151
|
/** In-flight status reads, so N callers asking at once share one `git status`. */
|
|
1148
1152
|
const statusInFlight = new Map();
|
|
1149
|
-
/** Completed reads inside `config.statusCacheMs`; unused at the 0 default. */
|
|
1150
|
-
const statusCache = new Map();
|
|
1151
1153
|
|
|
1152
1154
|
/**
|
|
1153
1155
|
* Status for dir, with identical CONCURRENT reads collapsed into one. That is the
|
|
1154
1156
|
* shape a burst of sidebar session rows produces when they mount together: N rows
|
|
1155
1157
|
* of one workspace resolve to the same directory, and without this they would run
|
|
1156
|
-
* N `git status` walks of the same tree at the same instant.
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
* that mutates a repository and asks again read a stale answer, and correctness
|
|
1161
|
-
* of a *status* badge outranks saving a walk.
|
|
1158
|
+
* N `git status` walks of the same tree at the same instant. Deliberately NO
|
|
1159
|
+
* completed-read cache: one that outlived the call would let a caller that
|
|
1160
|
+
* mutates a repository and asks again read a stale answer, and correctness of a
|
|
1161
|
+
* *status* badge outranks saving a walk.
|
|
1162
1162
|
*/
|
|
1163
1163
|
function gitStatus(dir, wantDetail, wantPr) {
|
|
1164
1164
|
const key = dir + "\u0000" + (wantDetail === true) + "\u0000" + (wantPr === true);
|
|
1165
|
-
|
|
1166
|
-
const hit = statusCache.get(key);
|
|
1167
|
-
if (hit !== void 0 && Date.now() - hit.at < config.statusCacheMs) return Promise.resolve(hit.value);
|
|
1168
|
-
}
|
|
1169
|
-
const pending = statusInFlight.get(key);
|
|
1170
|
-
if (pending !== void 0) return pending;
|
|
1171
|
-
const run = gitStatusUncached(dir, wantDetail, wantPr)
|
|
1172
|
-
.then((value) => {
|
|
1173
|
-
if (config.statusCacheMs > 0) statusCache.set(key, { at: Date.now(), value });
|
|
1174
|
-
return value;
|
|
1175
|
-
})
|
|
1176
|
-
.finally(() => {
|
|
1177
|
-
statusInFlight.delete(key);
|
|
1178
|
-
});
|
|
1179
|
-
statusInFlight.set(key, run);
|
|
1180
|
-
return run;
|
|
1165
|
+
return singleFlight(statusInFlight, key, {}, () => gitStatusUncached(dir, wantDetail, wantPr));
|
|
1181
1166
|
}
|
|
1182
1167
|
|
|
1183
1168
|
//#region git-state watcher
|
|
@@ -1546,17 +1531,10 @@ async function resolveWorkspace(ctx, params) {
|
|
|
1546
1531
|
* directory that is not a worktree of this repository.
|
|
1547
1532
|
*/
|
|
1548
1533
|
function sessionCheckouts() {
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
? raw.sessions
|
|
1554
|
-
: {};
|
|
1555
|
-
} catch {
|
|
1556
|
-
return {};
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1559
|
-
return readSessionCheckouts();
|
|
1534
|
+
// The store owns the file's shape; the test seam only redirects the path.
|
|
1535
|
+
return config.sessionCheckoutsFile === null
|
|
1536
|
+
? readSessionCheckouts()
|
|
1537
|
+
: readSessionCheckouts(config.sessionCheckoutsFile);
|
|
1560
1538
|
}
|
|
1561
1539
|
|
|
1562
1540
|
async function registeredCheckout(target, sessionId) {
|
|
@@ -1615,7 +1593,7 @@ function apply(ctx) {
|
|
|
1615
1593
|
// exists, seeds the namespace as its composition BASE so an order configured
|
|
1616
1594
|
// before this existed survives the upgrade; it is also the fallback when the
|
|
1617
1595
|
// settings service is absent (a host composed without dsh-settings).
|
|
1618
|
-
|
|
1596
|
+
seedOrder();
|
|
1619
1597
|
if (typeof ctx.inject === "function") {
|
|
1620
1598
|
try {
|
|
1621
1599
|
ctx.inject(["settings"], async (settingsCtx) => {
|
|
@@ -1630,12 +1608,12 @@ function apply(ctx) {
|
|
|
1630
1608
|
base: { order: legacyOrderFile() }
|
|
1631
1609
|
});
|
|
1632
1610
|
currentOrder = normalizeOrder(scope.get()?.order);
|
|
1633
|
-
settingsOrderActive = true;
|
|
1634
1611
|
settingsCtx.effect(() => scope.watch((next) => {
|
|
1635
1612
|
currentOrder = normalizeOrder(next?.order);
|
|
1636
1613
|
}), "dsh-git-badge: order settings");
|
|
1637
1614
|
settingsCtx.effect(() => () => {
|
|
1638
|
-
|
|
1615
|
+
// the namespace is gone: fall back to the boot-time seed
|
|
1616
|
+
seedOrder();
|
|
1639
1617
|
}, "dsh-git-badge: order settings teardown");
|
|
1640
1618
|
});
|
|
1641
1619
|
} catch (error) {
|
|
@@ -1778,8 +1756,8 @@ export {
|
|
|
1778
1756
|
config,
|
|
1779
1757
|
OPERATION_MARKERS,
|
|
1780
1758
|
operationMarker,
|
|
1781
|
-
nextStep,
|
|
1782
1759
|
nextActions,
|
|
1760
|
+
seedOrder,
|
|
1783
1761
|
outerGitDir,
|
|
1784
1762
|
parseStatusV2,
|
|
1785
1763
|
parseWorktreeList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
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",
|
|
@@ -22,9 +22,6 @@
|
|
|
22
22
|
"patch": "./cordis.patch.yml"
|
|
23
23
|
},
|
|
24
24
|
"client": {
|
|
25
|
-
"inject": [
|
|
26
|
-
"@deepseek-ai/dsh-client-runtime"
|
|
27
|
-
],
|
|
28
25
|
"platform": "web"
|
|
29
26
|
}
|
|
30
27
|
},
|
|
@@ -58,6 +55,7 @@
|
|
|
58
55
|
"seam/anchors.js",
|
|
59
56
|
"seam/apply.js",
|
|
60
57
|
"seam/checkout.js",
|
|
58
|
+
"seam/patch.js",
|
|
61
59
|
"seam/postinstall.js",
|
|
62
60
|
"seam/skill.js",
|
|
63
61
|
"seam/store.js",
|