dorfl 0.10.0 → 0.10.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/src/gc.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {existsSync, readdirSync, statSync} from 'node:fs';
1
+ import {existsSync, lstatSync, readdirSync, rmSync, statSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
3
  import {git, run} from './git.js';
4
4
  import {
@@ -261,11 +261,29 @@ export interface GcOptions {
261
261
  env?: NodeJS.ProcessEnv;
262
262
  }
263
263
 
264
+ /**
265
+ * An ORPHAN `<workspacesDir>/work/*` entry `gc` swept: a path git never
266
+ * registered as a worktree and that carries NO job record — a dangling symlink,
267
+ * or a bare directory left by a run that crashed BEFORE `git worktree add`
268
+ * registered it (an early `spawn git ENOENT`). It holds no durable work, so it is
269
+ * always safe to remove; sweeping it self-heals a half-set-up claim instead of
270
+ * leaving it to wedge the next `worktree add` ("already exists") until a human
271
+ * `rm`s it. Reported SEPARATELY from reaped jobs (it was never a real job).
272
+ */
273
+ export interface SweptOrphan {
274
+ /** Absolute path to the orphan entry that was removed. */
275
+ dir: string;
276
+ /** Whether the orphan was a dangling symlink or an un-registered directory. */
277
+ kind: 'dangling-symlink' | 'orphan-dir';
278
+ }
279
+
264
280
  export interface GcResult {
265
281
  /** The worktrees reaped this sweep (provably safe, or forced). */
266
282
  reaped: ReapedJob[];
267
283
  /** The worktrees retained, each with a clear reason. */
268
284
  retained: RetainedJob[];
285
+ /** Record-less orphan `work/*` entries swept (dangling symlinks / orphan dirs). */
286
+ sweptOrphans: SweptOrphan[];
269
287
  }
270
288
 
271
289
  /**
@@ -284,6 +302,11 @@ export function gc(options: GcOptions): GcResult {
284
302
  const reaped: ReapedJob[] = [];
285
303
  const retained: RetainedJob[] = [];
286
304
 
305
+ // First, self-heal any record-less ORPHAN `work/*` entry (a dangling symlink
306
+ // or a dir git never registered) so a half-set-up claim does not linger unseen
307
+ // by the job loop below and wedge the next `worktree add`.
308
+ const sweptOrphans = sweepOrphans(options.workspacesDir, note);
309
+
287
310
  for (const job of discoverJobs(options.workspacesDir)) {
288
311
  const mirrorPath = resolveMirrorPath(options.workspacesDir, job);
289
312
  const result = reapJob({
@@ -310,7 +333,77 @@ export function gc(options: GcOptions): GcResult {
310
333
  note(`Retained ${job.slug}: ${reasonText}.`);
311
334
  }
312
335
 
313
- return {reaped, retained};
336
+ return {reaped, retained, sweptOrphans};
337
+ }
338
+
339
+ /**
340
+ * Sweep record-less ORPHAN entries under `<workspacesDir>/work/*`: a DANGLING
341
+ * SYMLINK (its target gone) or a directory with NO job record at either the
342
+ * sibling or legacy in-tree location. These are the residue of a run that
343
+ * crashed BETWEEN creating the `work/<id>` path and registering it as a git
344
+ * worktree (or writing its record) — e.g. an early `spawn git ENOENT`. They hold
345
+ * no durable work, are invisible to {@link discoverJobs} (which requires a
346
+ * record), and block the next same-id `worktree add`. Removing them is a bounded
347
+ * `rmSync` of ONE path each (never a registered worktree — those carry a record
348
+ * and go through the reap predicate). Best-effort per entry.
349
+ */
350
+ function sweepOrphans(
351
+ workspacesDir: string,
352
+ note: (message: string) => void,
353
+ ): SweptOrphan[] {
354
+ const workDir = join(workspacesDir, 'work');
355
+ if (!existsSync(workDir)) {
356
+ return [];
357
+ }
358
+ const swept: SweptOrphan[] = [];
359
+ for (const entry of readdirSync(workDir)) {
360
+ if (entry.endsWith('.json')) {
361
+ continue; // a sibling record file, not a work-id entry
362
+ }
363
+ const dir = join(workDir, entry);
364
+ let link;
365
+ try {
366
+ link = lstatSync(dir);
367
+ } catch {
368
+ continue; // vanished under us
369
+ }
370
+ const isSymlink = link.isSymbolicLink();
371
+ const targetExists = existsSync(dir); // follows the link; false ⇒ dangling
372
+ const hasRecord =
373
+ existsSync(jobRecordPath(dir)) ||
374
+ (targetExists && existsSync(join(dir, JOB_RECORD_FILENAME)));
375
+ // Orphan iff: a dangling symlink (target gone), OR a record-less entry that
376
+ // is not a live directory git could own (a stray symlink-to-elsewhere, or a
377
+ // dir with no record). A record-bearing entry is a real job → leave it to the
378
+ // reap predicate above.
379
+ const danglingSymlink = isSymlink && !targetExists;
380
+ if (hasRecord) {
381
+ continue;
382
+ }
383
+ if (!danglingSymlink) {
384
+ // A record-less real directory: only sweep it if git does not track it as a
385
+ // worktree here. `discoverJobs` already skips it (no record), and a
386
+ // registered worktree always has our record, so a record-less dir is an
387
+ // orphan. But be conservative: skip a NON-symlink dir that is not empty of
388
+ // a `.git` pointer only when it looks like a crashed pre-register dir.
389
+ if (isSymlink && targetExists) {
390
+ // symlink to a live path but no record → still an orphan link
391
+ } else if (!link.isDirectory()) {
392
+ continue; // not a dir, not a dangling link — leave alone
393
+ } else if (existsSync(join(dir, '.git'))) {
394
+ continue; // has a git pointer but no record: leave for a human (rare)
395
+ }
396
+ }
397
+ try {
398
+ rmSync(dir, {recursive: true, force: true});
399
+ const kind = danglingSymlink ? 'dangling-symlink' : 'orphan-dir';
400
+ swept.push({dir, kind});
401
+ note(`Swept orphan work entry ${entry} (${kind}).`);
402
+ } catch {
403
+ // best-effort: a permission error surfaces on the next add attempt
404
+ }
405
+ }
406
+ return swept;
314
407
  }
315
408
 
316
409
  /**
package/src/git.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {spawnSync, spawn} from 'node:child_process';
2
- import {mkdirSync} from 'node:fs';
3
- import {dirname} from 'node:path';
2
+ import {existsSync, mkdirSync, statSync} from 'node:fs';
3
+ import {delimiter, dirname, isAbsolute, join} from 'node:path';
4
4
 
5
5
  /** Result of running a git (or any) command. */
6
6
  export interface RunResult {
@@ -9,6 +9,176 @@ export interface RunResult {
9
9
  stderr: string;
10
10
  }
11
11
 
12
+ /**
13
+ * The standard system dirs core tools (`git`, `ssh`, `sh`) live in. Some callers
14
+ * launch `dorfl` with a CURATED `PATH` (a version-manager / MCP-agent env that
15
+ * lists only `~/.volta/bin`, `~/.cargo/bin`, `~/.local/bin`, …) that OMITS these.
16
+ * `dorfl` spawns bare `git` (and git in turn may shell out to `ssh`/`sh` for
17
+ * hooks and remote transport), so a `PATH` missing `/usr/bin` produces an opaque
18
+ * mid-run `spawn git ENOENT`. We UNION these onto whatever `PATH` we are given so
19
+ * core tools resolve even under a curated caller `PATH`, without discarding the
20
+ * caller's own entries (a project-pinned `git` earlier on `PATH` still wins).
21
+ */
22
+ const SYSTEM_PATH_DIRS = [
23
+ '/usr/local/bin',
24
+ '/usr/bin',
25
+ '/bin',
26
+ '/usr/sbin',
27
+ '/sbin',
28
+ ];
29
+
30
+ /**
31
+ * `PATH` with {@link SYSTEM_PATH_DIRS} APPENDED (caller entries kept FIRST, so a
32
+ * pinned tool earlier on `PATH` still wins; missing system dirs are added, not
33
+ * substituted). Deduplicated, preserving first-seen order.
34
+ */
35
+ function pathWithSystemDirs(path: string | undefined): string {
36
+ const seen = new Set<string>();
37
+ const out: string[] = [];
38
+ for (const dir of [
39
+ ...(path ? path.split(delimiter) : []),
40
+ ...SYSTEM_PATH_DIRS,
41
+ ]) {
42
+ if (dir !== '' && !seen.has(dir)) {
43
+ seen.add(dir);
44
+ out.push(dir);
45
+ }
46
+ }
47
+ return out.join(delimiter);
48
+ }
49
+
50
+ /**
51
+ * Return `env` with `PATH` hardened via {@link pathWithSystemDirs}, so the
52
+ * spawned tool (and any tool IT shells out to) can find core system binaries
53
+ * even when the caller's `PATH` omitted `/usr/bin`. Returns a COPY — never
54
+ * mutates the caller's env object. `PATH` is looked up case-insensitively so a
55
+ * Windows `Path`/`PATH` split is not silently missed.
56
+ */
57
+ function envWithSystemPath(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
58
+ const key =
59
+ Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH';
60
+ return {...env, [key]: pathWithSystemDirs(env[key])};
61
+ }
62
+
63
+ /**
64
+ * Per-effective-PATH cache of the resolved absolute `git` path. Keyed by the
65
+ * effective PATH (+ any `DORFL_GIT`/`GIT` override) so a DIFFERENT env — e.g. a
66
+ * test's PATH-prepended `git` shim, or a project that pins its own git — is
67
+ * resolved AGAINST ITS OWN PATH, never masked by a stale global memo. Bounded in
68
+ * practice (the process uses a handful of distinct envs) and reset in tests.
69
+ */
70
+ const gitBinaryCache = new Map<string, string>();
71
+
72
+ /** Clear the {@link resolveGitBinary} cache (tests only). */
73
+ export function resetResolvedGitBinaryForTest(): void {
74
+ gitBinaryCache.clear();
75
+ }
76
+
77
+ /**
78
+ * Resolve the `git` executable to an ABSOLUTE path for a given `env`, robustly —
79
+ * so a caller `PATH` that omits `/usr/bin` cannot produce a mid-run `spawn git
80
+ * ENOENT`, WITHOUT masking a git the caller deliberately put earlier on `PATH`
81
+ * (a project pin, or a test shim). Order:
82
+ *
83
+ * 1. an explicit `DORFL_GIT` / `GIT` env override (an absolute path to a git
84
+ * binary), for full operator control;
85
+ * 2. a probe of the env's OWN `PATH` FIRST (so a shim / pinned git earlier on
86
+ * `PATH` wins), then the standard system dirs APPENDED — so `/usr/bin/git`
87
+ * is still found when the caller dropped `/usr/bin`, but never AHEAD of the
88
+ * caller's own entries.
89
+ *
90
+ * Falls back to the bare name `'git'` when nothing resolves (git genuinely
91
+ * absent) so the spawn still runs and produces the diagnostic path. Cached PER
92
+ * effective PATH so distinct envs resolve independently.
93
+ */
94
+ export function resolveGitBinary(env: NodeJS.ProcessEnv = process.env): string {
95
+ const pathValue = pathWithSystemDirs(env.PATH);
96
+ const cacheKey = `${env.DORFL_GIT ?? ''}\u0000${env.GIT ?? ''}\u0000${pathValue}`;
97
+ const cached = gitBinaryCache.get(cacheKey);
98
+ if (cached !== undefined) {
99
+ return cached;
100
+ }
101
+ const resolved = resolveGitBinaryUncached(env, pathValue);
102
+ gitBinaryCache.set(cacheKey, resolved);
103
+ return resolved;
104
+ }
105
+
106
+ function resolveGitBinaryUncached(
107
+ env: NodeJS.ProcessEnv,
108
+ pathValue: string,
109
+ ): string {
110
+ for (const override of [env.DORFL_GIT, env.GIT]) {
111
+ if (override && isAbsolute(override) && isExecutableFile(override)) {
112
+ return override;
113
+ }
114
+ }
115
+ const exe = process.platform === 'win32' ? 'git.exe' : 'git';
116
+ for (const dir of pathValue.split(delimiter)) {
117
+ const candidate = join(dir, exe);
118
+ if (isExecutableFile(candidate)) {
119
+ return candidate;
120
+ }
121
+ }
122
+ return 'git'; // unresolved — let the spawn surface the ENOENT diagnostic
123
+ }
124
+
125
+ /** True iff `path` is a regular file (a symlink target is followed by statSync). */
126
+ function isExecutableFile(path: string): boolean {
127
+ try {
128
+ return existsSync(path) && statSync(path).isFile();
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Resolve a command + harden its spawn env: bare `'git'` is replaced by the
136
+ * absolute {@link resolveGitBinary} path, and the spawn env's `PATH` is unioned
137
+ * with the standard system dirs so git's own child processes (hooks, `ssh`) also
138
+ * resolve. Any OTHER command is passed through unchanged (only its `PATH` is
139
+ * hardened). This is the single choke-point both {@link run} and
140
+ * {@link runAsync} funnel through, so every git spawn in the codebase inherits
141
+ * the robust resolution for free.
142
+ */
143
+ function resolveSpawn(
144
+ command: string,
145
+ env: NodeJS.ProcessEnv,
146
+ ): {command: string; env: NodeJS.ProcessEnv} {
147
+ const hardenedEnv = envWithSystemPath(env);
148
+ const resolved = command === 'git' ? resolveGitBinary(hardenedEnv) : command;
149
+ return {command: resolved, env: hardenedEnv};
150
+ }
151
+
152
+ /**
153
+ * Build the spawn-failure message. A plain `spawn git ENOENT` is OPAQUE (it does
154
+ * not say WHY git was not found); for the `ENOENT` case we surface the effective
155
+ * `PATH` and point at `DORFL_GIT`, so a curated caller `PATH` that omits the
156
+ * system dirs is diagnosable at a glance instead of mid-run. Other spawn errors
157
+ * pass through with the original message.
158
+ */
159
+ function spawnErrorMessage(
160
+ command: string,
161
+ resolved: string,
162
+ env: NodeJS.ProcessEnv,
163
+ err: Error & {code?: string},
164
+ ): string {
165
+ if (err.code === 'ENOENT') {
166
+ const path =
167
+ env[Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH'] ??
168
+ '';
169
+ const hint =
170
+ command === 'git'
171
+ ? `Is git installed and on PATH? Set DORFL_GIT to an absolute git path, or add its dir to PATH. `
172
+ : `Is it installed and on PATH? `;
173
+ return (
174
+ `failed to spawn '${command}': not found (tried '${resolved}'). ` +
175
+ hint +
176
+ `Effective PATH=${path}`
177
+ );
178
+ }
179
+ return `failed to spawn '${command}': ${err.message}`;
180
+ }
181
+
12
182
  /**
13
183
  * Run a command synchronously in `cwd`, capturing output. A non-zero exit is NOT
14
184
  * thrown here — callers inspect `status` (claim.sh's exit codes are meaningful).
@@ -19,15 +189,16 @@ export function run(
19
189
  cwd: string,
20
190
  options: {input?: string; env?: NodeJS.ProcessEnv} = {},
21
191
  ): RunResult {
22
- const result = spawnSync(command, args, {
192
+ const {command: exe, env} = resolveSpawn(command, options.env ?? process.env);
193
+ const result = spawnSync(exe, args, {
23
194
  cwd,
24
195
  encoding: 'utf8',
25
196
  input: options.input,
26
- env: options.env ?? process.env,
197
+ env,
27
198
  maxBuffer: 64 * 1024 * 1024,
28
199
  });
29
200
  if (result.error) {
30
- throw new Error(`failed to spawn '${command}': ${result.error.message}`);
201
+ throw new Error(spawnErrorMessage(command, exe, env, result.error));
31
202
  }
32
203
  return {
33
204
  status: result.status ?? -1,
@@ -47,10 +218,11 @@ export function runAsync(
47
218
  cwd: string,
48
219
  options: {input?: string; env?: NodeJS.ProcessEnv} = {},
49
220
  ): Promise<RunResult> {
221
+ const {command: exe, env} = resolveSpawn(command, options.env ?? process.env);
50
222
  return new Promise((resolvePromise, reject) => {
51
- const child = spawn(command, args, {
223
+ const child = spawn(exe, args, {
52
224
  cwd,
53
- env: options.env ?? process.env,
225
+ env,
54
226
  });
55
227
  let stdout = '';
56
228
  let stderr = '';
@@ -61,7 +233,7 @@ export function runAsync(
61
233
  stderr += chunk.toString('utf8');
62
234
  });
63
235
  child.on('error', (err) =>
64
- reject(new Error(`failed to spawn '${command}': ${err.message}`)),
236
+ reject(new Error(spawnErrorMessage(command, exe, env, err))),
65
237
  );
66
238
  child.on('close', (code) => {
67
239
  resolvePromise({status: code ?? -1, stdout, stderr});
package/src/workspace.ts CHANGED
@@ -1,4 +1,10 @@
1
- import {existsSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
2
8
  import {basename, dirname, join} from 'node:path';
3
9
  import {git} from './git.js';
4
10
  import {
@@ -440,21 +446,80 @@ export function updateJobRecord(
440
446
  return next;
441
447
  }
442
448
 
443
- /** Remove a stale worktree dir / branch registration before re-creating. */
444
- function clearStale(
449
+ /**
450
+ * Clear a stale worktree PATH the contract-safe way, SELF-HEALING a half-set-up
451
+ * claim. `git worktree remove --force` is the normal path, but it REFUSES with
452
+ * "is not a working tree" when `dir` is a leftover that git never registered — a
453
+ * dangling `~/.dorfl/work/<id>` SYMLINK, or a bare directory from a run that
454
+ * crashed AFTER the path appeared but BEFORE `git worktree add` registered it
455
+ * (an early `spawn git ENOENT`, exactly the failure this whole change targets).
456
+ * In that case git's own removal + `worktree prune` both leave the path in place,
457
+ * so the NEXT `git worktree add <dir>` fails "already exists" and the claim is
458
+ * wedged until a human `rm`s it by hand.
459
+ *
460
+ * So: if after the git removal the path STILL EXISTS and is NOT a live
461
+ * registered worktree, fall back to a BOUNDED `rmSync` of that one path. This is
462
+ * ADR §4-safe — we only ever `rm` a path git ITSELF refused to manage and that
463
+ * carries no registered worktree (no durable work to lose): a broken symlink, or
464
+ * an orphaned dir git does not know about. A genuinely registered worktree is
465
+ * removed by git and never reaches the `rmSync`.
466
+ */
467
+ function forceClearWorktreePath(
445
468
  mirrorPath: string,
446
469
  dir: string,
447
- branch: string,
448
470
  env: NodeJS.ProcessEnv | undefined,
449
471
  ): void {
450
- if (existsSync(dir)) {
451
- // Soft-remove: ignore errors (e.g. the dir is not a registered worktree).
472
+ if (!pathPresent(dir)) {
473
+ return;
474
+ }
475
+ // Soft-remove: ignore errors (e.g. the dir is not a registered worktree).
476
+ try {
477
+ git(['worktree', 'remove', '--force', dir], mirrorPath, {env});
478
+ } catch {
479
+ // fall through to the prune + orphan-rm below
480
+ }
481
+ try {
482
+ git(['worktree', 'prune'], mirrorPath, {env});
483
+ } catch {
484
+ // best-effort
485
+ }
486
+ // If the path survived git's removal + prune it is an ORPHAN git will not
487
+ // manage (a dangling symlink / an un-registered dir). Remove that one path so
488
+ // the re-create's `worktree add` is not blocked. Never recurse into a
489
+ // registered worktree (git already handled those).
490
+ if (pathPresent(dir)) {
452
491
  try {
453
- git(['worktree', 'remove', '--force', dir], mirrorPath, {env});
492
+ rmSync(dir, {recursive: true, force: true});
454
493
  } catch {
455
- // fall through to prune below
494
+ // best-effort: a genuine permission error surfaces on the next add
456
495
  }
457
496
  }
497
+ }
498
+
499
+ /**
500
+ * True iff `dir` EXISTS as a path entry — including a DANGLING symlink (whose
501
+ * target is gone). `existsSync` follows symlinks so it returns `false` for a
502
+ * broken one, which would let an orphaned `~/.dorfl/work/<id>` symlink slip past
503
+ * the cleanup; `lstatSync` inspects the LINK itself, so a dangling symlink is
504
+ * still seen (and removed).
505
+ */
506
+ function pathPresent(dir: string): boolean {
507
+ try {
508
+ lstatSync(dir);
509
+ return true;
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
514
+
515
+ /** Remove a stale worktree dir / branch registration before re-creating. */
516
+ function clearStale(
517
+ mirrorPath: string,
518
+ dir: string,
519
+ branch: string,
520
+ env: NodeJS.ProcessEnv | undefined,
521
+ ): void {
522
+ forceClearWorktreePath(mirrorPath, dir, env);
458
523
  pruneAndDropBranch(mirrorPath, branch, env);
459
524
  }
460
525
 
@@ -469,18 +534,7 @@ function clearStaleWorktreeOnly(
469
534
  dir: string,
470
535
  env: NodeJS.ProcessEnv | undefined,
471
536
  ): void {
472
- if (existsSync(dir)) {
473
- try {
474
- git(['worktree', 'remove', '--force', dir], mirrorPath, {env});
475
- } catch {
476
- // fall through to prune below
477
- }
478
- }
479
- try {
480
- git(['worktree', 'prune'], mirrorPath, {env});
481
- } catch {
482
- // best-effort
483
- }
537
+ forceClearWorktreePath(mirrorPath, dir, env);
484
538
  }
485
539
 
486
540
  /** Prune dangling worktree registrations + delete the work branch if present. */