dsh-git-badge 0.16.1 → 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
@@ -95,17 +95,10 @@ const config = {
95
95
  fetchTimeoutMs: 8000,
96
96
  /** minimum interval between background fetches per toplevel */
97
97
  fetchTtlMs: 60000,
98
+ /** how long a resolved default-branch name is trusted (per toplevel) */
99
+ defaultBranchTtlMs: 60000,
98
100
  /** fs-event burst collapse window */
99
101
  debounceMs: 200,
100
- /**
101
- * Coalescing window for identical status reads. 0 (the default) collapses only
102
- * CONCURRENT identical reads — which is exactly what a burst of sidebar
103
- * session rows mounting together produces — and leaves sequential reads
104
- * honest, so a caller that mutates a repository and asks again always sees the
105
- * new state. Raise it to also absorb serial bursts, at the cost of serving a
106
- * change up to that many milliseconds late.
107
- */
108
- statusCacheMs: 0,
109
102
  /** backoff before re-attempting a failed watcher (or a missing .git) */
110
103
  watchRetryMs: 60000,
111
104
  /**
@@ -130,8 +123,16 @@ const config = {
130
123
  * process — see the PR region.
131
124
  */
132
125
  prStatus: "auto",
133
- /** minimum interval between forge refreshes per repository */
134
- prTtlMs: 90000,
126
+ /**
127
+ * Minimum interval between forge refreshes per repository. It must stay BELOW
128
+ * the client's idle poll (FALLBACK_POLL_MS, 60s in lib/client.js): the refresh
129
+ * is request-driven, so a TTL longer than that poll silently suppresses every
130
+ * OTHER request — and a pull request that appears on the forge with no local
131
+ * git event to announce it then waits out TTL + poll before it shows. At 90s
132
+ * that worst case was ~2.5 minutes; 20s bounds it at roughly one poll, for up
133
+ * to one `gh pr view` per open chip per minute.
134
+ */
135
+ prTtlMs: 20000,
135
136
  /**
136
137
  * Forge call budget. Unlike git this is a network round trip to GitHub, so
137
138
  * it gets a more generous budget than the status sample — but it is bounded
@@ -199,6 +200,41 @@ function runGit(dir, args, opts) {
199
200
  /** Marker response for a git invocation that failed or timed out (transient). */
200
201
  const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
201
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
+
202
238
  //#region TTL-bounded background fetch
203
239
  /**
204
240
  * ahead/behind come from the local remote-tracking ref, which only moves on
@@ -210,6 +246,30 @@ const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)"
210
246
  * auto-fetch), with a TTL rather than a timer: fetch only happens when
211
247
  * someone is actually looking at the badge.
212
248
  */
249
+ /**
250
+ * toplevel -> { at, value } for the repository's default branch name ("main"),
251
+ * resolved from `refs/remotes/origin/HEAD`. Read on the HOT path and refreshed
252
+ * out of band, exactly like the fetch: a status call must never pay a process
253
+ * for it, so the first request after a cache miss simply answers without the
254
+ * flag and the next one carries it.
255
+ */
256
+ const defaultBranchState = new Map();
257
+
258
+ /** The cached default branch, kicking a refresh when stale. Never throws. */
259
+ function defaultBranchFor(toplevel, notify) {
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
+ });
271
+ }
272
+
213
273
  /** lastFetchAt per toplevel; in-flight promise per toplevel collapses races. */
214
274
  const fetchState = new Map();
215
275
 
@@ -221,13 +281,12 @@ const fetchState = new Map();
221
281
  * means concurrent requests share one fetch instead of stampeding.
222
282
  */
223
283
  async function maybeFetch(toplevel) {
224
- const now = Date.now();
225
- const state = fetchState.get(toplevel);
226
- if (state !== void 0) {
227
- if (state.inFlight !== null) return state.inFlight;
228
- if (now - state.lastAttemptAt < config.fetchTtlMs) return false;
229
- }
230
- 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 () => {
231
290
  // --no-tags --prune: refs-only refresh, cheapest correct form.
232
291
  // No remote name: `git fetch` uses the branch's configured upstream,
233
292
  // and a bare `fetch --all` would probe every remote for every badge.
@@ -239,13 +298,8 @@ async function maybeFetch(toplevel) {
239
298
  // route latency, paid at most once per fetchTtlMs per workspace.
240
299
  timeout: config.fetchTimeoutMs
241
300
  });
242
- const ok = out.stdout !== null;
243
- fetchState.set(toplevel, { lastAttemptAt: Date.now(), inFlight: null });
244
- return ok;
245
- })();
246
- if (state === void 0) fetchState.set(toplevel, { lastAttemptAt: now, inFlight });
247
- else state.inFlight = inFlight;
248
- return inFlight;
301
+ return out.stdout !== null;
302
+ });
249
303
  }
250
304
  //#endregion
251
305
 
@@ -405,26 +459,19 @@ function prListKey(byBranch) {
405
459
  */
406
460
  function prStatusFor(toplevel, branch, notify) {
407
461
  if (config.prStatus === "off") return void 0;
408
- const now = Date.now();
409
462
  const key = toplevel + "\u0000" + branch;
410
- const state = prState.get(key);
411
- if (state !== void 0) {
412
- // Fresh, or a refresh is already running: serve what we have either way.
413
- if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
414
- }
415
- const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
416
- 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) => {
417
470
  const value = await readPrStatus(toplevel, branch).catch(() => void 0);
418
471
  const changed = JSON.stringify(record.value ?? null) !== JSON.stringify(value ?? null);
419
- record.lastAttemptAt = Date.now();
420
- record.inFlight = null;
421
- record.value = value;
422
472
  if (changed) notify(toplevel);
423
473
  return value;
424
- })();
425
- record.inFlight = inFlight;
426
- prState.set(key, record);
427
- return record.value;
474
+ });
428
475
  }
429
476
  //#endregion
430
477
 
@@ -586,13 +633,7 @@ const worktreeInFlight = new Map();
586
633
  * local git calls, and a worktree created a moment ago must be visible at once.
587
634
  */
588
635
  function worktreesFor(dir) {
589
- const pending = worktreeInFlight.get(dir);
590
- if (pending !== void 0) return pending;
591
- const run = readWorktrees(dir).finally(() => {
592
- worktreeInFlight.delete(dir);
593
- });
594
- worktreeInFlight.set(dir, run);
595
- return run;
636
+ return singleFlight(worktreeInFlight, dir, {}, () => readWorktrees(dir));
596
637
  }
597
638
 
598
639
  /**
@@ -692,17 +733,24 @@ function legacyOrderFile() {
692
733
  }
693
734
 
694
735
  /**
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.
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.
699
743
  */
700
744
  let currentOrder = DEFAULT_NEXT_ORDER;
701
- 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
+ }
702
750
 
703
751
  /** The ranking order every status read uses. */
704
752
  function nextOrder() {
705
- return settingsOrderActive ? currentOrder : legacyOrderFile();
753
+ return currentOrder;
706
754
  }
707
755
 
708
756
  /**
@@ -730,7 +778,7 @@ function nextOrder() {
730
778
  *
731
779
  * @returns {{ args: string, command: string|undefined, why: string, what: string } | null}
732
780
  */
733
- function nextStep(info) {
781
+ function nextStepRules(info) {
734
782
  if (info === void 0 || info === null || info.git !== true) return null;
735
783
  const unmerged = info.unmergedFiles || 0;
736
784
  const plural = (n) => (n === 1 ? "" : "s");
@@ -786,15 +834,71 @@ function nextStep(info) {
786
834
  if (pr !== void 0 && pr.number !== void 0 && pr.state === "failing") {
787
835
  add("checks", `checks ${pr.number}`, `checks failing on #${pr.number}`, "watch the CI checks on pull request " + pr.number, `gh pr checks ${pr.number} --watch`);
788
836
  }
789
- if (rules.length === 0) return null;
837
+
838
+ // STANDING options: never ranked, never the primary suggestion — they are
839
+ // things you may do, not things the checkout is asking for. `pr view` exists
840
+ // whenever a pull request does; `pr` (create) only for a pushed branch that
841
+ // is not the default; `clean` last and flagged, because a destructive verb
842
+ // whose intent git status cannot reveal must never be suggested.
843
+ const standing = [];
844
+ if (pr !== void 0 && pr.number !== void 0) {
845
+ standing.push({
846
+ args: `pr view ${pr.number}`,
847
+ why: `open pull request #${pr.number}`,
848
+ what: `show pull request ${pr.number} on GitHub`
849
+ });
850
+ } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && dirty === 0 && info.defaultBranch !== true) {
851
+ standing.push({ args: "pr", why: "branch is pushed and has no pull request", what: "open a pull request for this branch" });
852
+ }
853
+ if (untracked > 0) {
854
+ standing.push({
855
+ args: "clean",
856
+ why: `${untracked} untracked file${plural(untracked)}`,
857
+ what: "remove untracked files — lists them and asks first",
858
+ danger: true
859
+ });
860
+ }
790
861
 
791
862
  const order = nextOrder();
792
863
  const rank = (cat) => {
793
864
  const at = order.indexOf(cat);
794
865
  return at === -1 ? order.length : at;
795
866
  };
796
- const [top] = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat));
797
- return { args: top.args, command: top.command, why: top.why, what: top.what };
867
+ const ranked = [...rules].sort((a, b) => rank(a.cat) - rank(b.cat)).slice(0, 5);
868
+ return { ranked, standing };
869
+ }
870
+
871
+ /**
872
+ * Every action the checkout justifies, in order — the ONE list both surfaces
873
+ * render: the card shows the primary entry, the (+) picker lists them all.
874
+ *
875
+ * The server owns the whole list because both halves previously carried their
876
+ * own copy of these conditions and drifted: the card promoted a client-only
877
+ * extra into its action row, and `/gh pr` was offered on the default branch.
878
+ * One authority, one list, no reconciliation.
879
+ *
880
+ * Exactly one entry can be `primary` (the top RANKED rule) or none at all —
881
+ * a clean, synced checkout has actions (look at the PR, clean up) but no
882
+ * suggestion, because "no suggestion" is also a suggestion.
883
+ *
884
+ * @returns {{ actions: Array<{args, why, what, command?, danger?, primary?}>, next: object|null }}
885
+ */
886
+ function nextActions(info) {
887
+ const built = nextStepRules(info);
888
+ if (built === null) return { actions: [], next: null };
889
+ const { ranked, standing } = built;
890
+ const actions = [
891
+ ...ranked.map((rule, index) => ({
892
+ args: rule.args,
893
+ why: rule.why,
894
+ what: rule.what,
895
+ ...(rule.command === void 0 ? {} : { command: rule.command }),
896
+ ...(index === 0 ? { primary: true } : {})
897
+ })),
898
+ ...standing
899
+ ];
900
+ const primary = actions.find((action) => action.primary === true) ?? null;
901
+ return { actions, next: primary };
798
902
  }
799
903
 
800
904
  /**
@@ -897,6 +1001,15 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
897
1001
  ahead: parsed.ahead,
898
1002
  behind: parsed.behind
899
1003
  };
1004
+ // Is this the repository's DEFAULT branch? main/master with no pull request
1005
+ // is that branch's normal state, and "open a pull request for this branch" is
1006
+ // nonsense there, so the surfaces suppress it. Cached and refreshed out of
1007
+ // band: the flag may be absent on the very first read, present on the next.
1008
+ const defaultBranch = defaultBranchFor(toplevel, notifyChange);
1009
+ if (defaultBranch !== void 0 && defaultBranch === parsed.branch) info.defaultBranch = true;
1010
+ // Resolved inside the detail block; the PR-token hover below reuses it, so it
1011
+ // is declared here rather than scoped to that block.
1012
+ let baseRef = null;
900
1013
  if (wantDetail) {
901
1014
  // The ✎n NAMES — the one part of the count the numbers cannot answer
902
1015
  // ("what are these?"). Hover-gated with the rest of detail=1, so no
@@ -931,10 +1044,11 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
931
1044
  // `remote get-url origin` with ssh syntax converted; absent when there is
932
1045
  // no origin or the URL is not recognisably a hosted repo.
933
1046
  // These reads are independent — one batch, so hover latency is the
934
- // longest call rather than their sum. The only dependent call (the
935
- // left-right count needs a probed base ref) runs after the batch and
936
- // tolerates its own failure.
937
- 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([
938
1052
  runGit(toplevel, ["remote", "get-url", "origin"]),
939
1053
  runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/main"]),
940
1054
  runGit(toplevel, ["rev-parse", "--verify", "--quiet", "origin/master"]),
@@ -944,7 +1058,7 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
944
1058
  if (remoteUrl.stdout !== null && remoteUrl.stdout.trim() !== "") {
945
1059
  info.repoUrl = remoteToWebUrl(remoteUrl.stdout);
946
1060
  }
947
- const baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
1061
+ baseRef = probeMain.stdout !== null && probeMain.stdout.trim() !== ""
948
1062
  ? "origin/main"
949
1063
  : probeMaster.stdout !== null && probeMaster.stdout.trim() !== "" ? "origin/master" : null;
950
1064
  if (baseRef !== null && info.repoUrl !== void 0) {
@@ -987,9 +1101,9 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
987
1101
  if (Number.isFinite(total)) info.branchCommitsTotal = total;
988
1102
  }
989
1103
  } else {
990
- const plainOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", "HEAD"]);
991
- if (plainOut.stdout !== null && plainOut.stdout.trim() !== "") {
992
- 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) => {
993
1107
  const [hash, subject, when] = line.split("\t");
994
1108
  return { hash, subject: subject ?? "", when: when ?? "" };
995
1109
  }).filter((c) => c.hash !== void 0);
@@ -1009,45 +1123,46 @@ async function gitStatusUncached(dir, wantDetail, wantPr) {
1009
1123
  const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
1010
1124
  if (pr !== void 0) info.pr = pr;
1011
1125
  }
1012
- const next = nextStep(info);
1126
+ if (baseRef !== null && info.pr !== void 0 && info.pr !== null && info.pr.number !== void 0
1127
+ && info.pr.open !== false && (info.mainAhead || 0) > 0) {
1128
+ // The PR's OWN commits (base..HEAD) — the token's hover answers "what is in
1129
+ // this pull request", so it lists exactly that: not the branch's recent
1130
+ // history, nothing else. Hover-gated with the rest of detail=1, and the
1131
+ // total is the ahead count already computed above (no second rev-list).
1132
+ const prOut = await runGit(toplevel, ["log", "-10", "--format=%h%x09%s%x09%cr", baseRef + "..HEAD"]);
1133
+ if (prOut.stdout !== null && prOut.stdout.trim() !== "") {
1134
+ info.prCommits = prOut.stdout.trim().split("\n").map((line) => {
1135
+ const [hash, subject, when] = line.split("\t");
1136
+ return { hash, subject: subject ?? "", when: when ?? "" };
1137
+ }).filter((c) => c.hash !== void 0);
1138
+ info.prCommitsTotal = info.mainAhead;
1139
+ }
1140
+ }
1141
+ // The ranked suggestion rides EVERY response (it is pure derivation, and a
1142
+ // sidebar row may one day use it instead of its own copy of these rules);
1143
+ // the full action list is sent only for `pr=1`, the surfaces that have a
1144
+ // picker, so a row's payload stays lean.
1145
+ const { actions, next } = nextActions(info);
1013
1146
  if (next !== null) info.next = next;
1147
+ if (wantPr && actions.length > 0) info.actions = actions;
1014
1148
  return info;
1015
1149
  }
1016
1150
 
1017
1151
  /** In-flight status reads, so N callers asking at once share one `git status`. */
1018
1152
  const statusInFlight = new Map();
1019
- /** Completed reads inside `config.statusCacheMs`; unused at the 0 default. */
1020
- const statusCache = new Map();
1021
1153
 
1022
1154
  /**
1023
1155
  * Status for dir, with identical CONCURRENT reads collapsed into one. That is the
1024
1156
  * shape a burst of sidebar session rows produces when they mount together: N rows
1025
1157
  * of one workspace resolve to the same directory, and without this they would run
1026
- * N `git status` walks of the same tree at the same instant.
1027
- *
1028
- * The optional `statusCacheMs` window extends the collapse to serial bursts. It
1029
- * defaults to 0 on purpose: a cache that outlives the call would let a caller
1030
- * that mutates a repository and asks again read a stale answer, and correctness
1031
- * 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.
1032
1162
  */
1033
1163
  function gitStatus(dir, wantDetail, wantPr) {
1034
1164
  const key = dir + "\u0000" + (wantDetail === true) + "\u0000" + (wantPr === true);
1035
- if (config.statusCacheMs > 0) {
1036
- const hit = statusCache.get(key);
1037
- if (hit !== void 0 && Date.now() - hit.at < config.statusCacheMs) return Promise.resolve(hit.value);
1038
- }
1039
- const pending = statusInFlight.get(key);
1040
- if (pending !== void 0) return pending;
1041
- const run = gitStatusUncached(dir, wantDetail, wantPr)
1042
- .then((value) => {
1043
- if (config.statusCacheMs > 0) statusCache.set(key, { at: Date.now(), value });
1044
- return value;
1045
- })
1046
- .finally(() => {
1047
- statusInFlight.delete(key);
1048
- });
1049
- statusInFlight.set(key, run);
1050
- return run;
1165
+ return singleFlight(statusInFlight, key, {}, () => gitStatusUncached(dir, wantDetail, wantPr));
1051
1166
  }
1052
1167
 
1053
1168
  //#region git-state watcher
@@ -1416,17 +1531,10 @@ async function resolveWorkspace(ctx, params) {
1416
1531
  * directory that is not a worktree of this repository.
1417
1532
  */
1418
1533
  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();
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);
1430
1538
  }
1431
1539
 
1432
1540
  async function registeredCheckout(target, sessionId) {
@@ -1485,7 +1593,7 @@ function apply(ctx) {
1485
1593
  // exists, seeds the namespace as its composition BASE so an order configured
1486
1594
  // before this existed survives the upgrade; it is also the fallback when the
1487
1595
  // settings service is absent (a host composed without dsh-settings).
1488
- currentOrder = legacyOrderFile();
1596
+ seedOrder();
1489
1597
  if (typeof ctx.inject === "function") {
1490
1598
  try {
1491
1599
  ctx.inject(["settings"], async (settingsCtx) => {
@@ -1500,12 +1608,12 @@ function apply(ctx) {
1500
1608
  base: { order: legacyOrderFile() }
1501
1609
  });
1502
1610
  currentOrder = normalizeOrder(scope.get()?.order);
1503
- settingsOrderActive = true;
1504
1611
  settingsCtx.effect(() => scope.watch((next) => {
1505
1612
  currentOrder = normalizeOrder(next?.order);
1506
1613
  }), "dsh-git-badge: order settings");
1507
1614
  settingsCtx.effect(() => () => {
1508
- settingsOrderActive = false;
1615
+ // the namespace is gone: fall back to the boot-time seed
1616
+ seedOrder();
1509
1617
  }, "dsh-git-badge: order settings teardown");
1510
1618
  });
1511
1619
  } catch (error) {
@@ -1648,7 +1756,8 @@ export {
1648
1756
  config,
1649
1757
  OPERATION_MARKERS,
1650
1758
  operationMarker,
1651
- nextStep,
1759
+ nextActions,
1760
+ seedOrder,
1652
1761
  outerGitDir,
1653
1762
  parseStatusV2,
1654
1763
  parseWorktreeList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.16.1",
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",