@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -0
- package/dist/chunk-3RKICLMI.js +34 -0
- package/dist/{chunk-HVD2GMN3.js → chunk-56ABBKUE.js} +2 -2
- package/dist/chunk-74KOMQZO.js +505 -0
- package/dist/{chunk-4YLCUJ6C.js → chunk-AZSAAPRC.js} +20 -2
- package/dist/{chunk-IUFDFBU7.js → chunk-C4Z6RMQ5.js} +1 -1
- package/dist/chunk-F57IFY3K.js +21 -0
- package/dist/{chunk-5VP4C72Z.js → chunk-K2ANDHRQ.js} +1 -1
- package/dist/{chunk-U7SITLCL.js → chunk-LYKMU7QR.js} +185 -301
- package/dist/{chunk-3VN2SQTP.js → chunk-PVOZYFMP.js} +1 -1
- package/dist/cli.js +10 -9
- package/dist/hook-handler.js +32 -13
- package/dist/mcp/server.js +61 -2
- package/dist/observer-server.js +9 -8
- package/dist/pi/extension.js +788 -343
- package/dist/sync-cycle.js +4 -3
- package/package.json +1 -1
- package/dist/chunk-CSOFP7MT.js +0 -67
- package/dist/chunk-EX5J3MPG.js +0 -142
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
|
+
};
|
|
@@ -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-
|
|
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 ? {
|
|
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);
|
|
@@ -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
|
+
};
|