@promptctl/cc-candybar 1.0.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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,553 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { GitService, type GitInfo } from "../../segments/git";
4
+ import { ok, type Outcome } from "../../utils/outcome";
5
+ import { debug } from "../../utils/logger";
6
+ import { WatcherRegistry, type WatcherHandle } from "./watchers";
7
+
8
+ // Logger callable for cache invalidation / eviction / subscriber-error
9
+ // events. Daemon passes `dlog` so these land in daemon.log at the right
10
+ // level for ops/debugging; non-daemon consumers (var-system in tests,
11
+ // future library use) take the default debug-routed logger so var-system
12
+ // module load never touches daemon log files.
13
+ // [LAW:no-defensive-null-guards] Default is non-null; injection replaces
14
+ // the implementation, never adds a "no logger" mode.
15
+ export type GitProviderLogger = (
16
+ level: "info" | "warn" | "error",
17
+ message: string,
18
+ ) => void;
19
+
20
+ const defaultProviderLogger: GitProviderLogger = (_level, message) =>
21
+ debug(message);
22
+
23
+ // [LAW:one-source-of-truth] One provider for git data in the daemon. The
24
+ // daemon is the sole owner; segments pull via getInfo() (per-render snapshot),
25
+ // var-system pushes via subscribe() (MobX-driven reactivity). Both surfaces
26
+ // share one cache (keyed by *effective git directory*), one watcher set per
27
+ // effective dir, and one launch category ("git"). Pre-kz8.3 there were three
28
+ // parallel fleets — see the ticket epic for the inventory that this file
29
+ // replaces.
30
+ //
31
+ // [LAW:one-source-of-truth] Cache key derives from `resolveEffectiveGitDir`,
32
+ // which returns the exact directory the shell-runner will run git in (taking
33
+ // `projectDir` and worktree-ness into account). Keying off only
34
+ // `findGitRoot(workingDir)` would cache repo B's data under repo A's key
35
+ // whenever `projectDir` overrides workingDir's repo — the bug Copilot caught
36
+ // on first review of kz8.3.
37
+ //
38
+ // [LAW:single-enforcer] subscribe() is the only reactive entrypoint. When a
39
+ // watcher fires (or the sanity-check mtime walk detects a missed event), the
40
+ // provider re-fetches the core snapshot once per gitDir and notifies every
41
+ // subscriber for that gitDir. No parallel poller, no parallel WatchManager.
42
+
43
+ const DEFAULT_TTL_MS = 30_000;
44
+ const DEFAULT_MAX_ENTRIES = 64;
45
+ const SANITY_INTERVAL_MS = 5 * 60_000;
46
+
47
+ // Options applied to subscribe()'s internal getInfo() call. var-system's six
48
+ // GitField values (branch, sha, dirty, ahead, behind, stash) all project from
49
+ // these flags — keep them in lockstep with the projection in
50
+ // src/var-system/sources.ts. Anything not asked for here is undefined on the
51
+ // snapshot delivered to subscribers, which makes the projection's missing-
52
+ // field paths reachable and tested.
53
+ const SUBSCRIBE_OPTIONS = {
54
+ showSha: true,
55
+ showStashCount: true,
56
+ } as const;
57
+
58
+ interface MtimeSnapshot {
59
+ head: number;
60
+ index: number;
61
+ }
62
+
63
+ interface GitCacheEntry {
64
+ info: GitInfo;
65
+ computedAt: number;
66
+ mtime: MtimeSnapshot;
67
+ watcher: WatcherHandle;
68
+ // All entries for the same repoRoot share invalidation: one watcher fires →
69
+ // every option-set entry for that repo is dropped.
70
+ repoRoot: string;
71
+ }
72
+
73
+ type GitOptions = NonNullable<Parameters<GitService["getGitInfo"]>[1]>;
74
+
75
+ type SubscribeCallback = (info: GitInfo | null) => void;
76
+
77
+ interface RepoSubscribers {
78
+ // The effective gitDir is the cache + watcher identity; subscribers
79
+ // register with a working directory but we resolve to gitDir once at
80
+ // subscribe-time and store both so the refresh path doesn't have to
81
+ // re-resolve on every invalidation.
82
+ workingDir: string;
83
+ repoRoot: string;
84
+ callbacks: Set<SubscribeCallback>;
85
+ watcher: WatcherHandle;
86
+ }
87
+
88
+ function optionsKey(options: GitOptions): string {
89
+ const keys = Object.keys(options).sort() as Array<keyof GitOptions>;
90
+ const normalized: Record<string, unknown> = {};
91
+ for (const k of keys) normalized[k as string] = options[k];
92
+ return JSON.stringify(normalized);
93
+ }
94
+
95
+ function snapshotMtimes(gitDir: string): MtimeSnapshot {
96
+ // Missing files → 0; comparison still detects changes (0 → number).
97
+ const stat = (rel: string): number => {
98
+ try {
99
+ return fs.statSync(path.join(gitDir, rel)).mtimeMs;
100
+ } catch {
101
+ return 0;
102
+ }
103
+ };
104
+ return { head: stat("HEAD"), index: stat("index") };
105
+ }
106
+
107
+ function mtimeChanged(a: MtimeSnapshot, b: MtimeSnapshot): boolean {
108
+ return a.head !== b.head || a.index !== b.index;
109
+ }
110
+
111
+ // `gitDir` must be the *resolved* git directory (the value
112
+ // `GitService.resolveGitDir(repoRoot)` returns). For a normal repo that's
113
+ // `<repoRoot>/.git`; for a worktree it's the worktree-metadata dir nested
114
+ // under the main repo's `.git/worktrees/`. Either way, `HEAD` and `index`
115
+ // live directly inside `gitDir`, so the file watchers find real files.
116
+ //
117
+ // `refs/heads/` only exists in the main repo's gitDir, not under a worktree's
118
+ // metadata dir. WatcherRegistry would log a warn-level "watch failed" for
119
+ // every worktree if we passed the target unconditionally (the injected dlog
120
+ // surfaces what was previously silent). Check existence here and only include
121
+ // the dir target when it's real — the file watchers on HEAD/index still cover
122
+ // branch and index changes in the worktree case.
123
+ function watcherTargets(gitDir: string) {
124
+ const dirs: Array<{ path: string }> = [];
125
+ const refsHeads = path.join(gitDir, "refs/heads");
126
+ try {
127
+ if (fs.statSync(refsHeads).isDirectory()) {
128
+ dirs.push({ path: refsHeads });
129
+ }
130
+ } catch {
131
+ // ENOENT (worktree case) or other fs error — skip the dir target.
132
+ }
133
+ return {
134
+ files: [path.join(gitDir, "HEAD"), path.join(gitDir, "index")],
135
+ dirs,
136
+ };
137
+ }
138
+
139
+ export class GitDataProvider extends GitService {
140
+ private readonly entries = new Map<string, GitCacheEntry>();
141
+ private readonly subscribersByRepo = new Map<string, RepoSubscribers>();
142
+ // [LAW:single-enforcer] Coalesce concurrent cache misses on the same key.
143
+ // Renders build lines in parallel (Promise.all in src/powerline.ts), so two
144
+ // line-renders requesting the same git data can both observe the cache as
145
+ // cold and would otherwise spawn duplicate `git` work — exactly the failure
146
+ // mode the daemon is meant to eliminate. The first miss installs a promise
147
+ // here; subsequent concurrent callers await the same promise and resolve in
148
+ // lockstep.
149
+ private readonly fetchInFlight = new Map<string, Promise<Outcome<GitInfo>>>();
150
+ // [LAW:single-enforcer] Coalesce overlapping refreshes for the same repo.
151
+ // `refreshing` holds the repoRoots whose refresh loop is currently
152
+ // executing; `refreshAgain` is the trailing-edge flag: if a new
153
+ // invalidation arrives while a refresh is in flight, we set this flag and
154
+ // the loop will re-fetch once more before exiting. Without this,
155
+ // back-to-back invalidations (rapid commits, rebase) would fan back out
156
+ // into parallel `git status` calls — exactly the failure kz8.3 collapses.
157
+ private readonly refreshing = new Set<string>();
158
+ private readonly refreshAgain = new Set<string>();
159
+ private hits = 0;
160
+ private misses = 0;
161
+ private invalidations = 0;
162
+ private readonly inner: GitService;
163
+ private readonly ttlMs: number;
164
+ private readonly maxEntries: number;
165
+ private readonly watchers: WatcherRegistry;
166
+ private readonly ownsWatchers: boolean;
167
+ private readonly logger: GitProviderLogger;
168
+ private sanityTimer: NodeJS.Timeout | null = null;
169
+
170
+ constructor(
171
+ opts: {
172
+ ttlMs?: number;
173
+ maxEntries?: number;
174
+ inner?: GitService;
175
+ watchers?: WatcherRegistry;
176
+ sanityIntervalMs?: number;
177
+ // [LAW:single-enforcer] One injection point. Daemon passes `dlog`;
178
+ // non-daemon consumers leave the default (debug-routed, quiet
179
+ // unless CC_CANDYBAR_DEBUG is set).
180
+ logger?: GitProviderLogger;
181
+ } = {},
182
+ ) {
183
+ super();
184
+ this.inner = opts.inner ?? new GitService();
185
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
186
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
187
+ this.logger = opts.logger ?? defaultProviderLogger;
188
+ if (opts.watchers) {
189
+ this.watchers = opts.watchers;
190
+ this.ownsWatchers = false;
191
+ } else {
192
+ // Auto-constructed registry shares the same logger so daemon ops see
193
+ // both cache and watcher events; var-system's default stays quiet.
194
+ this.watchers = new WatcherRegistry({ logger: this.logger });
195
+ this.ownsWatchers = true;
196
+ }
197
+ const sanityMs = opts.sanityIntervalMs ?? SANITY_INTERVAL_MS;
198
+ if (sanityMs > 0) {
199
+ this.sanityTimer = setInterval(() => this.runSanityCheck(), sanityMs);
200
+ this.sanityTimer.unref();
201
+ }
202
+ }
203
+
204
+ getWatcherRegistry(): WatcherRegistry {
205
+ return this.watchers;
206
+ }
207
+
208
+ getStats(): {
209
+ size: number;
210
+ hits: number;
211
+ misses: number;
212
+ invalidations: number;
213
+ watchers: number;
214
+ } {
215
+ return {
216
+ size: this.entries.size,
217
+ hits: this.hits,
218
+ misses: this.misses,
219
+ invalidations: this.invalidations,
220
+ watchers: this.watchers.size(),
221
+ };
222
+ }
223
+
224
+ override async getGitInfo(
225
+ workingDir: string,
226
+ options: GitOptions = {},
227
+ projectDir?: string,
228
+ ): Promise<Outcome<GitInfo>> {
229
+ // [LAW:one-source-of-truth] Effective gitDir is the cache + watcher
230
+ // identity. Resolving once here means inner.getGitInfo sees workingDir =
231
+ // effectiveDir, projectDir = undefined: it lands on the same dir without
232
+ // re-running its own resolution branches. An absent (not a repo) or
233
+ // failed resolution passes through as the fetch outcome.
234
+ const effectiveDir = await this.inner.resolveEffectiveGitDir(
235
+ workingDir,
236
+ projectDir,
237
+ );
238
+ if (effectiveDir.kind !== "ok") return effectiveDir;
239
+ return this.getGitInfoForRoot(effectiveDir.value, options);
240
+ }
241
+
242
+ // Cache+fetch helper that *already knows* the effective gitDir. Subscribe
243
+ // and refresh use this directly so they don't pay an extra resolution per
244
+ // call (which on the refresh-loop hot path was an extra `git rev-parse`
245
+ // per invalidation — finding from PR #8 review pass 2).
246
+ private getGitInfoForRoot(
247
+ repoRoot: string,
248
+ options: GitOptions,
249
+ ): Promise<Outcome<GitInfo>> {
250
+ const key = `${repoRoot}|${optionsKey(options)}`;
251
+ const now = Date.now();
252
+
253
+ const existing = this.entries.get(key);
254
+ if (existing && now - existing.computedAt < this.ttlMs) {
255
+ this.entries.delete(key);
256
+ this.entries.set(key, existing);
257
+ this.hits++;
258
+ return Promise.resolve(ok(existing.info));
259
+ }
260
+
261
+ // Coalesce concurrent misses on the same key — see fetchInFlight comment.
262
+ const pending = this.fetchInFlight.get(key);
263
+ if (pending) return pending;
264
+
265
+ const promise = this.doFetch(repoRoot, key, options, now).finally(() => {
266
+ this.fetchInFlight.delete(key);
267
+ });
268
+ this.fetchInFlight.set(key, promise);
269
+ return promise;
270
+ }
271
+
272
+ private async doFetch(
273
+ repoRoot: string,
274
+ key: string,
275
+ options: GitOptions,
276
+ now: number,
277
+ ): Promise<Outcome<GitInfo>> {
278
+ this.misses++;
279
+ // [LAW:one-source-of-truth] gitDir is the watch+mtime identity for the
280
+ // repo. For worktrees this differs from repoRoot — keying watchers off
281
+ // <repoRoot>/.git/HEAD would silently fail there since .git is a file.
282
+ const gitDir = this.inner.resolveGitDir(repoRoot);
283
+ const mtimeBefore = snapshotMtimes(gitDir);
284
+ // Pass repoRoot as workingDir, no projectDir: inner's gitDir resolution
285
+ // lands on repoRoot via the sync isWorktree/isGitRepo checks — no extra
286
+ // findGitRoot shell-out.
287
+ //
288
+ // Only `ok` is cached: absent/failed pass through uncached (the next
289
+ // caller re-fetches, matching the prior null-isn't-cached behavior), and
290
+ // the outcome carries the reason to each surface's logging edge.
291
+ const outcome = await this.inner.getGitInfo(repoRoot, options);
292
+ if (outcome.kind !== "ok") return outcome;
293
+ const info = outcome.value;
294
+
295
+ // Drop any prior entry for this exact key before re-inserting (so we
296
+ // release its watcher refcount cleanly).
297
+ this.dropEntry(key);
298
+
299
+ // Files/dirs inside .git that meaningfully change what we'd render.
300
+ // Working-tree changes are picked up by `git status` itself the next time
301
+ // the cache misses.
302
+ const watcher = this.watchers.acquire(
303
+ `git:${repoRoot}`,
304
+ watcherTargets(gitDir),
305
+ () => this.invalidateRepo(repoRoot),
306
+ );
307
+ this.entries.set(key, {
308
+ info,
309
+ computedAt: now,
310
+ mtime: mtimeBefore,
311
+ watcher,
312
+ repoRoot,
313
+ });
314
+ this.evictIfNeeded();
315
+ return outcome;
316
+ }
317
+
318
+ // [LAW:effects-at-boundaries] The subscribe surface's edge: fold the typed
319
+ // outcome into the GitInfo|null the var-system callback contract expects.
320
+ // `failed` is logged HERE — the one log site for this consumption path
321
+ // (the pull path's edge is buildRenderPayload) — so the interior fetch
322
+ // machinery never logs and never double-logs.
323
+ private deliverable(
324
+ outcome: Outcome<GitInfo>,
325
+ repoRoot: string,
326
+ ): GitInfo | null {
327
+ if (outcome.kind === "failed") {
328
+ this.logger("warn", `git fetch failed (${repoRoot}): ${outcome.reason}`);
329
+ return null;
330
+ }
331
+ return outcome.kind === "ok" ? outcome.value : null;
332
+ }
333
+
334
+ // [LAW:dataflow-not-control-flow] Push surface for var-system. The callback
335
+ // receives the current snapshot once (after the initial fetch completes)
336
+ // and again after each invalidation — sharing the one cache + one watcher
337
+ // already managed by getInfo(). Multiple subscribers for the same repoRoot
338
+ // share one fetch; the resolved repoRoot is the unit of sharing, not the
339
+ // workingDir.
340
+ //
341
+ // Initial delivery is asynchronous: subscribe() returns immediately, but
342
+ // the callback fires *after* both gitDir resolution and the first fetch
343
+ // settle (the fetch can include a `git status` shell-out on a cold cache).
344
+ // It is **not** a same-tick or microtask delivery — consumers should not
345
+ // rely on the box value changing before the next render scheduling tick.
346
+ subscribe(workingDir: string, callback: SubscribeCallback): () => void {
347
+ let unsubscribed = false;
348
+ let attached: { repoRoot: string; entry: RepoSubscribers } | null = null;
349
+
350
+ void (async () => {
351
+ // Resolve once at subscribe time using the same logic the pull surface
352
+ // uses. var-system's declareGit doesn't pass projectDir, but going
353
+ // through resolveEffectiveGitDir keeps the cache-key derivation
354
+ // identical for both surfaces — single source of truth.
355
+ const resolved = await this.inner.resolveEffectiveGitDir(workingDir);
356
+ if (unsubscribed) return;
357
+
358
+ if (resolved.kind !== "ok") {
359
+ // Not in a git repo (absent) or resolution failed: deliver null once.
360
+ // No watcher, no follow-up — when the path later becomes a repo, the
361
+ // existing daemon-lifecycle invariants don't try to detect that, and
362
+ // neither did the prior GitPoller. Subscribers handle null by
363
+ // applying their fallback chain; a failure is logged at this edge.
364
+ if (resolved.kind === "failed") {
365
+ this.logger(
366
+ "warn",
367
+ `git resolve failed (subscribe ${workingDir}): ${resolved.reason}`,
368
+ );
369
+ }
370
+ this.safeInvoke(callback, null);
371
+ return;
372
+ }
373
+ const repoRoot = resolved.value;
374
+
375
+ let entry = this.subscribersByRepo.get(repoRoot);
376
+ if (!entry) {
377
+ const gitDir = this.inner.resolveGitDir(repoRoot);
378
+ const watcher = this.watchers.acquire(
379
+ `git:${repoRoot}`,
380
+ watcherTargets(gitDir),
381
+ () => this.invalidateRepo(repoRoot),
382
+ );
383
+ entry = {
384
+ workingDir,
385
+ repoRoot,
386
+ callbacks: new Set<SubscribeCallback>(),
387
+ watcher,
388
+ };
389
+ this.subscribersByRepo.set(repoRoot, entry);
390
+ }
391
+ entry.callbacks.add(callback);
392
+ attached = { repoRoot, entry };
393
+
394
+ // Initial fetch: routes through getGitInfoForRoot using the already-
395
+ // resolved repoRoot — no second findGitRoot. Cache is shared with the
396
+ // pull surface so concurrent segment renders see the same value.
397
+ const initial = await this.getGitInfoForRoot(repoRoot, {
398
+ ...SUBSCRIBE_OPTIONS,
399
+ });
400
+ if (unsubscribed) return;
401
+ this.safeInvoke(callback, this.deliverable(initial, repoRoot));
402
+ })();
403
+
404
+ return () => {
405
+ unsubscribed = true;
406
+ if (!attached) return;
407
+ const { repoRoot, entry } = attached;
408
+ entry.callbacks.delete(callback);
409
+ if (entry.callbacks.size === 0) {
410
+ entry.watcher.release();
411
+ this.subscribersByRepo.delete(repoRoot);
412
+ }
413
+ };
414
+ }
415
+
416
+ // Public for tests + future stats endpoint. Drops every entry for repoRoot
417
+ // and re-fetches for any active subscribers (the watcher fire path).
418
+ invalidateRepo(repoRoot: string): void {
419
+ let dropped = 0;
420
+ for (const [key, entry] of this.entries) {
421
+ if (entry.repoRoot === repoRoot) {
422
+ entry.watcher.release();
423
+ this.entries.delete(key);
424
+ dropped++;
425
+ }
426
+ }
427
+ if (dropped > 0) {
428
+ this.invalidations += dropped;
429
+ this.logger("info", `gitCache invalidate ${repoRoot} dropped=${dropped}`);
430
+ }
431
+ this.refreshSubscribers(repoRoot);
432
+ }
433
+
434
+ // [LAW:single-enforcer] Refreshes for one repo are serialized. If invalidation
435
+ // re-fires while the current refresh is awaiting `getGitInfo`, we set the
436
+ // trailing-edge flag and the loop re-runs once more; back-to-back
437
+ // invalidations collapse into at most two fetches, never N parallel ones.
438
+ private refreshSubscribers(repoRoot: string): void {
439
+ if (this.refreshing.has(repoRoot)) {
440
+ this.refreshAgain.add(repoRoot);
441
+ return;
442
+ }
443
+ const entry = this.subscribersByRepo.get(repoRoot);
444
+ if (!entry || entry.callbacks.size === 0) return;
445
+ this.refreshing.add(repoRoot);
446
+ void this.doRefreshLoop(repoRoot);
447
+ }
448
+
449
+ private async doRefreshLoop(repoRoot: string): Promise<void> {
450
+ try {
451
+ do {
452
+ this.refreshAgain.delete(repoRoot);
453
+ const entry = this.subscribersByRepo.get(repoRoot);
454
+ if (!entry || entry.callbacks.size === 0) return;
455
+ // Use the stored repoRoot — no findGitRoot per refresh, no chance of
456
+ // re-resolving to a different value under racing fs changes.
457
+ const refreshed = await this.getGitInfoForRoot(repoRoot, {
458
+ ...SUBSCRIBE_OPTIONS,
459
+ });
460
+ const info = this.deliverable(refreshed, repoRoot);
461
+ const current = this.subscribersByRepo.get(repoRoot);
462
+ if (!current || current.callbacks.size === 0) return;
463
+ // [LAW:dataflow-not-control-flow] Membership check at call time, not at
464
+ // snapshot time. A subscriber that unsubscribed during the await above
465
+ // (or during a prior cb invocation in this same iteration) must not
466
+ // receive this notification — has() reads the current truth.
467
+ for (const cb of [...current.callbacks]) {
468
+ if (!current.callbacks.has(cb)) continue;
469
+ this.safeInvoke(cb, info);
470
+ }
471
+ } while (this.refreshAgain.has(repoRoot));
472
+ } finally {
473
+ this.refreshing.delete(repoRoot);
474
+ this.refreshAgain.delete(repoRoot);
475
+ }
476
+ }
477
+
478
+ private safeInvoke(cb: SubscribeCallback, info: GitInfo | null): void {
479
+ try {
480
+ cb(info);
481
+ } catch (e) {
482
+ this.logger(
483
+ "warn",
484
+ `git subscriber threw: ${(e as Error).message ?? String(e)}`,
485
+ );
486
+ }
487
+ }
488
+
489
+ private dropEntry(key: string): void {
490
+ const entry = this.entries.get(key);
491
+ if (!entry) return;
492
+ entry.watcher.release();
493
+ this.entries.delete(key);
494
+ }
495
+
496
+ private evictIfNeeded(): void {
497
+ while (this.entries.size > this.maxEntries) {
498
+ const oldest = this.entries.keys().next().value;
499
+ if (oldest === undefined) break;
500
+ this.dropEntry(oldest);
501
+ this.logger("info", `gitCache evict ${oldest}`);
502
+ }
503
+ }
504
+
505
+ // [LAW:single-enforcer] Watchers are an optimization; this mtime walk is
506
+ // the correctness backstop for filesystems where fs.watch silently no-ops
507
+ // (network mounts, some FUSE volumes). Sample mtimes from the *resolved*
508
+ // gitDir so worktrees compare against real HEAD/index files.
509
+ private runSanityCheck(): void {
510
+ const seen = new Map<string, MtimeSnapshot>();
511
+ for (const entry of this.entries.values()) {
512
+ let current = seen.get(entry.repoRoot);
513
+ if (!current) {
514
+ const gitDir = this.inner.resolveGitDir(entry.repoRoot);
515
+ current = snapshotMtimes(gitDir);
516
+ seen.set(entry.repoRoot, current);
517
+ }
518
+ if (mtimeChanged(entry.mtime, current)) {
519
+ this.invalidateRepo(entry.repoRoot);
520
+ }
521
+ }
522
+ }
523
+
524
+ // Test hook: drive the sanity check synchronously.
525
+ runSanityCheckNow(): void {
526
+ this.runSanityCheck();
527
+ }
528
+
529
+ close(): void {
530
+ if (this.sanityTimer) {
531
+ clearInterval(this.sanityTimer);
532
+ this.sanityTimer = null;
533
+ }
534
+ for (const entry of this.entries.values()) {
535
+ entry.watcher.release();
536
+ }
537
+ this.entries.clear();
538
+ for (const entry of this.subscribersByRepo.values()) {
539
+ entry.callbacks.clear();
540
+ entry.watcher.release();
541
+ }
542
+ this.subscribersByRepo.clear();
543
+ // In-flight refreshes will observe the empty subscribers map on their
544
+ // next iteration and exit naturally; the flags are cleared so a fresh
545
+ // provider with the same repoRoot starts clean. In-flight fetches still
546
+ // resolve (we can't cancel a pending await on inner.getGitInfo) but the
547
+ // map is cleared so the next caller starts a fresh fetch.
548
+ this.refreshing.clear();
549
+ this.refreshAgain.clear();
550
+ this.fetchInFlight.clear();
551
+ if (this.ownsWatchers) this.watchers.closeAll();
552
+ }
553
+ }