@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- 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
|
+
}
|