@fml-inc/fml 0.8.13-rc.1 → 0.8.13-rc.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fml",
3
- "version": "0.8.13-rc.1",
3
+ "version": "0.8.13-rc.2",
4
4
  "description": "FML agent tools for Claude Code",
5
5
  "mcpServers": {
6
6
  "fml": {
package/README.md CHANGED
@@ -398,6 +398,48 @@ directories request owner-only permissions, and the SQLite database and
398
398
  sidecars are tightened to owner-only modes on platforms that support POSIX
399
399
  modes. An existing caller-supplied state directory retains its directory mode.
400
400
 
401
+ ## Local Git state
402
+
403
+ Hooks, MCP defaults, and transcript discovery use the shared
404
+ `@fml/agent-targets/git-state` resolver. A snapshot includes the canonical
405
+ GitHub repository (when available), full HEAD ref, branch, commit SHA,
406
+ worktree root, worktree Git directory, common Git directory, and detached,
407
+ unborn, and linked-worktree flags. Local checkout state is retained even when
408
+ there is no GitHub origin. Origin parsing consistently requires `github.com`;
409
+ SSH aliases need a Git `url.*.insteadOf` rewrite to a canonical GitHub URL.
410
+ Raw remote URLs and Git configuration are not stored.
411
+
412
+ The resolver has synchronous and asynchronous entry points with a shared
413
+ command sequence. Ordinary cold snapshots use two Git processes; symbolic-ref
414
+ chains, reftable storage, and discovery overrides use an additional lookup.
415
+ Hooks have a 250 ms total Git subprocess budget. It reads
416
+ only the current checkout; it does not enumerate sibling worktrees or scan
417
+ working files for changes.
418
+
419
+ The cache holds up to 512 checkout snapshots in memory and in
420
+ `git-state.cache.json` under the observer directory. Subdirectories and
421
+ symlink aliases share their checkout's entry; linked worktrees remain
422
+ separate. Cache hits inspect local metadata without launching Git. HEAD,
423
+ current refs, packed refs, and local/worktree configuration changes invalidate
424
+ entries immediately. Small ref files are read once per lookup; config and
425
+ packed-ref files use inode/size/nanosecond mtime and ctime identities. The hook
426
+ resolver validates snapshots without loading a schema library. A five-second
427
+ maximum age bounds changes to global and
428
+ included configuration. Unknown layouts and indeterminate failures bypass
429
+ persistence. Concurrent cache writers may lose cache entries, never captures.
430
+
431
+ New hook payloads carry the snapshot in `_fmlGitState`; `null` records an
432
+ unsuccessful capture-time lookup. Upload never replaces those snapshots with
433
+ a later checkout. Legacy hooks without a snapshot still use live resolution,
434
+ but a branch is only attached when the captured and live repository match.
435
+ Large hook payloads retain the snapshot when their other content is truncated.
436
+
437
+ Claude and Codex transcript discovery also include bounded `localGitStates`
438
+ metadata, observed at discovery time. Pi stores its initial snapshot in the
439
+ native repository-origin marker and reuses that evidence during transcript
440
+ sync. These fields use existing metadata/payload storage; they are not new
441
+ indexed database columns or a full history of local changes.
442
+
401
443
  ## Automatic updates
402
444
 
403
445
  While the observer is running it checks npm for a newer FML release about once
@@ -0,0 +1,34 @@
1
+ import {
2
+ GIT_STATE_CACHE_FILENAME,
3
+ createGitStateResolver
4
+ } from "./chunk-74KOMQZO.js";
5
+ import {
6
+ FML_OBSERVER_DIR
7
+ } from "./chunk-HDAFEHUY.js";
8
+
9
+ // src/repo.ts
10
+ import path from "path";
11
+ var CACHE_FILE = path.join(FML_OBSERVER_DIR, GIT_STATE_CACHE_FILENAME);
12
+ var resolver = createGitStateResolver({ cacheFile: CACHE_FILE });
13
+ function repoInfo(state) {
14
+ return state?.repository ? { repo: state.repository, branch: state.branch, gitState: state } : null;
15
+ }
16
+ function resolveGitStateFromCwd(cwd, timeoutMs = 1e3) {
17
+ return resolver.resolve(cwd, { timeoutMs });
18
+ }
19
+ function resolveRepoFromCwd(cwd, timeoutMs = 1e3) {
20
+ return repoInfo(resolveGitStateFromCwd(cwd, timeoutMs));
21
+ }
22
+ function resolveRepositoryRootFromCwd(cwd, timeoutMs = 1e3) {
23
+ return resolveGitStateFromCwd(cwd, timeoutMs)?.worktreeRoot ?? null;
24
+ }
25
+ async function resolveRepoFromCwdAsync(cwd, signal) {
26
+ return repoInfo(await resolver.resolveAsync(cwd, { signal }));
27
+ }
28
+
29
+ export {
30
+ resolveGitStateFromCwd,
31
+ resolveRepoFromCwd,
32
+ resolveRepositoryRootFromCwd,
33
+ resolveRepoFromCwdAsync
34
+ };
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  runRawSyncOnce
3
- } from "./chunk-U7SITLCL.js";
3
+ } from "./chunk-LYKMU7QR.js";
4
4
  import {
5
5
  resolvePackageRoot
6
- } from "./chunk-CSOFP7MT.js";
6
+ } from "./chunk-F57IFY3K.js";
7
7
 
8
8
  // src/observer/sync-cycle.ts
9
9
  import { fork } from "child_process";
@@ -0,0 +1,505 @@
1
+ // ../../packages/shared/src/github-repository.ts
2
+ var MAX_REPOSITORY_BYTES = 201;
3
+ var UTF8_ENCODER = new TextEncoder();
4
+ function utf8Bytes(value) {
5
+ return UTF8_ENCODER.encode(value).byteLength;
6
+ }
7
+ function githubRepositoryPath(pathname) {
8
+ const parts = pathname.replace(/^\/+|\/+$/g, "").split("/");
9
+ if (parts.length !== 2) return null;
10
+ const owner = parts[0];
11
+ const repository = parts[1]?.replace(/\.git$/i, "");
12
+ if (!owner || !repository) return null;
13
+ const segmentPattern = /^[A-Za-z0-9_.-]+$/;
14
+ if (!segmentPattern.test(owner) || !segmentPattern.test(repository)) {
15
+ return null;
16
+ }
17
+ const canonical = `${owner.toLowerCase()}/${repository.toLowerCase()}`;
18
+ if (canonical.startsWith("./") || canonical.startsWith("../") || repository === "." || repository === ".." || repository.toLowerCase().endsWith(".git")) {
19
+ return null;
20
+ }
21
+ return utf8Bytes(canonical) <= MAX_REPOSITORY_BYTES ? canonical : null;
22
+ }
23
+ function canonicalGitHubRepository(remote) {
24
+ const trimmed = remote.trim();
25
+ if (!trimmed || trimmed.includes("\0")) return null;
26
+ const scp = trimmed.match(/^git@github\.com:([^?#]+)$/i);
27
+ if (scp?.[1]) return githubRepositoryPath(scp[1]);
28
+ let url;
29
+ try {
30
+ url = new URL(trimmed);
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (url.hostname.toLowerCase() !== "github.com") return null;
35
+ if (url.search || url.hash || url.password || url.port) return null;
36
+ if (url.protocol === "https:" || url.protocol === "http:") {
37
+ if (url.username) return null;
38
+ } else if (url.protocol === "ssh:") {
39
+ if (url.username && url.username !== "git") return null;
40
+ } else {
41
+ return null;
42
+ }
43
+ return githubRepositoryPath(url.pathname);
44
+ }
45
+
46
+ // ../../packages/agent-targets/src/git-state.ts
47
+ import { execFile, execFileSync } from "child_process";
48
+ import { createHash, randomUUID } from "crypto";
49
+ import fs from "fs";
50
+ import path from "path";
51
+
52
+ // ../../packages/shared/src/git-state.ts
53
+ function isRecord(value) {
54
+ return value !== null && typeof value === "object" && !Array.isArray(value);
55
+ }
56
+ function validGitText(value) {
57
+ return typeof value === "string" && value.length > 0 && value.length <= 4096 && !new RegExp("\\p{Cc}", "u").test(value);
58
+ }
59
+ function parseGitState(value) {
60
+ if (!isRecord(value)) return null;
61
+ const {
62
+ version,
63
+ observedAtMs,
64
+ repository,
65
+ worktreeRoot,
66
+ gitDir,
67
+ commonDir,
68
+ headRef,
69
+ branch,
70
+ commitHash,
71
+ detached,
72
+ unborn,
73
+ isLinkedWorktree
74
+ } = value;
75
+ if (version !== 1 || typeof observedAtMs !== "number" || !Number.isSafeInteger(observedAtMs) || observedAtMs < 0 || !(repository === null || typeof repository === "string" && repository.length <= 201 && canonicalGitHubRepository(`https://github.com/${repository}`) === repository) || !validGitText(worktreeRoot) || !validGitText(gitDir) || !validGitText(commonDir) || !(headRef === null || validGitText(headRef)) || !(branch === null || validGitText(branch)) || !(commitHash === null || typeof commitHash === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commitHash)) || typeof detached !== "boolean" || typeof unborn !== "boolean" || typeof isLinkedWorktree !== "boolean")
76
+ return null;
77
+ return {
78
+ version,
79
+ observedAtMs,
80
+ repository,
81
+ worktreeRoot,
82
+ gitDir,
83
+ commonDir,
84
+ headRef,
85
+ branch,
86
+ commitHash,
87
+ detached,
88
+ unborn,
89
+ isLinkedWorktree
90
+ };
91
+ }
92
+ var MAX_GIT_STATES_BYTES = 16 * 1024;
93
+
94
+ // ../../packages/agent-targets/src/git-state.ts
95
+ function isRecord2(value) {
96
+ return value !== null && typeof value === "object" && !Array.isArray(value);
97
+ }
98
+ var GIT_STATE_CACHE_FILENAME = "git-state.cache.json";
99
+ var GIT_STATE_PAYLOAD_KEY = "_fmlGitState";
100
+ function capturedGitState(payload) {
101
+ if (!payload || typeof payload !== "object" || !(GIT_STATE_PAYLOAD_KEY in payload))
102
+ return void 0;
103
+ return parseGitState(payload[GIT_STATE_PAYLOAD_KEY]);
104
+ }
105
+ var DEFAULT_TTL_MS = 5e3;
106
+ var DEFAULT_TIMEOUT_MS = 1e3;
107
+ var DEFAULT_MAX_ENTRIES = 512;
108
+ var MAX_OUTPUT_BYTES = 16 * 1024;
109
+ var GIT_DISCOVERY_OVERRIDES = [
110
+ "GIT_DIR",
111
+ "GIT_COMMON_DIR",
112
+ "GIT_WORK_TREE",
113
+ "GIT_CEILING_DIRECTORIES",
114
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM"
115
+ ];
116
+ function* readState(cwd, knownHeadRef) {
117
+ const layout = yield {
118
+ args: [
119
+ "rev-parse",
120
+ "--show-toplevel",
121
+ "--absolute-git-dir",
122
+ "--git-common-dir",
123
+ "--verify",
124
+ "HEAD"
125
+ ],
126
+ // An unborn HEAD still emits the three validated checkout paths.
127
+ allowedExit: 128,
128
+ keepOutputOnAllowedExit: true
129
+ };
130
+ const [worktreePath, gitPath, commonPath, revision] = layout?.split("\n") ?? [];
131
+ if (!worktreePath || !gitPath || !commonPath) return null;
132
+ const worktreeRoot = path.resolve(cwd, worktreePath);
133
+ const gitDir = path.resolve(cwd, gitPath);
134
+ const commonDir = path.resolve(cwd, commonPath);
135
+ const headRef = knownHeadRef === void 0 ? yield { args: ["symbolic-ref", "--quiet", "HEAD"], allowedExit: 1 } : knownHeadRef;
136
+ const commitHash = revision ?? null;
137
+ if (!headRef && !commitHash) return null;
138
+ const remote = yield {
139
+ args: ["remote", "get-url", "origin"],
140
+ allowedExit: 2
141
+ };
142
+ return {
143
+ version: 1,
144
+ repository: remote ? canonicalGitHubRepository(remote) : null,
145
+ worktreeRoot,
146
+ gitDir,
147
+ commonDir,
148
+ headRef,
149
+ branch: headRef?.startsWith("refs/heads/") ? headRef.slice(11) : null,
150
+ commitHash: commitHash?.toLowerCase() ?? null,
151
+ detached: headRef === null,
152
+ unborn: headRef !== null && commitHash === null,
153
+ isLinkedWorktree: gitDir !== commonDir
154
+ };
155
+ }
156
+ function remaining(deadline) {
157
+ const milliseconds = deadline - performance.now();
158
+ if (milliseconds <= 0) throw new Error("Git lookup timed out");
159
+ return Math.max(1, Math.ceil(milliseconds));
160
+ }
161
+ function runSync(cwd, command, deadline) {
162
+ try {
163
+ return execFileSync("git", ["-C", cwd, ...command.args], {
164
+ encoding: "utf8",
165
+ timeout: remaining(deadline),
166
+ maxBuffer: MAX_OUTPUT_BYTES,
167
+ stdio: ["ignore", "pipe", "ignore"],
168
+ windowsHide: true
169
+ }).trim() || null;
170
+ } catch (error) {
171
+ if (command.allowedExit !== void 0 && error && typeof error === "object" && "status" in error && error.status === command.allowedExit) {
172
+ return command.keepOutputOnAllowedExit && "stdout" in error && typeof error.stdout === "string" ? error.stdout.trim() || null : null;
173
+ }
174
+ throw error;
175
+ }
176
+ }
177
+ async function runAsync(cwd, command, deadline, signal) {
178
+ return await new Promise((resolve, reject) => {
179
+ execFile(
180
+ "git",
181
+ ["-C", cwd, ...command.args],
182
+ {
183
+ encoding: "utf8",
184
+ timeout: remaining(deadline),
185
+ maxBuffer: MAX_OUTPUT_BYTES,
186
+ windowsHide: true,
187
+ signal
188
+ },
189
+ (error, stdout) => {
190
+ if (signal?.aborted)
191
+ reject(signal.reason ?? new Error("Git lookup aborted"));
192
+ else if (error && error.code !== command.allowedExit) reject(error);
193
+ else
194
+ resolve(
195
+ error && !command.keepOutputOnAllowedExit ? null : stdout.trim() || null
196
+ );
197
+ }
198
+ );
199
+ });
200
+ }
201
+ function inspectCheckout(cwd) {
202
+ if (!cwd || cwd.trim() !== cwd || new RegExp("\\p{Cc}", "u").test(cwd) || Buffer.byteLength(cwd) > 4096)
203
+ return null;
204
+ try {
205
+ const directory = fs.realpathSync(cwd);
206
+ if (!fs.statSync(directory).isDirectory()) return null;
207
+ if (GIT_DISCOVERY_OVERRIDES.some((key) => process.env[key] !== void 0))
208
+ return { key: directory, signature: null };
209
+ const layout = findGitDirectory(directory);
210
+ if (!layout) return null;
211
+ if (process.env.GIT_CONFIG !== void 0)
212
+ return { key: layout.worktreeRoot, signature: null };
213
+ const local = checkoutSignature(layout);
214
+ const environment = Object.keys(process.env).filter(
215
+ (key) => key.startsWith("GIT_") || key === "HOME" || key === "XDG_CONFIG_HOME"
216
+ ).sort().map((key) => [key, process.env[key]]);
217
+ return {
218
+ key: layout.worktreeRoot,
219
+ headRef: local.headRef,
220
+ signature: local.signature === null ? null : createHash("sha256").update(JSON.stringify([local.signature, environment])).digest("hex")
221
+ };
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+ function createGitStateResolver(options = {}) {
227
+ const entries = /* @__PURE__ */ new Map();
228
+ const pending = /* @__PURE__ */ new Map();
229
+ const now = options.nowMs ?? Date.now;
230
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
231
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
232
+ const readDisk = () => {
233
+ if (!options.cacheFile) return;
234
+ try {
235
+ const file = readRegularFile(options.cacheFile, 8 * 1024 * 1024).toString(
236
+ "utf8"
237
+ );
238
+ const parsed = JSON.parse(file);
239
+ if (!isRecord2(parsed) || parsed.version !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > maxEntries)
240
+ return;
241
+ const timestamp = now();
242
+ for (const pair of parsed.entries) {
243
+ if (!Array.isArray(pair) || pair.length !== 2) continue;
244
+ const cwd = pair[0];
245
+ const entry = pair[1];
246
+ if (typeof cwd !== "string" || !isRecord2(entry)) continue;
247
+ const state = parseGitState(entry.state);
248
+ if (state && typeof entry.signature === "string" && typeof entry.expiresAtMs === "number" && Number.isFinite(entry.expiresAtMs) && entry.expiresAtMs > timestamp && state.observedAtMs <= timestamp && entry.expiresAtMs <= state.observedAtMs + ttlMs) {
249
+ entries.set(cwd, {
250
+ signature: entry.signature,
251
+ state,
252
+ expiresAtMs: entry.expiresAtMs
253
+ });
254
+ }
255
+ }
256
+ trim();
257
+ } catch {
258
+ }
259
+ };
260
+ const trim = () => {
261
+ while (entries.size > Math.max(0, maxEntries)) {
262
+ const key = entries.keys().next().value;
263
+ if (key === void 0) break;
264
+ entries.delete(key);
265
+ }
266
+ };
267
+ const cached = (cwd, signature) => {
268
+ if (!signature || ttlMs <= 0) return void 0;
269
+ if (!entries.has(cwd)) readDisk();
270
+ const entry = entries.get(cwd);
271
+ if (!entry || entry.signature !== signature || entry.expiresAtMs <= now() || entry.state.observedAtMs > now())
272
+ return void 0;
273
+ entries.delete(cwd);
274
+ entries.set(cwd, entry);
275
+ return entry.state;
276
+ };
277
+ const finish = (cwd, signature, value) => {
278
+ const state = parseGitState(value && { ...value, observedAtMs: now() });
279
+ if (!state) return null;
280
+ const after = inspectCheckout(cwd)?.signature ?? null;
281
+ if (signature !== after) return null;
282
+ if (signature && maxEntries > 0 && ttlMs > 0) {
283
+ entries.delete(cwd);
284
+ entries.set(cwd, {
285
+ signature,
286
+ state,
287
+ expiresAtMs: state.observedAtMs + ttlMs
288
+ });
289
+ trim();
290
+ if (options.cacheFile) {
291
+ const temporary = `${options.cacheFile}.${process.pid}.${randomUUID()}.tmp`;
292
+ try {
293
+ fs.mkdirSync(path.dirname(options.cacheFile), {
294
+ recursive: true,
295
+ mode: 448
296
+ });
297
+ fs.writeFileSync(
298
+ temporary,
299
+ JSON.stringify({ version: 1, entries: [...entries] }),
300
+ { mode: 384 }
301
+ );
302
+ fs.renameSync(temporary, options.cacheFile);
303
+ } catch {
304
+ try {
305
+ fs.rmSync(temporary, { force: true });
306
+ } catch {
307
+ }
308
+ }
309
+ }
310
+ }
311
+ return state;
312
+ };
313
+ return {
314
+ resolve(cwd, lookup = {}) {
315
+ lookup.signal?.throwIfAborted();
316
+ const snapshot = inspectCheckout(cwd);
317
+ if (!snapshot) return null;
318
+ const { key, signature } = snapshot;
319
+ const hit = cached(key, signature);
320
+ if (hit) return hit;
321
+ try {
322
+ const deadline = performance.now() + (lookup.timeoutMs ?? DEFAULT_TIMEOUT_MS);
323
+ const recipe = readState(key, snapshot.headRef);
324
+ let step = recipe.next();
325
+ while (!step.done) {
326
+ lookup.signal?.throwIfAborted();
327
+ step = recipe.next(runSync(key, step.value, deadline));
328
+ }
329
+ return finish(key, signature, step.value);
330
+ } catch {
331
+ lookup.signal?.throwIfAborted();
332
+ return null;
333
+ }
334
+ },
335
+ async resolveAsync(cwd, lookup = {}) {
336
+ lookup.signal?.throwIfAborted();
337
+ const snapshot = inspectCheckout(cwd);
338
+ if (!snapshot) return null;
339
+ const { key, signature } = snapshot;
340
+ const hit = cached(key, signature);
341
+ if (hit) return hit;
342
+ const existing = !lookup.signal ? pending.get(key) : void 0;
343
+ if (existing) return await existing;
344
+ const work = (async () => {
345
+ try {
346
+ const deadline = performance.now() + (lookup.timeoutMs ?? DEFAULT_TIMEOUT_MS);
347
+ const recipe = readState(key, snapshot.headRef);
348
+ let step = recipe.next();
349
+ while (!step.done)
350
+ step = recipe.next(
351
+ await runAsync(key, step.value, deadline, lookup.signal)
352
+ );
353
+ return finish(key, signature, step.value);
354
+ } catch {
355
+ lookup.signal?.throwIfAborted();
356
+ return null;
357
+ }
358
+ })();
359
+ if (!lookup.signal) pending.set(key, work);
360
+ try {
361
+ return await work;
362
+ } finally {
363
+ if (pending.get(key) === work) pending.delete(key);
364
+ }
365
+ }
366
+ };
367
+ }
368
+ function readRegularFile(filePath, maxBytes) {
369
+ const fd = fs.openSync(
370
+ filePath,
371
+ fs.constants.O_RDONLY | fs.constants.O_NONBLOCK
372
+ );
373
+ try {
374
+ const stat = fs.fstatSync(fd);
375
+ if (!stat.isFile() || stat.size > maxBytes)
376
+ throw new Error("Git metadata is not a bounded regular file");
377
+ const buffer = Buffer.alloc(stat.size + 1);
378
+ let length = 0;
379
+ while (length < buffer.length) {
380
+ const bytes = fs.readSync(
381
+ fd,
382
+ buffer,
383
+ length,
384
+ buffer.length - length,
385
+ null
386
+ );
387
+ if (bytes === 0) break;
388
+ length += bytes;
389
+ }
390
+ if (length > stat.size) throw new Error("Git metadata grew during read");
391
+ return buffer.subarray(0, length);
392
+ } finally {
393
+ fs.closeSync(fd);
394
+ }
395
+ }
396
+ function fileContentIdentity(filePath) {
397
+ try {
398
+ const contents = readRegularFile(filePath, 4 * 1024 * 1024);
399
+ return `sha256:${createHash("sha256").update(contents).digest("hex")}`;
400
+ } catch (error) {
401
+ const code = error && typeof error === "object" && "code" in error ? error.code : void 0;
402
+ return code === "ENOENT" || code === "ENOTDIR" ? "missing" : null;
403
+ }
404
+ }
405
+ function fileStatIdentity(filePath) {
406
+ try {
407
+ const stat = fs.statSync(filePath, { bigint: true });
408
+ if (!stat.isFile() || stat.ino === 0n) return null;
409
+ return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(
410
+ ":"
411
+ );
412
+ } catch (error) {
413
+ const code = error && typeof error === "object" && "code" in error ? error.code : void 0;
414
+ return code === "ENOENT" || code === "ENOTDIR" ? "missing" : null;
415
+ }
416
+ }
417
+ function readSmallText(filePath) {
418
+ try {
419
+ return readRegularFile(filePath, MAX_OUTPUT_BYTES).toString("utf8").trim();
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+ function findGitDirectory(cwd) {
425
+ let current = path.resolve(cwd);
426
+ for (; ; ) {
427
+ const candidate = path.join(current, ".git");
428
+ let stat;
429
+ try {
430
+ stat = fs.statSync(candidate);
431
+ } catch {
432
+ stat = void 0;
433
+ }
434
+ if (stat?.isDirectory()) {
435
+ return { worktreeRoot: current, gitDir: candidate, commonDir: candidate };
436
+ }
437
+ if (stat?.isFile()) {
438
+ const pointer = readSmallText(candidate);
439
+ const match = pointer ? /^gitdir:\s*(.+)$/u.exec(pointer) : null;
440
+ if (!match?.[1]) return null;
441
+ const gitDir = path.resolve(current, match[1]);
442
+ const common = readSmallText(path.join(gitDir, "commondir"));
443
+ return {
444
+ worktreeRoot: current,
445
+ gitDir,
446
+ commonDir: common ? path.resolve(gitDir, common) : gitDir
447
+ };
448
+ }
449
+ const parent = path.dirname(current);
450
+ if (parent === current) return null;
451
+ try {
452
+ if (fs.statSync(current).dev !== fs.statSync(parent).dev) return null;
453
+ } catch {
454
+ return null;
455
+ }
456
+ current = parent;
457
+ }
458
+ }
459
+ function checkoutSignature(layout) {
460
+ const head = readSmallText(path.join(layout.gitDir, "HEAD"));
461
+ const configIdentity = [
462
+ fileStatIdentity(path.join(layout.commonDir, "config")),
463
+ fileStatIdentity(path.join(layout.gitDir, "config.worktree"))
464
+ ];
465
+ if (head === null || configIdentity.some((value) => value === null))
466
+ return { signature: null };
467
+ const parts = [layout.gitDir, layout.commonDir, head, ...configIdentity];
468
+ const ref = /^ref:\s*(.+)$/u.exec(head)?.[1];
469
+ if (ref) {
470
+ const looseRefPath = path.join(layout.commonDir, ref);
471
+ const looseRef = readSmallText(looseRefPath);
472
+ if (/^ref:/u.test(looseRef ?? "")) return { signature: null };
473
+ const packedIdentity = fileStatIdentity(
474
+ path.join(layout.commonDir, "packed-refs")
475
+ );
476
+ const reftableIdentity = fileContentIdentity(
477
+ path.join(layout.commonDir, "reftable", "tables.list")
478
+ );
479
+ const looseIdentity = looseRef ?? fileStatIdentity(looseRefPath);
480
+ if (looseIdentity === null || packedIdentity === null || reftableIdentity === null)
481
+ return { signature: null };
482
+ if (looseRef === null && looseIdentity !== "missing")
483
+ return { signature: null };
484
+ parts.push(looseIdentity, packedIdentity, reftableIdentity);
485
+ return {
486
+ // A fresh unborn checkout has no refs yet. Cache its missing-file
487
+ // identities after Git validates the snapshot; the first ref invalidates it.
488
+ signature: parts.join("\0"),
489
+ ...reftableIdentity === "missing" ? { headRef: ref } : {}
490
+ };
491
+ }
492
+ return {
493
+ signature: parts.join("\0"),
494
+ .../^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(head) ? { headRef: null } : {}
495
+ };
496
+ }
497
+
498
+ export {
499
+ canonicalGitHubRepository,
500
+ parseGitState,
501
+ GIT_STATE_CACHE_FILENAME,
502
+ GIT_STATE_PAYLOAD_KEY,
503
+ capturedGitState,
504
+ createGitStateResolver
505
+ };
@@ -1,6 +1,10 @@
1
1
  import {
2
2
  resolveRepoFromCwdAsync
3
- } from "./chunk-EX5J3MPG.js";
3
+ } from "./chunk-3RKICLMI.js";
4
+ import {
5
+ GIT_STATE_PAYLOAD_KEY,
6
+ capturedGitState
7
+ } from "./chunk-74KOMQZO.js";
4
8
  import {
5
9
  getActiveEnv,
6
10
  getSiteUrlForConvexUrl,
@@ -263,6 +267,7 @@ function boundedPayloadRow(baseRow, payload) {
263
267
  if (initialBytes <= MAX_HOOK_EVENT_ROW_JSON_BYTES) {
264
268
  return { row: baseRow, jsonBytes: initialBytes };
265
269
  }
270
+ const snapshot = capturedGitState(payload);
266
271
  const payloadJson = serializedJson(payload, "Hook event payload");
267
272
  const metadata = {
268
273
  truncated: true,
@@ -280,6 +285,7 @@ function boundedPayloadRow(baseRow, payload) {
280
285
  ...baseRow,
281
286
  payload: {
282
287
  _fmlTruncated: metadata,
288
+ ...snapshot !== void 0 ? { [GIT_STATE_PAYLOAD_KEY]: snapshot } : {},
283
289
  preview: codeUnitPrefix(payloadJson, middle)
284
290
  }
285
291
  };
@@ -374,8 +380,20 @@ async function prepareHookEvent(record, signal) {
374
380
  }
375
381
  async function repositoryForRecord(record, signal) {
376
382
  if (record.target === "pi" && !record.repository) return null;
383
+ const snapshot = capturedGitState(record.payload);
384
+ if (snapshot !== void 0) {
385
+ const repo = record.repository ?? snapshot?.repository;
386
+ return repo ? {
387
+ repo,
388
+ branch: snapshot?.repository === repo ? snapshot.branch : null,
389
+ ...snapshot ? { gitState: snapshot } : {}
390
+ } : null;
391
+ }
377
392
  const liveRepository = record.cwd ? await abortable(resolveRepoFromCwdAsync(record.cwd, signal), signal) : null;
378
- return record.repository ? { repo: record.repository, branch: liveRepository?.branch ?? null } : liveRepository;
393
+ return record.repository ? {
394
+ repo: record.repository,
395
+ branch: liveRepository?.repo === record.repository ? liveRepository.branch : null
396
+ } : liveRepository;
379
397
  }
380
398
  function hookSessionRow(events) {
381
399
  const records = events.map((event) => event.record);
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  resolveRepoFromCwd
3
- } from "./chunk-EX5J3MPG.js";
3
+ } from "./chunk-3RKICLMI.js";
4
4
  import {
5
5
  CONVEX_URL,
6
6
  getActiveEnv,
@@ -0,0 +1,21 @@
1
+ // src/package-root.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ function resolvePackageRoot(startDir = path.dirname(fileURLToPath(import.meta.url))) {
6
+ let directory = startDir;
7
+ while (true) {
8
+ if (fs.existsSync(path.join(directory, "package.json")) && fs.existsSync(path.join(directory, "bin"))) {
9
+ return directory;
10
+ }
11
+ const parent = path.dirname(directory);
12
+ if (parent === directory) {
13
+ throw new Error(`Could not resolve FML package root from ${startDir}`);
14
+ }
15
+ directory = parent;
16
+ }
17
+ }
18
+
19
+ export {
20
+ resolvePackageRoot
21
+ };