@335g/pi-herdr-fleet 0.0.1

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/worktree.ts ADDED
@@ -0,0 +1,573 @@
1
+ /**
2
+ * worktree: create a herdr worktree and carry the untracked development
3
+ * environment into it.
4
+ *
5
+ * A fresh worktree has whatever git tracks and nothing else. `.env` and
6
+ * `.envrc` are gitignored in most repos, so a Pi started in the new worktree
7
+ * dies immediately with "No API key found". The copy below is the difference
8
+ * between a usable worktree and a broken one.
9
+ *
10
+ * What is deliberately *not* done: granting direnv trust that the source never
11
+ * had. `direnv allow` is a trust decision about code the worktree is about to
12
+ * execute, so it is only mirrored, never introduced.
13
+ *
14
+ * The next thing a fresh worktree lacks is `node_modules`. `prepareWorktree`
15
+ * opens a pane in the checkout and installs there, so the install runs in a
16
+ * shell the user can watch instead of inside the main session's process.
17
+ */
18
+
19
+ import { copyFileSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ import { type HerdrClient, type Outcome, err, ok } from "./herdr-client.ts";
23
+
24
+ export interface CommandResult {
25
+ stdout: string;
26
+ stderr: string;
27
+ code: number;
28
+ killed: boolean;
29
+ }
30
+
31
+ /** `pi.exec`, narrowed to what this module needs. */
32
+ export type CommandRunner = (
33
+ command: string,
34
+ args: string[],
35
+ options?: { cwd?: string; timeout?: number },
36
+ ) => Promise<CommandResult>;
37
+
38
+ /** `.env`, `.env.local`, `.envrc` — everything dotenv/direnv may read. */
39
+ const ENV_PREFIX = ".env";
40
+ const PROBE_TIMEOUT_MS = 10_000;
41
+ const DEFAULT_TIMEOUT_MS = 60_000;
42
+ const GIT_TIMEOUT_MS = 15_000;
43
+ /** The socket's own timeout has to outlast herdr's, or every install looks dead. */
44
+ const REQUEST_SLACK_MS = 10_000;
45
+ const AGENT_START_TIMEOUT_MS = 60_000;
46
+ const AGENT_SETTLE_TIMEOUT_MS = 30_000;
47
+ const AGENT_START_ATTEMPTS = 6;
48
+ const AGENT_START_RETRY_MS = 1_000;
49
+
50
+ export interface EnvPropagation {
51
+ /** Copied into the worktree. */
52
+ copied: string[];
53
+ /** Already present there, so left alone. */
54
+ skipped: string[];
55
+ /** True only when a copied `.envrc` was allowed in the new worktree. */
56
+ allowed: boolean;
57
+ warnings: string[];
58
+ }
59
+
60
+ export interface CreatedWorktree {
61
+ /** The worktree's checkout path, as herdr reports it. */
62
+ path: string;
63
+ workspaceId: string;
64
+ /** The workspace's original pane, still a shell at the checkout. */
65
+ rootPaneId?: string;
66
+ branch?: string | null;
67
+ /** The ref the branch was cut from, when it is known. */
68
+ base?: string;
69
+ env: EnvPropagation;
70
+ /** Warnings about the checkout itself, as opposed to its environment. */
71
+ warnings: string[];
72
+ }
73
+
74
+ export interface CreateWorktreeOptions {
75
+ /** The repo (or any directory in it) the worktree is created from. */
76
+ cwd: string;
77
+ branch?: string;
78
+ label?: string;
79
+ /** Branch or commit the new branch is based on. */
80
+ base?: string;
81
+ }
82
+
83
+ /**
84
+ * `herdr worktree create` plus the environment copy. A failure to copy is a
85
+ * warning, not a failure: the worktree itself exists and is usable.
86
+ */
87
+ export async function createWorktree(
88
+ client: HerdrClient,
89
+ run: CommandRunner,
90
+ options: CreateWorktreeOptions,
91
+ ): Promise<Outcome<CreatedWorktree>> {
92
+ const source = await resolveSource(run, options.cwd, options.base);
93
+ if (!source.ok) return source;
94
+ const response = await client.request("worktree.create", {
95
+ cwd: source.value.cwd,
96
+ branch: options.branch,
97
+ label: options.label,
98
+ base: source.value.base ?? options.base,
99
+ // Background work by default; ③ creates worktrees the user is not looking at.
100
+ focus: false,
101
+ });
102
+ if (!response.ok) return response;
103
+
104
+ const created = response.value;
105
+ const path = created?.worktree?.path;
106
+ const workspaceId = created?.workspace?.workspace_id;
107
+ if (typeof path !== "string" || typeof workspaceId !== "string") {
108
+ return err("worktree.create: no worktree path in the response");
109
+ }
110
+
111
+ // The environment comes from the caller's own checkout, not from the main one:
112
+ // what a fork carries over is the environment it was started in.
113
+ const env = await propagateEnv(path, options.cwd, run);
114
+ const rootPaneId = created?.root_pane?.pane_id;
115
+ return ok({
116
+ path,
117
+ workspaceId,
118
+ rootPaneId: typeof rootPaneId === "string" ? rootPaneId : undefined,
119
+ branch: created.worktree.branch ?? undefined,
120
+ // The ref actually branched from: for a linked worktree the caller's own
121
+ // HEAD was pinned to a commit, and that is what a review has to diff against.
122
+ base: source.value.base ?? options.base,
123
+ env,
124
+ warnings: source.value.warnings,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * The main checkout for `cwd`: the first entry of `git worktree list`. herdr
130
+ * refuses a linked worktree as a worktree source, so callers that need "the
131
+ * repository" — the run records in §3c — resolve through here.
132
+ */
133
+ export async function mainCheckout(run: CommandRunner, cwd: string): Promise<string | undefined> {
134
+ const worktrees = await gitWorktrees(run, cwd);
135
+ return worktrees?.[0]?.path;
136
+ }
137
+
138
+ // ------------------------------------------------------- worktree source
139
+
140
+ interface WorktreeSource {
141
+ /** What `worktree.create` is given as its `cwd`. */
142
+ cwd: string;
143
+ /** The commit to branch from: always a commit, never a symbolic ref. */
144
+ base?: string;
145
+ warnings: string[];
146
+ }
147
+
148
+ interface GitWorktree {
149
+ path: string;
150
+ branch?: string;
151
+ }
152
+
153
+ /**
154
+ * Which checkout `worktree.create` may be told to branch from.
155
+ *
156
+ * herdr refuses a linked worktree as the source (`linked_worktree_source`), so a
157
+ * fork started inside one is created from the main checkout instead — that is
158
+ * the first entry of `git worktree list`.
159
+ *
160
+ * The cost of that detour is the fork point: `worktree.create` with no `base`
161
+ * would branch from the main checkout's HEAD, which is not where the caller is.
162
+ * So the caller's HEAD is resolved to a commit and passed as `base`. Uncommitted
163
+ * changes cannot be part of a commit, so they are warned about rather than
164
+ * silently left behind.
165
+ */
166
+ async function resolveSource(run: CommandRunner, cwd: string, base: string | undefined): Promise<Outcome<WorktreeSource>> {
167
+ const worktrees = await gitWorktrees(run, cwd);
168
+ // Not a repository, or a git too old for `--porcelain`: let herdr report it.
169
+ if (!worktrees || worktrees.length === 0) return ok({ cwd, warnings: [] });
170
+
171
+ const main = worktrees[0]!;
172
+ const toplevel = await git(run, ["rev-parse", "--show-toplevel"], cwd);
173
+ const linked = toplevel.code === 0 && !samePath(toplevel.stdout.trim(), main.path);
174
+
175
+ // The fork point is resolved to a commit in the caller's own checkout, for
176
+ // two reasons. `HEAD` names each worktree's own commit, so the main checkout
177
+ // may resolve it differently; and the commit is what §3c records, so a review
178
+ // can diff a nested fork against its own fork point instead of the parent's.
179
+ const pinned = await git(run, ["rev-parse", "--verify", "--quiet", base ?? "HEAD"], cwd);
180
+ const commit = pinned.stdout.trim();
181
+ if (pinned.code !== 0 || commit === "") return err(`worktree: cannot resolve ${base ?? "HEAD"} in ${cwd}`);
182
+
183
+ if (!linked) return ok({ cwd, base: commit, warnings: [] });
184
+
185
+ const warnings = [`created from the main checkout at ${main.path}: herdr cannot branch from a linked worktree`];
186
+ const status = await git(run, ["status", "--porcelain"], cwd);
187
+ if (status.code === 0 && status.stdout.trim() !== "") {
188
+ warnings.push(`uncommitted changes in ${cwd} are not part of the fork`);
189
+ }
190
+ return ok({ cwd: main.path, base: commit, warnings });
191
+ }
192
+
193
+ /** `git worktree list --porcelain`, or nothing when git cannot answer. */
194
+ async function gitWorktrees(run: CommandRunner, cwd: string): Promise<GitWorktree[] | undefined> {
195
+ const result = await git(run, ["worktree", "list", "--porcelain"], cwd);
196
+ if (result.code !== 0) return undefined;
197
+ const worktrees: GitWorktree[] = [];
198
+ for (const line of result.stdout.split("\n")) {
199
+ if (line.startsWith("worktree ")) worktrees.push({ path: line.slice("worktree ".length).trim() });
200
+ else if (line.startsWith("branch ")) worktrees.at(-1)!.branch = line.slice("branch ".length).trim();
201
+ }
202
+ return worktrees;
203
+ }
204
+
205
+ /** Symlinks differ between git and a caller's shell (`/tmp` against `/private/tmp`). */
206
+ function samePath(left: string, right: string): boolean {
207
+ const resolve = (path: string) => {
208
+ try {
209
+ return realpathSync(path);
210
+ } catch {
211
+ return path;
212
+ }
213
+ };
214
+ return resolve(left) === resolve(right);
215
+ }
216
+
217
+ function git(run: CommandRunner, args: string[], cwd: string): Promise<CommandResult> {
218
+ return run("git", args, { cwd, timeout: GIT_TIMEOUT_MS });
219
+ }
220
+
221
+ // ---------------------------------------------------------------- preparation
222
+
223
+ export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
224
+
225
+ /** Lockfiles, most specific first. The first one found decides the installer. */
226
+ const LOCKFILES: [string, PackageManager][] = [
227
+ ["pnpm-lock.yaml", "pnpm"],
228
+ ["yarn.lock", "yarn"],
229
+ ["bun.lockb", "bun"],
230
+ ["bun.lock", "bun"],
231
+ ["package-lock.json", "npm"],
232
+ ];
233
+
234
+ export interface InstallPlan {
235
+ manager: PackageManager;
236
+ lockfile: string;
237
+ command: string;
238
+ }
239
+
240
+ /** Which install this checkout needs, or nothing when it has no lockfile. */
241
+ export function installPlan(root: string): InstallPlan | undefined {
242
+ for (const [lockfile, manager] of LOCKFILES) {
243
+ if (existsSync(join(root, lockfile))) return { manager, lockfile, command: `${manager} install` };
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ /**
249
+ * The completion marker. It cannot be a literal in the script: the pane echoes
250
+ * the command as it is typed, so a literal marker matches before the install has
251
+ * run a single step. `$$` expands to digits in the output while the echoed line
252
+ * keeps `$$`, and only the expanded form matches.
253
+ */
254
+ const INSTALL_MARKER = "FLEET_INSTALL";
255
+ const INSTALL_MARKED = new RegExp(`${INSTALL_MARKER}_[0-9]+=([0-9]+)`);
256
+ const installScript = (command: string) => `fleet_t=${INSTALL_MARKER}_$$; ${command}; printf '%s=%s\\n' "$fleet_t" "$?"`;
257
+
258
+ /** Ten minutes: enough for a real install, short enough to give up eventually. */
259
+ const INSTALL_TIMEOUT_MS = 10 * 60_000;
260
+
261
+ /** The result of the install step, reported whether or not it finished. */
262
+ export interface InstallOutcome {
263
+ command: string;
264
+ lockfile: string;
265
+ ok: boolean;
266
+ /** Why it did not finish, when `ok` is false. */
267
+ error?: string;
268
+ }
269
+
270
+ export interface WorktreePane {
271
+ paneId: string;
272
+ /** Absent when there was nothing to install, or installation was skipped. */
273
+ install?: InstallOutcome;
274
+ }
275
+
276
+ export interface PrepareWorktreeOptions {
277
+ /** The checkout the pane opens in. */
278
+ path: string;
279
+ workspaceId?: string;
280
+ /** The worktree workspace's own pane, so the split cannot land elsewhere. */
281
+ rootPaneId?: string;
282
+ /** False for `--no-install`. */
283
+ install: boolean;
284
+ timeoutMs?: number;
285
+ }
286
+
287
+ /**
288
+ * Open the pane the forked agent will run in, and install there first.
289
+ *
290
+ * The install is a shell command typed into that pane rather than an exec in
291
+ * this process: its output belongs on the screen the user can switch to, and a
292
+ * slow install must not be work the main session is doing.
293
+ */
294
+ export async function prepareWorktree(
295
+ client: HerdrClient,
296
+ options: PrepareWorktreeOptions,
297
+ ): Promise<Outcome<WorktreePane>> {
298
+ const split = await client.request("pane.split", {
299
+ direction: "right",
300
+ cwd: options.path,
301
+ workspace_id: options.workspaceId,
302
+ target_pane_id: options.rootPaneId,
303
+ focus: false,
304
+ });
305
+ if (!split.ok) return split;
306
+ const paneId = split.value?.pane?.pane_id;
307
+ if (typeof paneId !== "string") return err("pane.split: no pane id in the response");
308
+
309
+ const plan = options.install ? installPlan(options.path) : undefined;
310
+ if (!plan) return ok({ paneId });
311
+ return ok({ paneId, install: await installInPane(client, paneId, plan, options.timeoutMs) });
312
+ }
313
+
314
+ /** A failed install is reported, not thrown: the worktree and the pane still exist. */
315
+ async function installInPane(
316
+ client: HerdrClient,
317
+ paneId: string,
318
+ plan: InstallPlan,
319
+ timeoutMs = INSTALL_TIMEOUT_MS,
320
+ ): Promise<InstallOutcome> {
321
+ const result = (ok: boolean, error?: string): InstallOutcome => ({
322
+ command: plan.command,
323
+ lockfile: plan.lockfile,
324
+ ok,
325
+ error,
326
+ });
327
+
328
+ const sent = await client.paneSendInput(paneId, installScript(plan.command));
329
+ if (!sent.ok) return result(false, sent.error);
330
+
331
+ const waited = await client.request(
332
+ "pane.wait_for_output",
333
+ {
334
+ pane_id: paneId,
335
+ source: "recent_unwrapped",
336
+ match: { type: "regex", value: `${INSTALL_MARKER}_[0-9]+=[0-9]+` },
337
+ timeout_ms: timeoutMs,
338
+ },
339
+ timeoutMs + REQUEST_SLACK_MS,
340
+ );
341
+ if (!waited.ok) return result(false, waited.error);
342
+
343
+ // The wait hands back the snapshot that matched, so the exit status is here.
344
+ const exit = INSTALL_MARKED.exec(waited.value?.read?.text ?? "")?.[1];
345
+ if (exit === undefined) return result(false, "pane.wait_for_output: no exit status in the snapshot");
346
+ return result(exit === "0", exit === "0" ? undefined : `${plan.command} exited ${exit}`);
347
+ }
348
+
349
+ /**
350
+ * The agent name for a branch: `[a-z][a-z0-9_-]{0,31}` as herdr requires.
351
+ * Unique agent names are what make `agent get` unambiguous later.
352
+ *
353
+ * `suffix` is what keeps two agents on the same branch apart, as a review of a
354
+ * branch needs: an agent name is taken once, and the second `agent.start` with
355
+ * the same name is refused rather than retried.
356
+ */
357
+ export function agentName(branch: string, suffix = ""): string {
358
+ const slug = branch
359
+ .toLowerCase()
360
+ .replace(/[^a-z0-9_-]+/g, "-")
361
+ .replace(/^[-_]+|[-_]+$/g, "");
362
+ const named = /^[a-z]/.test(slug) ? slug : `fork-${slug}`;
363
+ const tail = suffix === "" ? "" : `-${suffix}`;
364
+ const head = named.slice(0, AGENT_NAME_MAX - tail.length).replace(/[-_]+$/, "");
365
+ return `${head === "" ? "fork" : head}${tail}`;
366
+ }
367
+
368
+ /** herdr's limit, not this extension's choice. */
369
+ const AGENT_NAME_MAX = 32;
370
+
371
+ // ---------------------------------------------------------------- agent start
372
+
373
+ /**
374
+ * `agent.start`, then `agent.wait` for a settled state.
375
+ *
376
+ * Both halves are here because a real pane does not behave the way the API
377
+ * reads. Measured against herdr and Pi:
378
+ *
379
+ * - `agent.start` answers `agent_pane_busy` while the pane's shell is still
380
+ * being recognised — the common case here, because the install just ran in
381
+ * that pane. A retry a second later succeeds, so a busy pane is waited out.
382
+ * - herdr reports the agent ready about three seconds before the agent accepts
383
+ * input. A prompt sent inside that window lands in the editor and is never
384
+ * submitted: the trailing Enter is simply lost. Waiting for a settled state
385
+ * is what makes the seed arrive as a message.
386
+ */
387
+ export async function startAgent(client: HerdrClient, options: StartAgentOptions): Promise<Outcome<void>> {
388
+ const timeoutMs = options.timeoutMs ?? AGENT_START_TIMEOUT_MS;
389
+ let last = "agent.start: no attempt was made";
390
+ for (let attempt = 0; attempt < AGENT_START_ATTEMPTS; attempt += 1) {
391
+ if (attempt > 0) await delay(AGENT_START_RETRY_MS);
392
+ const started = await client.request(
393
+ "agent.start",
394
+ {
395
+ name: options.name,
396
+ kind: "pi",
397
+ pane_id: options.paneId,
398
+ ...(options.args ? { args: options.args } : {}),
399
+ timeout_ms: timeoutMs,
400
+ },
401
+ timeoutMs + REQUEST_SLACK_MS,
402
+ );
403
+ if (started.ok) return waitForAgent(client, options.paneId);
404
+ last = started.error;
405
+ // A name already taken, or a kind herdr does not know, will not change on
406
+ // a retry; only the pane's shell is expected to settle.
407
+ if (started.code !== "agent_pane_busy") return err(last, started.code);
408
+ }
409
+ return err(last);
410
+ }
411
+
412
+ export interface StartAgentOptions {
413
+ paneId: string;
414
+ name: string;
415
+ /**
416
+ * Arguments for the agent itself, passed through to the agent's own CLI.
417
+ * `fleet_review` uses this to load this extension with `-e`, so the reviewer
418
+ * always has `fleet_verdict` even when the extension is not installed.
419
+ */
420
+ args?: string[];
421
+ timeoutMs?: number;
422
+ }
423
+
424
+ async function waitForAgent(client: HerdrClient, paneId: string): Promise<Outcome<void>> {
425
+ const waited = await client.request(
426
+ "agent.wait",
427
+ { target: paneId, until: ["idle", "done", "blocked"], timeout_ms: AGENT_SETTLE_TIMEOUT_MS },
428
+ AGENT_SETTLE_TIMEOUT_MS + REQUEST_SLACK_MS,
429
+ );
430
+ return waited.ok ? ok(undefined) : waited;
431
+ }
432
+
433
+ /**
434
+ * Deliver a seed as one message: the text through `pane.send_input`, then an
435
+ * Enter, retried until the agent actually starts working.
436
+ *
437
+ * A seed is a long, multi-line paste. An Enter sent while the paste is still
438
+ * being ingested is dropped, and the seed then sits in the editor forever — the
439
+ * failure the acceptance test catches as "the seed never reached the forked
440
+ * session". A fixed delay is a guess that a loaded machine breaks, so the
441
+ * retry waits on herdr's own view of the agent instead: once it is working, the
442
+ * seed arrived. `blocked` and `done` count too, because an agent that answered
443
+ * immediately has also received it.
444
+ */
445
+ export async function sendSeed(client: HerdrClient, paneId: string, text: string): Promise<Outcome<void>> {
446
+ const typed = await client.paneSendInput(paneId, text, []);
447
+ if (!typed.ok) return typed;
448
+ let last = "the seed was typed but the agent never started working";
449
+ for (let attempt = 0; attempt < SEED_SUBMIT_ATTEMPTS; attempt += 1) {
450
+ await delay(SEED_SUBMIT_DELAY_MS);
451
+ const pressed = await client.paneSendKeys(paneId, ["enter"]);
452
+ if (!pressed.ok) return pressed;
453
+ const accepted = await client.request(
454
+ "agent.wait",
455
+ { target: paneId, until: ["working", "blocked", "done"], timeout_ms: SEED_ACCEPT_TIMEOUT_MS },
456
+ SEED_ACCEPT_TIMEOUT_MS + REQUEST_SLACK_MS,
457
+ );
458
+ if (accepted.ok) return ok(undefined);
459
+ last = accepted.error;
460
+ }
461
+ return err(last);
462
+ }
463
+
464
+ const SEED_SUBMIT_ATTEMPTS = 5;
465
+ const SEED_SUBMIT_DELAY_MS = 700;
466
+ const SEED_ACCEPT_TIMEOUT_MS = 4_000;
467
+
468
+ function delay(ms: number): Promise<void> {
469
+ return new Promise((resolve) => setTimeout(resolve, ms));
470
+ }
471
+
472
+ /**
473
+ * Copy `.env*` from `sourceRoot` into `worktreePath` without overwriting
474
+ * anything, then mirror the source's direnv trust for `.envrc`.
475
+ */
476
+ export async function propagateEnv(
477
+ worktreePath: string,
478
+ sourceRoot: string,
479
+ run: CommandRunner,
480
+ ): Promise<EnvPropagation> {
481
+ const copied: string[] = [];
482
+ const skipped: string[] = [];
483
+ const warnings: string[] = [];
484
+ const result = (allowed: boolean): EnvPropagation => ({ copied, skipped, allowed, warnings });
485
+
486
+ for (const name of envFiles(sourceRoot)) {
487
+ const destination = join(worktreePath, name);
488
+ // A tracked `.env.example` already exists here; the worktree's own copy wins.
489
+ if (existsSync(destination)) {
490
+ skipped.push(name);
491
+ continue;
492
+ }
493
+ try {
494
+ copyFileSync(join(sourceRoot, name), destination);
495
+ copied.push(name);
496
+ } catch (error) {
497
+ warnings.push(`${name}: ${describe(error)}`);
498
+ }
499
+ }
500
+
501
+ if (!copied.includes(".envrc")) {
502
+ if (!skipped.includes(".envrc")) warnings.push(NOTICES.noEnvrc);
503
+ return result(false);
504
+ }
505
+
506
+ if ((await run("direnv", ["version"], { timeout: PROBE_TIMEOUT_MS })).code !== 0) {
507
+ warnings.push(NOTICES.noDirenv);
508
+ return result(false);
509
+ }
510
+
511
+ const status = await run("direnv", ["status", "--json"], { cwd: sourceRoot, timeout: PROBE_TIMEOUT_MS });
512
+ if (status.code !== 0) {
513
+ warnings.push(NOTICES.noDirenv);
514
+ return result(false);
515
+ }
516
+ if (!sourceIsAllowed(status.stdout)) {
517
+ // The source never trusted this `.envrc`. Allowing it in the new worktree
518
+ // would be a trust grant the user did not make.
519
+ warnings.push(NOTICES.sourceNotAllowed);
520
+ return result(false);
521
+ }
522
+
523
+ const allow = await run("direnv", ["allow", worktreePath], { timeout: DEFAULT_TIMEOUT_MS });
524
+ if (allow.code !== 0) {
525
+ warnings.push(`${NOTICES.allowFailed}: ${firstLine(allow.stderr) ?? `exit ${allow.code}`}`);
526
+ return result(false);
527
+ }
528
+ return result(true);
529
+ }
530
+
531
+ /** `state.foundRC.allowed === 0` is direnv's "this .envrc is allowed". */
532
+ function sourceIsAllowed(statusJson: string): boolean {
533
+ try {
534
+ return JSON.parse(statusJson)?.state?.foundRC?.allowed === 0;
535
+ } catch {
536
+ return false;
537
+ }
538
+ }
539
+
540
+ function envFiles(root: string): string[] {
541
+ let names: string[];
542
+ try {
543
+ names = readdirSync(root);
544
+ } catch {
545
+ return [];
546
+ }
547
+ return names
548
+ .filter((name) => {
549
+ if (!name.startsWith(ENV_PREFIX)) return false;
550
+ try {
551
+ // Follow symlinks: a linked `.env` is common and still needs copying.
552
+ return statSync(join(root, name)).isFile();
553
+ } catch {
554
+ return false;
555
+ }
556
+ })
557
+ .sort();
558
+ }
559
+
560
+ const NOTICES = {
561
+ noEnvrc: "the source has no .envrc, so nothing was allowed",
562
+ noDirenv: "direnv is not usable here; the worktree's .envrc is not allowed",
563
+ sourceNotAllowed: "the source .envrc is not allowed by direnv, so the new one is not either",
564
+ allowFailed: "direnv allow failed",
565
+ };
566
+
567
+ function firstLine(text: string): string | undefined {
568
+ return text.split("\n").find((line) => line.trim() !== "")?.trim();
569
+ }
570
+
571
+ function describe(error: unknown): string {
572
+ return error instanceof Error ? error.message : String(error);
573
+ }