pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,507 +0,0 @@
1
- /**
2
- * The filesystem watcher — the "something changed" half of liveness
3
- * (weave-workspace §6).
4
- *
5
- * ```text
6
- * fs.watch(vault, {recursive}) ─┐
7
- * fs.watch(repo/.okf, {recursive}) ─┼─▶ debounce 80ms ─▶ onPath (cache.invalidate)
8
- * git HEAD/index poll (2s) ─┘ ─▶ onChange(scopes) ─▶ SSE
9
- * ```
10
- *
11
- * ## Events are hints, never deltas
12
- *
13
- * macOS coalesces `fs.watch` events and can drop them under a rapid burst,
14
- * and recursive watches on every platform report renames and content writes
15
- * with the same `"rename" | "change"` vocabulary. So a change here means
16
- * "something in this scope moved, re-read it" and nothing more. Everything
17
- * downstream — the cache, the SSE frame, the client's refetch — is built on
18
- * that weaker promise, which is why a missed event costs latency rather than
19
- * correctness.
20
- *
21
- * ## Coalescing into a scope set, not a path list
22
- *
23
- * A `git checkout` or a `/weave-scan` touches hundreds of files. The
24
- * debounce collects them into a {@link ChangeScope} *set*, so one window
25
- * emits at most three frames (`vault`, `repo`, `git`) no matter how many
26
- * paths took part. Per-path work still happens — {@link WatcherOptions.onPath}
27
- * fires immediately for each accepted path so the cache can evict precisely —
28
- * but the broadcast is per scope.
29
- *
30
- * The window is **leading-edge opening, trailing-edge firing**: the first
31
- * accepted path opens an 80 ms window and every later path joins it without
32
- * restarting it. A restart-on-each-event debounce starves under a sustained
33
- * write stream (a large checkout would emit nothing until it finished);
34
- * this variant bounds notification latency at `debounceMs` regardless of
35
- * event rate, which is the property that matters for a live UI.
36
- *
37
- * ## Why git is polled rather than watched
38
- *
39
- * `<repo>/.git` is not under either watched root, and watching it directly
40
- * would mean absorbing the object database's write traffic to learn one bit.
41
- * A 2 s fingerprint of `HEAD`, the loose ref it points at, and `index`
42
- * catches branch switches, commits and staging with three `stat`/`read`
43
- * calls and no subprocess.
44
- */
45
-
46
- import { promises as fs, watch, type FSWatcher } from "node:fs";
47
- import { isAbsolute, join, resolve } from "node:path";
48
- import { classifyPath } from "../../core/cache/workspace";
49
- import { repoIndexDir } from "../../core/paths";
50
- import { CHANGE_SCOPES, type ChangeScope } from "../shared/wire";
51
-
52
- // --- injectable timers --------------------------------------------------------
53
-
54
- /**
55
- * The two timing primitives this module needs, injected so tests never sleep.
56
- *
57
- * Cancellation is a returned closure rather than an opaque handle: it keeps
58
- * the interface free of `NodeJS.Timeout` (which a fake would have to forge)
59
- * and makes "cancel exactly the thing I scheduled" the only expressible
60
- * operation.
61
- */
62
- export interface Scheduler {
63
- /** Run `fn` once after `ms`. Returns a cancel function; cancelling twice is safe. */
64
- delay(fn: () => void, ms: number): () => void;
65
- /** Run `fn` every `ms`. Returns a cancel function; cancelling twice is safe. */
66
- repeat(fn: () => void, ms: number): () => void;
67
- }
68
-
69
- /**
70
- * Real timers, `unref`'d so a watcher nobody closed cannot by itself keep the
71
- * pi session's process alive after shutdown.
72
- */
73
- export const realScheduler: Scheduler = {
74
- delay(fn, ms) {
75
- const handle = setTimeout(fn, ms);
76
- handle.unref?.();
77
- return () => clearTimeout(handle);
78
- },
79
- repeat(fn, ms) {
80
- const handle = setInterval(fn, ms);
81
- handle.unref?.();
82
- return () => clearInterval(handle);
83
- },
84
- };
85
-
86
- // --- the ignore list ----------------------------------------------------------
87
-
88
- /** §6 defaults: 80 ms debounce, 2 s git poll, 200 ms self-write suppression. */
89
- export const DEFAULT_DEBOUNCE_MS = 80;
90
- export const DEFAULT_GIT_POLL_MS = 2_000;
91
- export const DEFAULT_SUPPRESS_MS = 200;
92
-
93
- /** Split a path on either separator, dropping empty and `.` segments. */
94
- function segments(path: string): string[] {
95
- return path.split(/[\\/]/).filter((part) => part.length > 0 && part !== ".");
96
- }
97
-
98
- /**
99
- * True when a watch event for this path should be discarded outright.
100
- *
101
- * Takes the path **relative to the watched root** — that is what `fs.watch`
102
- * hands us, and it is also the only form in which a `.git` segment is
103
- * unambiguous. An absolute path could contain a directory literally named
104
- * `.git` somewhere above the root and would then be misread.
105
- *
106
- * The list, and why each entry is on it:
107
- *
108
- * | Pattern | Source |
109
- * | ----------- | ------ |
110
- * | `.git/**` | The object database writes constantly and means nothing to us. `HEAD` and `index` are the two exceptions — they are the branch-switch and staging signals, and are classified as the `git` scope. |
111
- * | `*.swp` | vim's swap file, rewritten on every keystroke. |
112
- * | `*~` | emacs/gedit backup on save — arrives paired with the real write. |
113
- * | `.#*` | emacs lock symlink, created on first edit and removed on close. |
114
- * | `4913` | vim's writability probe: created, `stat`ed and deleted before every single save. Named for the port-number-looking constant in `vim/src/fileio.c`. |
115
- * | `.DS_Store` | Finder rewrites it merely for opening the folder. |
116
- */
117
- export function isIgnoredPath(path: string): boolean {
118
- const parts = segments(path);
119
- const gitAt = parts.indexOf(".git");
120
- if (gitAt >= 0) {
121
- // Everything under `.git` is noise except the two files §6 names, and
122
- // those only when they sit directly in the git directory.
123
- const rest = parts.slice(gitAt + 1);
124
- return !(rest.length === 1 && (rest[0] === "HEAD" || rest[0] === "index"));
125
- }
126
- const base = parts[parts.length - 1] ?? "";
127
- if (base === ".DS_Store" || base === "4913") return true;
128
- if (base.endsWith(".swp") || base.endsWith("~")) return true;
129
- return base.startsWith(".#");
130
- }
131
-
132
- /**
133
- * The scope a *non-ignored* event path belongs to, or `null` for "not our
134
- * business".
135
- *
136
- * `relPath` is relative to `root`; `root` is one of the watched roots.
137
- * Classification of everything outside `.git` is delegated to
138
- * {@link classifyPath} in `src/core/cache/workspace` — the same function the
139
- * cache uses to decide what an invalidation affects. Two implementations of
140
- * "is this a note?" would eventually disagree, and the disagreement would
141
- * present as a note that silently stops updating.
142
- */
143
- export function inferScope(
144
- root: string,
145
- relPath: string,
146
- opts: { cwd: string; vaultRoot: string },
147
- ): ChangeScope | null {
148
- if (isIgnoredPath(relPath)) return null;
149
- // Survived the ignore list with a `.git` segment ⇒ it is HEAD or index.
150
- if (segments(relPath).includes(".git")) return "git";
151
- const scope = classifyPath(resolve(root, relPath), opts);
152
- return scope === "none" ? null : scope;
153
- }
154
-
155
- // --- watch handles ------------------------------------------------------------
156
-
157
- /** The slice of `FSWatcher` this module uses. */
158
- export interface WatchHandle {
159
- close(): void;
160
- }
161
-
162
- /**
163
- * Opens one recursive watch. Injected so tests can make it throw (the
164
- * network-filesystem case, §6 / §14) without mocking `node:fs` globally.
165
- *
166
- * `onEvent` receives the path relative to `root`, or `null` when the platform
167
- * declined to name the file — which is a legitimate outcome and means "assume
168
- * the whole root moved".
169
- */
170
- export type OpenWatch = (
171
- root: string,
172
- onEvent: (relPath: string | null) => void,
173
- onError: (error: Error) => void,
174
- ) => WatchHandle;
175
-
176
- /** `fs.watch` with the options §6 requires. Recursive is safe on `engines >= 20.13.0`. */
177
- export const realOpenWatch: OpenWatch = (root, onEvent, onError) => {
178
- const watcher: FSWatcher = watch(root, { recursive: true, persistent: false }, (_type, filename) => {
179
- onEvent(typeof filename === "string" ? filename : null);
180
- });
181
- watcher.on("error", onError);
182
- return watcher;
183
- };
184
-
185
- // --- status -------------------------------------------------------------------
186
-
187
- /** One root that `fs.watch` refused, with the reason. */
188
- export interface WatchFailure {
189
- root: string;
190
- error: string;
191
- }
192
-
193
- /**
194
- * What {@link Watcher.start} achieved.
195
- *
196
- * `available === false` is the signal §14 calls for: the caller falls back to
197
- * polling `/api/graph` for a changed stamp instead of assuming liveness it is
198
- * not getting. It is never an exception — a container or network filesystem
199
- * without `inotify` must degrade the workspace, not fail to open it.
200
- */
201
- export interface WatcherStatus {
202
- /** Roots currently under an open watch. */
203
- watching: string[];
204
- /** Roots that could not be watched, and why. */
205
- failed: WatchFailure[];
206
- /** True while at least one root is watched. */
207
- available: boolean;
208
- }
209
-
210
- export interface WatcherOptions {
211
- /** Repository/working directory. `<cwd>/.okf` is the second watched root. */
212
- cwd: string;
213
- /** Vault root (`~/.okf` by default). The first watched root. */
214
- vaultRoot: string;
215
- /**
216
- * One call per debounce window, with the scopes that changed, in
217
- * {@link CHANGE_SCOPES} order. Never called with an empty list.
218
- */
219
- onChange: (scopes: readonly ChangeScope[]) => void;
220
- /**
221
- * One call per accepted path, immediately — not debounced. This is
222
- * `cache.invalidate(path)` in the §6 diagram: eviction wants every path,
223
- * the broadcast wants one frame per scope.
224
- */
225
- onPath?: (absPath: string, scope: ChangeScope) => void;
226
- /** Injected clock in epoch ms, for suppression windows. */
227
- now?: () => number;
228
- debounceMs?: number;
229
- gitPollMs?: number;
230
- scheduler?: Scheduler;
231
- openWatch?: OpenWatch;
232
- }
233
-
234
- /** Fingerprint components of the git directory. Empty string = absent. */
235
- interface GitFingerprint {
236
- head: string;
237
- ref: string;
238
- index: string;
239
- }
240
-
241
- function sameFingerprint(a: GitFingerprint, b: GitFingerprint): boolean {
242
- return a.head === b.head && a.ref === b.ref && a.index === b.index;
243
- }
244
-
245
- /**
246
- * Watches the vault and the repository index, and polls git.
247
- *
248
- * A class for the same reason {@link classifyPath}'s owner is: this has a
249
- * lifetime, open OS handles and mutable state that a caller must be able to
250
- * `close()`. Everything decision-shaped is a pure exported function above, so
251
- * the class holds wiring only.
252
- */
253
- export class Watcher {
254
- private readonly cwd: string;
255
- private readonly vaultRoot: string;
256
- private readonly onChange: (scopes: readonly ChangeScope[]) => void;
257
- private readonly onPath: ((absPath: string, scope: ChangeScope) => void) | undefined;
258
- private readonly now: () => number;
259
- private readonly debounceMs: number;
260
- private readonly gitPollMs: number;
261
- private readonly scheduler: Scheduler;
262
- private readonly openWatch: OpenWatch;
263
-
264
- /** Watched root → its open handle. */
265
- private readonly handles = new Map<string, WatchHandle>();
266
- private readonly failures: WatchFailure[] = [];
267
-
268
- /** Scopes accumulated by the currently open debounce window. */
269
- private readonly pending = new Set<ChangeScope>();
270
- private cancelDebounce: (() => void) | null = null;
271
- private cancelPoll: (() => void) | null = null;
272
-
273
- /** Absolute path → epoch ms at which its self-write suppression expires. */
274
- private readonly suppressed = new Map<string, number>();
275
-
276
- /** `null` until the first poll establishes a baseline to compare against. */
277
- private gitSeen: GitFingerprint | null = null;
278
-
279
- private started = false;
280
- private closed = false;
281
-
282
- constructor(opts: WatcherOptions) {
283
- this.cwd = opts.cwd;
284
- this.vaultRoot = opts.vaultRoot;
285
- this.onChange = opts.onChange;
286
- this.onPath = opts.onPath;
287
- this.now = opts.now ?? (() => Date.now());
288
- this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
289
- this.gitPollMs = opts.gitPollMs ?? DEFAULT_GIT_POLL_MS;
290
- this.scheduler = opts.scheduler ?? realScheduler;
291
- this.openWatch = opts.openWatch ?? realOpenWatch;
292
- }
293
-
294
- /**
295
- * Open the watches and start the git poll.
296
- *
297
- * Never throws. A root that cannot be watched is recorded in
298
- * {@link WatcherStatus.failed} and the others still run; if every root
299
- * fails, `available` is false and the caller falls back to stamp polling.
300
- */
301
- start(): WatcherStatus {
302
- if (this.started) return this.status();
303
- this.started = true;
304
- for (const root of [this.vaultRoot, repoIndexDir(this.cwd)]) {
305
- this.open(root);
306
- }
307
- this.cancelPoll = this.scheduler.repeat(() => void this.pollGitOnce(), this.gitPollMs);
308
- return this.status();
309
- }
310
-
311
- /** Current watch health. Reflects roots lost to a runtime error, not just startup. */
312
- status(): WatcherStatus {
313
- return {
314
- watching: [...this.handles.keys()],
315
- failed: this.failures.map((f) => ({ ...f })),
316
- available: this.handles.size > 0,
317
- };
318
- }
319
-
320
- /**
321
- * Ignore changes to `absPath` for the next `ms`.
322
- *
323
- * `POST /api/open` (and, at P5, a browser save) writes through the same
324
- * filesystem the watcher is watching, so without this the write comes
325
- * straight back as a change event, which broadcasts, which makes the client
326
- * refetch what it just caused. A window rather than a one-shot flag because
327
- * a single logical save can produce several events — the editor's temp
328
- * file, the rename, the mtime touch.
329
- */
330
- suppress(absPath: string, ms: number = DEFAULT_SUPPRESS_MS): void {
331
- this.suppressed.set(resolve(absPath), this.now() + ms);
332
- }
333
-
334
- /**
335
- * Stop everything: close the OS handles, cancel the debounce window and the
336
- * poll. Idempotent, because both `session_shutdown` and an explicit stop can
337
- * plausibly arrive.
338
- */
339
- close(): void {
340
- if (this.closed) return;
341
- this.closed = true;
342
- for (const handle of this.handles.values()) safeClose(handle);
343
- this.handles.clear();
344
- this.cancelDebounce?.();
345
- this.cancelDebounce = null;
346
- this.cancelPoll?.();
347
- this.cancelPoll = null;
348
- this.pending.clear();
349
- }
350
-
351
- /**
352
- * One git fingerprint comparison. The body of the poll, exposed so tests
353
- * drive it directly instead of waiting 2 s.
354
- *
355
- * The first call only establishes a baseline: at startup nothing has
356
- * changed yet, and emitting a `git` frame for the state the client already
357
- * fetched would be a spurious refetch on every boot.
358
- */
359
- async pollGitOnce(): Promise<void> {
360
- if (this.closed) return;
361
- const next = await readGitFingerprint(this.cwd);
362
- const previous = this.gitSeen;
363
- this.gitSeen = next;
364
- if (previous === null || sameFingerprint(previous, next)) return;
365
- this.enqueue("git");
366
- }
367
-
368
- // --- internals --------------------------------------------------------------
369
-
370
- private open(root: string): void {
371
- try {
372
- const handle = this.openWatch(
373
- root,
374
- (relPath) => this.onEvent(root, relPath),
375
- (error) => this.onWatchError(root, error),
376
- );
377
- this.handles.set(root, handle);
378
- } catch (error) {
379
- this.failures.push({ root, error: messageOf(error) });
380
- }
381
- }
382
-
383
- /**
384
- * A watch that dies mid-session (the directory was deleted, inotify ran out
385
- * of handles) is demoted to a failure rather than left in `watching`, so
386
- * `available` stays an honest answer to "is liveness working right now?".
387
- */
388
- private onWatchError(root: string, error: Error): void {
389
- const handle = this.handles.get(root);
390
- if (handle === undefined) return;
391
- safeClose(handle);
392
- this.handles.delete(root);
393
- this.failures.push({ root, error: messageOf(error) });
394
- }
395
-
396
- private onEvent(root: string, relPath: string | null): void {
397
- if (this.closed) return;
398
- if (relPath === null) {
399
- // The platform declined to name the file. Treat the whole root as dirty
400
- // rather than guessing — this is the "hint, not delta" contract paying
401
- // for itself.
402
- this.enqueue(rootScope(root, this.vaultRoot));
403
- return;
404
- }
405
- const scope = inferScope(root, relPath, { cwd: this.cwd, vaultRoot: this.vaultRoot });
406
- if (scope === null) return;
407
- const abs = resolve(root, relPath);
408
- if (this.isSuppressed(abs)) return;
409
- this.onPath?.(abs, scope);
410
- this.enqueue(scope);
411
- }
412
-
413
- /** True while `abs` is inside its self-write window. Expired entries are reaped. */
414
- private isSuppressed(abs: string): boolean {
415
- const until = this.suppressed.get(abs);
416
- if (until === undefined) return false;
417
- if (this.now() < until) return true;
418
- this.suppressed.delete(abs);
419
- return false;
420
- }
421
-
422
- /**
423
- * Add a scope to the open window, opening one if there is none. The window
424
- * is deliberately *not* restarted by later events — see the module header.
425
- */
426
- private enqueue(scope: ChangeScope): void {
427
- this.pending.add(scope);
428
- if (this.cancelDebounce !== null) return;
429
- this.cancelDebounce = this.scheduler.delay(() => this.flush(), this.debounceMs);
430
- }
431
-
432
- private flush(): void {
433
- this.cancelDebounce = null;
434
- if (this.closed || this.pending.size === 0) return;
435
- const scopes = CHANGE_SCOPES.filter((scope) => this.pending.has(scope));
436
- this.pending.clear();
437
- this.onChange(scopes);
438
- }
439
- }
440
-
441
- /** Which scope an unnamed event on a watched root implies. */
442
- function rootScope(root: string, vaultRoot: string): ChangeScope {
443
- return resolve(root) === resolve(vaultRoot) ? "vault" : "repo";
444
- }
445
-
446
- function safeClose(handle: WatchHandle): void {
447
- try {
448
- handle.close();
449
- } catch {
450
- // Already closed, or the handle died with its directory. Either way there
451
- // is nothing left to release and shutdown must not fail because of it.
452
- }
453
- }
454
-
455
- function messageOf(error: unknown): string {
456
- return error instanceof Error ? error.message : String(error);
457
- }
458
-
459
- // --- git fingerprint ----------------------------------------------------------
460
-
461
- /**
462
- * Resolve the real git directory for `cwd`.
463
- *
464
- * Usually `<cwd>/.git`, but in a linked worktree or a submodule `.git` is a
465
- * *file* containing `gitdir: <path>`. Following it costs four lines and is the
466
- * difference between working and silently never reporting a branch switch for
467
- * everyone who uses `git worktree`.
468
- */
469
- async function resolveGitDir(cwd: string): Promise<string> {
470
- const dotGit = join(cwd, ".git");
471
- const stat = await fs.stat(dotGit).catch(() => null);
472
- if (stat === null || stat.isDirectory()) return dotGit;
473
- const text = (await fs.readFile(dotGit, "utf8").catch(() => "")).trim();
474
- const match = /^gitdir:\s*(.+)$/.exec(text);
475
- if (match === null) return dotGit;
476
- const target = match[1]!.trim();
477
- return isAbsolute(target) ? target : resolve(cwd, target);
478
- }
479
-
480
- /**
481
- * A cheap, total fingerprint of "which commit are we on, and what is staged".
482
- *
483
- * - `HEAD` text: `ref: refs/heads/main` or a bare sha when detached. Catches
484
- * every branch switch.
485
- * - the loose ref file `HEAD` names: catches commits, which move the ref but
486
- * leave `HEAD` byte-identical. A commit always writes a *loose* ref, so not
487
- * consulting `packed-refs` costs nothing — a packed ref is by definition one
488
- * that has not moved since the pack.
489
- * - `index` size and mtime: catches staging without reading a file that is
490
- * routinely megabytes.
491
- *
492
- * Every read is best-effort. Outside a repository all three parts stay empty,
493
- * the fingerprint never changes, and the poll is a no-op forever.
494
- */
495
- async function readGitFingerprint(cwd: string): Promise<GitFingerprint> {
496
- const gitDir = await resolveGitDir(cwd);
497
- const head = (await fs.readFile(join(gitDir, "HEAD"), "utf8").catch(() => "")).trim();
498
- const refMatch = /^ref:\s*(.+)$/.exec(head);
499
- const ref =
500
- refMatch === null
501
- ? ""
502
- : (await fs.readFile(join(gitDir, refMatch[1]!.trim()), "utf8").catch(() => "")).trim();
503
- const indexStat = await fs.stat(join(gitDir, "index")).catch(() => null);
504
- const index = indexStat === null ? "" : `${indexStat.size}:${indexStat.mtimeMs}`;
505
- return { head, ref, index };
506
- }
507
-