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