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/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 one `log -3` plus a stash count, and `pr=1` adds the
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
- const now = Date.now();
235
- const cached = defaultBranchState.get(toplevel);
236
- if (cached === void 0 || now - cached.at >= config.defaultBranchTtlMs) {
237
- // out of band: this answer is served from what we already know
238
- void (async () => {
239
- const out = await runGit(toplevel, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
240
- const name = out.stdout === null ? "" : out.stdout.trim().replace(/^origin\//u, "");
241
- const previous = defaultBranchState.get(toplevel)?.value;
242
- defaultBranchState.set(toplevel, { at: Date.now(), value: name === "" ? void 0 : name });
243
- if (previous !== defaultBranchState.get(toplevel).value) notify?.();
244
- })();
245
- }
246
- return cached?.value;
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
- const now = Date.now();
261
- const state = fetchState.get(toplevel);
262
- if (state !== void 0) {
263
- if (state.inFlight !== null) return state.inFlight;
264
- if (now - state.lastAttemptAt < config.fetchTtlMs) return false;
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
- const ok = out.stdout !== null;
279
- fetchState.set(toplevel, { lastAttemptAt: Date.now(), inFlight: null });
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
- const state = prState.get(key);
447
- if (state !== void 0) {
448
- // Fresh, or a refresh is already running: serve what we have either way.
449
- if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
450
- }
451
- const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
452
- const inFlight = (async () => {
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
- const pending = worktreeInFlight.get(dir);
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, updated by the settings scope. It is
732
- * authoritative only once the namespace actually registered; until then (and on
733
- * a host without the settings stack, or in a unit test that never calls apply)
734
- * the legacy file is read per call, which is the pre-settings behaviour.
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
- let settingsOrderActive = false;
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 settingsOrderActive ? currentOrder : legacyOrderFile();
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. The only dependent call (the
1045
- // left-right count needs a probed base ref) runs after the batch and
1046
- // tolerates its own failure.
1047
- const [remoteUrl, probeMain, probeMaster, logOut2, stashOut] = await Promise.all([
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
- const plainOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", "HEAD"]);
1101
- if (plainOut.stdout !== null && plainOut.stdout.trim() !== "") {
1102
- info.branchCommits = plainOut.stdout.trim().split("\n").map((line) => {
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
- * The optional `statusCacheMs` window extends the collapse to serial bursts. It
1159
- * defaults to 0 on purpose: a cache that outlives the call would let a caller
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
- if (config.statusCacheMs > 0) {
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
- if (typeof config.sessionCheckoutsFile === "string") {
1550
- try {
1551
- const raw = JSON.parse(readFileSync(config.sessionCheckoutsFile, "utf8"));
1552
- return raw !== null && typeof raw === "object" && typeof raw.sessions === "object" && raw.sessions !== null
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
- currentOrder = legacyOrderFile();
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
- settingsOrderActive = false;
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.0",
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",