dorfl 0.10.0 → 0.11.0

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/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. */