@n-seiji/nuthatch 0.1.4 → 0.1.5

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.
Files changed (4) hide show
  1. package/README.ja.md +36 -6
  2. package/README.md +36 -9
  3. package/dist/cli.js +1415 -461
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2041,8 +2041,11 @@ async function runCommand(cmd, opts) {
2041
2041
  // src/cli-dispatch.ts
2042
2042
  var normalizeCliArgs = (rawArgs, argv0, isCompiledBinary) => isCompiledBinary && rawArgs.length === 1 && rawArgs[0] === argv0 ? [] : rawArgs;
2043
2043
  var isRunningAsCompiledBinary = () => typeof Bun !== "undefined" && Bun.main.startsWith("/$bunfs/");
2044
+ var isUpdateRequest = (rawArgs) => rawArgs[0] === "--update";
2044
2045
  var HELP_FLAGS = new Set(["--help", "-h", "help"]);
2045
- var isHelpRequest = (rawArgs) => rawArgs.length > 0 && HELP_FLAGS.has(rawArgs[0] ?? "");
2046
+ var UPDATE_HELP_FLAGS = new Set(["--help", "-h"]);
2047
+ var isHelpRequest = (rawArgs) => rawArgs.length > 0 && (HELP_FLAGS.has(rawArgs[0] ?? "") || isUpdateRequest(rawArgs) && rawArgs.slice(1).some((token) => UPDATE_HELP_FLAGS.has(token)));
2048
+ var isVersionRequest = (rawArgs) => rawArgs[0] === "--version";
2046
2049
  var dispatchCliArgs = (rawArgs, reservedNames) => {
2047
2050
  const [first, ...rest] = rawArgs;
2048
2051
  if (first === "--") {
@@ -2156,14 +2159,17 @@ var render = (command, result, json) => {
2156
2159
  `);
2157
2160
  }
2158
2161
  };
2162
+ var reportResult = (command, result, json) => {
2163
+ render(command, result, json);
2164
+ process.exitCode = result.exitCode;
2165
+ };
2159
2166
 
2160
2167
  // src/cli-fatal.ts
2161
2168
  var wantsJson = (args) => args.some((arg) => arg === "--json" || arg.startsWith("--json="));
2162
2169
  var reportFatalError = (command, json, error) => {
2163
2170
  const fatal = describeFatalError(error);
2164
2171
  const result = fail(fatal.exitCode, `hop: ${fatal.message}`);
2165
- render(command, result, json);
2166
- process.exitCode = result.exitCode;
2172
+ reportResult(command, result, json);
2167
2173
  };
2168
2174
 
2169
2175
  // src/infra/lock.ts
@@ -2262,17 +2268,27 @@ var acquireRepoLock = async (commonDir, ttlMs = DEFAULT_TTL_MS) => {
2262
2268
  }
2263
2269
  };
2264
2270
  };
2265
- var acquireRepoLockOrRejection = async (commonDir, ttlMs) => {
2271
+ var acquireUnlessHeld = async (commonDir) => {
2266
2272
  try {
2267
- const lock = await acquireRepoLock(commonDir, ttlMs);
2268
- return { ok: true, lock };
2273
+ return await acquireRepoLock(commonDir);
2269
2274
  } catch (error) {
2270
2275
  if (error instanceof LockHeldError) {
2271
- return { ok: false, rejection: fail(EXIT_SAFE_REJECTION, error.message) };
2276
+ return error;
2272
2277
  }
2273
2278
  throw error;
2274
2279
  }
2275
2280
  };
2281
+ var withRepoLock = async (commonDir, mutate) => {
2282
+ const lock = await acquireUnlessHeld(commonDir);
2283
+ if (lock instanceof LockHeldError) {
2284
+ return fail(EXIT_SAFE_REJECTION, lock.message);
2285
+ }
2286
+ try {
2287
+ return await mutate();
2288
+ } finally {
2289
+ await lock.release();
2290
+ }
2291
+ };
2276
2292
 
2277
2293
  // src/infra/repo.ts
2278
2294
  import { basename, dirname, join as join2 } from "node:path";
@@ -2380,6 +2396,21 @@ var parsePorcelain = (output) => {
2380
2396
  return result;
2381
2397
  };
2382
2398
 
2399
+ // src/domain/tracking.ts
2400
+ var resolveTrackingRef = (branch, remotes) => {
2401
+ if (remotes.includes("origin")) {
2402
+ return { kind: "track", ref: `origin/${branch}` };
2403
+ }
2404
+ const [first, second] = remotes;
2405
+ if (first === undefined) {
2406
+ return { kind: "none" };
2407
+ }
2408
+ if (second !== undefined) {
2409
+ return { kind: "ambiguous" };
2410
+ }
2411
+ return { kind: "track", ref: `${first}/${branch}` };
2412
+ };
2413
+
2383
2414
  // src/infra/repo.ts
2384
2415
  var loadRepoContext = async (git, fs, cwd) => {
2385
2416
  const [commonDir, porcelainOut] = await Promise.all([
@@ -2403,8 +2434,34 @@ var loadRepoContext = async (git, fs, cwd) => {
2403
2434
  }));
2404
2435
  return { rootPath, managedRoot, commonDir, worktrees };
2405
2436
  };
2406
- var otherWorktreePaths = (worktrees, path) => nestedWorktrees(worktrees, path).map((wt) => wt.path);
2437
+ var nestedWorktreePaths = (worktrees, path) => nestedWorktrees(worktrees, path).map((wt) => wt.path);
2407
2438
  var nestedWorktrees = (worktrees, path) => worktrees.filter((wt) => wt.path !== path && !wt.prunable && isWithin(path, wt.path));
2439
+ var isWorktreeDirty = (git, worktrees, path) => git.isDirty(path, nestedWorktreePaths(worktrees, path));
2440
+ var worktreeDirtyState = async (git, fs, worktrees, wt) => {
2441
+ if (wt.bare || wt.prunable || !await fs.exists(wt.path)) {
2442
+ return null;
2443
+ }
2444
+ return isWorktreeDirty(git, worktrees, wt.path);
2445
+ };
2446
+ var resolveBranchCheckout = async (git, rootPath, branch, explicitTrack) => {
2447
+ const localBranches = await git.listBranches(rootPath);
2448
+ const createBranch = !localBranches.includes(branch);
2449
+ let track = explicitTrack;
2450
+ if (createBranch && track === undefined) {
2451
+ const remotes = await git.remotesWithBranch(rootPath, branch);
2452
+ const resolution = resolveTrackingRef(branch, remotes);
2453
+ if (resolution.kind === "ambiguous") {
2454
+ return {
2455
+ ok: false,
2456
+ rejection: fail(EXIT_USAGE_ERROR, `Branch "${branch}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`)
2457
+ };
2458
+ }
2459
+ if (resolution.kind === "track") {
2460
+ track = resolution.ref;
2461
+ }
2462
+ }
2463
+ return { ok: true, options: { createBranch, ...track === undefined ? {} : { track } } };
2464
+ };
2408
2465
  var realpathOrRaw = async (fs, path) => {
2409
2466
  try {
2410
2467
  return await fs.realpath(path);
@@ -2415,6 +2472,7 @@ var realpathOrRaw = async (fs, path) => {
2415
2472
 
2416
2473
  // src/commands/root-holder-swap.ts
2417
2474
  var holderRejection = (branch, holderPath, reason) => fail(EXIT_SAFE_REJECTION, `Branch "${branch}" is already checked out at ${holderPath}, and it ${reason}. Not swapping — cd there instead of using hop root.`);
2475
+ var prunableHolderRejection = (branch, holderPath) => fail(EXIT_SAFE_REJECTION, `Branch "${branch}" is held by a stale worktree registration at ${holderPath} (git reports it prunable). Not swapping — if that worktree was moved, re-link it with "git worktree repair <new path>"; otherwise drop the registration first ("hop rm ${branch}" or "git worktree prune").`);
2418
2476
  var multipleHoldersRejection = (branch, holders) => fail(EXIT_SAFE_REJECTION, `Branch "${branch}" is checked out at ${holders.length} worktrees at once (${holders.map((holder) => holder.path).join(", ")}). Not swapping — hop won't guess which one to detach.`);
2419
2477
  var resolveHolderSwap = async ({
2420
2478
  git,
@@ -2454,7 +2512,10 @@ var resolveHolderSwap = async ({
2454
2512
  rejection: holderRejection(target, freshHolder.path, `is locked by git${freshHolder.lockReason === null ? "" : ` (${freshHolder.lockReason})`}`)
2455
2513
  };
2456
2514
  }
2457
- const holderDirty = await git.isDirty(freshHolder.path, otherWorktreePaths(fresh.worktrees, freshHolder.path));
2515
+ if (freshHolder.prunable) {
2516
+ return { rejection: prunableHolderRejection(target, freshHolder.path) };
2517
+ }
2518
+ const holderDirty = await isWorktreeDirty(git, fresh.worktrees, freshHolder.path);
2458
2519
  if (holderDirty) {
2459
2520
  return {
2460
2521
  rejection: holderRejection(target, freshHolder.path, "has uncommitted or untracked changes")
@@ -2467,20 +2528,19 @@ var resolveHolderSwap = async ({
2467
2528
  // src/commands/root.ts
2468
2529
  var root = async (git, fs, options) => {
2469
2530
  const context = await loadRepoContext(git, fs, options.cwd);
2470
- const rootWorktree = context.worktrees.find((wt) => wt.kind === "root");
2471
2531
  if (options.target === undefined) {
2472
2532
  return ok({
2473
2533
  path: context.rootPath,
2474
2534
  data: {
2475
- branch: rootWorktree?.branch ?? null,
2535
+ branch: rootBranchOf(context),
2476
2536
  switched: false,
2477
2537
  detachedHolder: null
2478
2538
  }
2479
2539
  });
2480
2540
  }
2481
- const dirty = await git.isDirty(context.rootPath, otherWorktreePaths(context.worktrees, context.rootPath));
2482
- if (dirty) {
2483
- return fail(EXIT_SAFE_REJECTION, "Root clone has uncommitted or untracked changes. Commit, stash, or discard them before switching.");
2541
+ const dirtyRejection = await dirtyRootRejection(git, context);
2542
+ if (dirtyRejection !== null) {
2543
+ return dirtyRejection;
2484
2544
  }
2485
2545
  if (options.target === "-") {
2486
2546
  return switchAndReport({
@@ -2492,57 +2552,64 @@ var root = async (git, fs, options) => {
2492
2552
  holderPolicy: { allowExternal: true }
2493
2553
  });
2494
2554
  }
2495
- const localBranches = await git.listBranches(context.rootPath);
2496
- const branchExistsLocally = localBranches.includes(options.target);
2497
- const { track: initialTrack } = options;
2498
- let track = initialTrack;
2499
- if (!branchExistsLocally && track === undefined) {
2500
- const remotes = await git.remotesWithBranch(context.rootPath, options.target);
2501
- const [firstRemote] = remotes;
2502
- if (remotes.includes("origin")) {
2503
- track = `origin/${options.target}`;
2504
- } else if (remotes.length === 1 && firstRemote !== undefined) {
2505
- track = `${firstRemote}/${options.target}`;
2506
- } else if (remotes.length > 1) {
2507
- return fail(EXIT_USAGE_ERROR, `Branch "${options.target}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`);
2508
- }
2555
+ const checkout = await resolveBranchCheckout(git, context.rootPath, options.target, options.track);
2556
+ if (!checkout.ok) {
2557
+ return checkout.rejection;
2509
2558
  }
2510
2559
  return switchAndReport({
2511
2560
  git,
2512
2561
  fs,
2513
2562
  context,
2514
2563
  target: options.target,
2515
- switchOptions: {
2516
- createBranch: !branchExistsLocally,
2517
- ...track === undefined ? {} : { track }
2518
- },
2564
+ switchOptions: checkout.options,
2519
2565
  holderPolicy: {
2520
2566
  allowExternal: options.allowExternalHolderSwap ?? true,
2521
2567
  ...options.expectedHolderPath === undefined ? {} : { expectedPath: options.expectedHolderPath }
2522
2568
  }
2523
2569
  });
2524
2570
  };
2525
- var switchAndReport = async ({
2571
+ var rootBranchOf = ({ worktrees }) => worktrees.find((wt) => wt.kind === "root")?.branch ?? null;
2572
+ var dirtyRootRejection = async (git, context) => {
2573
+ const dirty = await isWorktreeDirty(git, context.worktrees, context.rootPath);
2574
+ if (!dirty) {
2575
+ return null;
2576
+ }
2577
+ return fail(EXIT_SAFE_REJECTION, "Root clone has uncommitted or untracked changes. Commit, stash, or discard them before switching.");
2578
+ };
2579
+ var rollBackSwitch = async (git, { rootPath, previousBranch, detachedHolder }) => {
2580
+ const warnings = [];
2581
+ if (previousBranch !== null) {
2582
+ try {
2583
+ await git.switchBranch(rootPath, previousBranch, {});
2584
+ } catch {
2585
+ warnings.push(`Could not restore ${rootPath} to its original branch (${previousBranch})`);
2586
+ }
2587
+ }
2588
+ if (detachedHolder !== null) {
2589
+ try {
2590
+ await git.switchBranch(detachedHolder.path, detachedHolder.branch, {});
2591
+ } catch {
2592
+ warnings.push(`Could not restore ${detachedHolder.path} to its original branch (${detachedHolder.branch}); it remains in detached HEAD`);
2593
+ }
2594
+ }
2595
+ return warnings;
2596
+ };
2597
+ var switchAndReport = ({
2526
2598
  git,
2527
2599
  fs,
2528
2600
  context,
2529
2601
  target,
2530
2602
  switchOptions,
2531
2603
  holderPolicy
2532
- }) => {
2533
- const acquisition = await acquireRepoLockOrRejection(context.commonDir);
2534
- if (!acquisition.ok) {
2535
- return acquisition.rejection;
2536
- }
2537
- const { lock } = acquisition;
2604
+ }) => withRepoLock(context.commonDir, async () => {
2538
2605
  let detachedHolder = null;
2539
2606
  let previousBranch = null;
2540
2607
  try {
2541
2608
  const fresh = await loadRepoContext(git, fs, context.rootPath);
2542
- previousBranch = fresh.worktrees.find((wt) => wt.kind === "root")?.branch ?? null;
2543
- const stillDirty = await git.isDirty(fresh.rootPath, otherWorktreePaths(fresh.worktrees, fresh.rootPath));
2544
- if (stillDirty) {
2545
- return fail(EXIT_SAFE_REJECTION, "Root clone has uncommitted or untracked changes. Commit, stash, or discard them before switching.");
2609
+ previousBranch = rootBranchOf(fresh);
2610
+ const stillDirty = await dirtyRootRejection(git, fresh);
2611
+ if (stillDirty !== null) {
2612
+ return stillDirty;
2546
2613
  }
2547
2614
  const holderSwap = await resolveHolderSwap({
2548
2615
  git,
@@ -2559,7 +2626,7 @@ var switchAndReport = async ({
2559
2626
  let resolvedBranch = target;
2560
2627
  if (target === "-") {
2561
2628
  const afterSwitch = await loadRepoContext(git, fs, fresh.rootPath);
2562
- resolvedBranch = afterSwitch.worktrees.find((wt) => wt.kind === "root")?.branch ?? null;
2629
+ resolvedBranch = rootBranchOf(afterSwitch);
2563
2630
  }
2564
2631
  return ok({
2565
2632
  path: fresh.rootPath,
@@ -2571,26 +2638,14 @@ var switchAndReport = async ({
2571
2638
  warnings: detachedHolder === null ? [] : [`Put ${detachedHolder.path} into detached HEAD`]
2572
2639
  });
2573
2640
  } catch (error) {
2574
- const rollbackWarnings = [];
2575
- if (previousBranch !== null) {
2576
- try {
2577
- await git.switchBranch(context.rootPath, previousBranch, {});
2578
- } catch {
2579
- rollbackWarnings.push(`Could not restore ${context.rootPath} to its original branch (${previousBranch})`);
2580
- }
2581
- }
2582
- if (detachedHolder !== null) {
2583
- try {
2584
- await git.switchBranch(detachedHolder.path, detachedHolder.branch, {});
2585
- } catch {
2586
- rollbackWarnings.push(`Could not restore ${detachedHolder.path} to its original branch (${detachedHolder.branch}); it remains in detached HEAD`);
2587
- }
2588
- }
2589
- return fail(EXIT_SAFE_REJECTION, `Failed to switch root: ${error.message}`, rollbackWarnings);
2590
- } finally {
2591
- await lock.release();
2641
+ const warnings = await rollBackSwitch(git, {
2642
+ rootPath: context.rootPath,
2643
+ previousBranch,
2644
+ detachedHolder
2645
+ });
2646
+ return fail(EXIT_SAFE_REJECTION, `Failed to switch root: ${messageOf(error)}`, warnings);
2592
2647
  }
2593
- };
2648
+ });
2594
2649
 
2595
2650
  // src/cli-root-command.ts
2596
2651
  var ROOT_PREVIOUS_TOKEN = " hop-root-previous ";
@@ -2612,7 +2667,7 @@ var rewriteRootPreviousToken = (args) => {
2612
2667
  return rewritten;
2613
2668
  };
2614
2669
  var resolveRootTarget = (branch) => branch === ROOT_PREVIOUS_TOKEN ? "-" : branch;
2615
- var createRootCommand = (git, fs, applyExitCode) => defineCommand({
2670
+ var createRootCommand = (git, fs) => defineCommand({
2616
2671
  meta: {
2617
2672
  name: "root",
2618
2673
  description: "cd to the root clone, or temporarily switch its branch"
@@ -2636,8 +2691,7 @@ var createRootCommand = (git, fs, applyExitCode) => defineCommand({
2636
2691
  ...target === undefined ? {} : { target: String(target) },
2637
2692
  ...args.track === undefined ? {} : { track: String(args.track) }
2638
2693
  });
2639
- render("root", result, Boolean(args.json));
2640
- applyExitCode(result);
2694
+ reportResult("root", result, Boolean(args.json));
2641
2695
  }
2642
2696
  });
2643
2697
 
@@ -2677,7 +2731,7 @@ var pick = async (git, fs, options) => {
2677
2731
  git.listRemoteBranches(context.rootPath),
2678
2732
  Promise.all(context.worktrees.map(async (worktree) => [
2679
2733
  worktree.path,
2680
- worktree.bare ? null : await git.isDirty(worktree.path, otherWorktreePaths(context.worktrees, worktree.path))
2734
+ await worktreeDirtyState(git, fs, context.worktrees, worktree)
2681
2735
  ]))
2682
2736
  ]);
2683
2737
  const dirtyByPath = new Map(dirtyEntries);
@@ -2688,6 +2742,8 @@ var pick = async (git, fs, options) => {
2688
2742
  // src/commands/rm.ts
2689
2743
  var EXT_DEPRECATION_WARNING = "--ext is deprecated and no longer required: hop rm can now remove external worktrees without it.";
2690
2744
  var lockedRejection = (branch, lockReason) => fail(EXIT_SAFE_REJECTION, `Worktree for "${branch}" is locked by git${lockReason === null ? "" : ` (${lockReason})`}. hop never unlocks worktrees automatically — run "git worktree unlock" yourself first if you're sure.`);
2745
+ var turnedPrunableRejection = (branch) => fail(EXIT_SAFE_REJECTION, `The selected worktree for "${branch}" changed before removal (git now reports it prunable). Refresh the picker and retry.`);
2746
+ var stalePrunableWarning = ({ path, prunableReason }) => `Worktree at ${path} no longer exists${prunableReason === null ? "" : ` (${prunableReason})`}; removed its stale registration — if it had been moved by hand, the moved copy is no longer linked to this repository.`;
2691
2747
  var resolveTarget = (worktrees, branch, expectedPath) => {
2692
2748
  const matches = worktrees.filter((wt) => wt.branch === branch);
2693
2749
  if (expectedPath !== undefined) {
@@ -2714,7 +2770,7 @@ var nestedWorktreeRejection = (branch, targetPath, worktrees) => {
2714
2770
  return fail(EXIT_SAFE_REJECTION, `Worktree for "${branch}" still contains ${nested.length} registered worktree(s) — refusing to remove it, since that would destroy their files too. Remove these first, then retry:
2715
2771
  ${listing}`);
2716
2772
  };
2717
- var targetSafetyRejection = async (git, context, target, options) => {
2773
+ var targetSafetyRejection = async ({ git, fs, context, options }, target) => {
2718
2774
  const nestedRejection = nestedWorktreeRejection(options.branch, target.path, context.worktrees);
2719
2775
  if (nestedRejection !== null) {
2720
2776
  return nestedRejection;
@@ -2722,61 +2778,64 @@ var targetSafetyRejection = async (git, context, target, options) => {
2722
2778
  if (target.locked) {
2723
2779
  return lockedRejection(options.branch, target.lockReason);
2724
2780
  }
2725
- if (!options.force && await git.isDirty(target.path, otherWorktreePaths(context.worktrees, target.path))) {
2781
+ if (target.prunable && options.allowPrunable === false) {
2782
+ return turnedPrunableRejection(options.branch);
2783
+ }
2784
+ if (!options.force && await worktreeDirtyState(git, fs, context.worktrees, target) === true) {
2726
2785
  return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
2727
2786
  }
2728
2787
  return null;
2729
2788
  };
2730
- var rm2 = async (git, fs, options) => {
2731
- const finish = (result) => options.ext ? {
2732
- ...result,
2733
- warnings: [...result.warnings ?? [], EXT_DEPRECATION_WARNING]
2734
- } : result;
2735
- const context = await loadRepoContext(git, fs, options.cwd);
2789
+ var validateRemoval = async (check) => {
2790
+ const { context, options } = check;
2736
2791
  const resolved = resolveTarget(context.worktrees, options.branch, options.expectedPath);
2737
2792
  if (resolved.rejection !== undefined) {
2738
- return finish(resolved.rejection);
2793
+ return { ok: false, rejection: resolved.rejection };
2739
2794
  }
2740
2795
  const { target } = resolved;
2741
2796
  if (target === undefined) {
2742
- return finish(fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`));
2797
+ return {
2798
+ ok: false,
2799
+ rejection: fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`)
2800
+ };
2743
2801
  }
2744
2802
  if (target.kind === "root") {
2745
- return finish(fail(EXIT_USAGE_ERROR, "Cannot remove the root clone."));
2746
- }
2747
- const safetyRejection = await targetSafetyRejection(git, context, target, options);
2748
- if (safetyRejection !== null) {
2749
- return finish(safetyRejection);
2803
+ return { ok: false, rejection: fail(EXIT_USAGE_ERROR, "Cannot remove the root clone.") };
2750
2804
  }
2751
- const acquisition = await acquireRepoLockOrRejection(context.commonDir);
2752
- if (!acquisition.ok) {
2753
- return finish(acquisition.rejection);
2805
+ const rejection = await targetSafetyRejection(check, target);
2806
+ return rejection === null ? { ok: true, target } : { ok: false, rejection };
2807
+ };
2808
+ var withExtWarning = (ext, result) => ext ? {
2809
+ ...result,
2810
+ warnings: [...result.warnings ?? [], EXT_DEPRECATION_WARNING]
2811
+ } : result;
2812
+ var removeWorktree = async (git, fs, options) => {
2813
+ const context = await loadRepoContext(git, fs, options.cwd);
2814
+ const validation = await validateRemoval({ git, fs, context, options });
2815
+ if (!validation.ok) {
2816
+ return validation.rejection;
2754
2817
  }
2755
- const { lock } = acquisition;
2756
- try {
2757
- const fresh = await loadRepoContext(git, fs, options.cwd);
2758
- const freshResolved = resolveTarget(fresh.worktrees, options.branch, options.expectedPath);
2759
- if (freshResolved.rejection !== undefined) {
2760
- return finish(freshResolved.rejection);
2761
- }
2762
- const freshTarget = freshResolved.target;
2763
- if (freshTarget === undefined) {
2764
- return finish(fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`));
2765
- }
2766
- const freshSafetyRejection = await targetSafetyRejection(git, fresh, freshTarget, options);
2767
- if (freshSafetyRejection !== null) {
2768
- return finish(freshSafetyRejection);
2818
+ return withRepoLock(context.commonDir, async () => {
2819
+ try {
2820
+ const fresh = await loadRepoContext(git, fs, options.cwd);
2821
+ const freshValidation = await validateRemoval({ git, fs, context: fresh, options });
2822
+ if (!freshValidation.ok) {
2823
+ return freshValidation.rejection;
2824
+ }
2825
+ const { target } = freshValidation;
2826
+ await git.removeWorktree(context.rootPath, target.path, options.force);
2827
+ return ok({
2828
+ data: { branch: options.branch, path: target.path },
2829
+ ...target.prunable ? { warnings: [stalePrunableWarning(target)] } : {}
2830
+ });
2831
+ } catch (error) {
2832
+ return fail(EXIT_SAFE_REJECTION, `Failed to remove worktree: ${messageOf(error)}`);
2769
2833
  }
2770
- await git.removeWorktree(context.rootPath, freshTarget.path, options.force);
2771
- return finish(ok({ data: { branch: options.branch, path: freshTarget.path } }));
2772
- } catch (error) {
2773
- return finish(fail(EXIT_SAFE_REJECTION, `Failed to remove worktree: ${error.message}`));
2774
- } finally {
2775
- await lock.release();
2776
- }
2834
+ });
2777
2835
  };
2836
+ var rm2 = async (git, fs, options) => withExtWarning(options.ext, await removeWorktree(git, fs, options));
2778
2837
 
2779
- // src/ui/picker.ts
2838
+ // src/ui/picker-render.ts
2780
2839
  import { homedir } from "node:os";
2781
2840
 
2782
2841
  // src/domain/display-width.ts
@@ -2855,6 +2914,19 @@ var padToWidth = (value, width) => {
2855
2914
  return current >= width ? value : value + " ".repeat(width - current);
2856
2915
  };
2857
2916
  var DEFAULT_ELLIPSIS = "…";
2917
+ var takeWithinWidth = (clusters, budget) => {
2918
+ const taken = [];
2919
+ let usedWidth = WIDTH_ZERO;
2920
+ for (const grapheme of clusters) {
2921
+ const nextWidth = usedWidth + graphemeWidth(grapheme);
2922
+ if (nextWidth > budget) {
2923
+ break;
2924
+ }
2925
+ taken.push(grapheme);
2926
+ usedWidth = nextWidth;
2927
+ }
2928
+ return taken;
2929
+ };
2858
2930
  var truncateToWidth = (value, width, ellipsis = DEFAULT_ELLIPSIS) => {
2859
2931
  if (displayWidth(value) <= width) {
2860
2932
  return value;
@@ -2863,17 +2935,7 @@ var truncateToWidth = (value, width, ellipsis = DEFAULT_ELLIPSIS) => {
2863
2935
  if (budget < WIDTH_ZERO) {
2864
2936
  return "";
2865
2937
  }
2866
- let kept = "";
2867
- let usedWidth = WIDTH_ZERO;
2868
- for (const grapheme of graphemes(value)) {
2869
- const nextWidth = usedWidth + graphemeWidth(grapheme);
2870
- if (nextWidth > budget) {
2871
- break;
2872
- }
2873
- kept += grapheme;
2874
- usedWidth = nextWidth;
2875
- }
2876
- return kept + ellipsis;
2938
+ return takeWithinWidth(graphemes(value), budget).join("") + ellipsis;
2877
2939
  };
2878
2940
  var truncateToWidthKeepingTail = (value, width, ellipsis = DEFAULT_ELLIPSIS) => {
2879
2941
  if (displayWidth(value) <= width) {
@@ -2883,21 +2945,14 @@ var truncateToWidthKeepingTail = (value, width, ellipsis = DEFAULT_ELLIPSIS) =>
2883
2945
  if (budget < WIDTH_ZERO) {
2884
2946
  return "";
2885
2947
  }
2886
- let kept = "";
2887
- let usedWidth = WIDTH_ZERO;
2888
- for (const grapheme of graphemes(value).toReversed()) {
2889
- const nextWidth = usedWidth + graphemeWidth(grapheme);
2890
- if (nextWidth > budget) {
2891
- break;
2892
- }
2893
- kept = grapheme + kept;
2894
- usedWidth = nextWidth;
2895
- }
2896
- return ellipsis + kept;
2948
+ const keptFromEnd = takeWithinWidth(graphemes(value).toReversed(), budget);
2949
+ return ellipsis + keptFromEnd.toReversed().join("");
2897
2950
  };
2898
2951
 
2899
- // src/ui/picker-frame.ts
2952
+ // src/ui/ansi.ts
2900
2953
  var ESC = "\x1B";
2954
+
2955
+ // src/ui/picker-frame.ts
2901
2956
  var CURSOR_HOME = `${ESC}[H`;
2902
2957
  var CLEAR_TO_END = `${ESC}[J`;
2903
2958
  var SGR_RESET = `${ESC}[0m`;
@@ -3034,28 +3089,6 @@ var buildFrameLines = (left, right, stacked, colorEnabled) => {
3034
3089
  return buildSideBySide(left, right, colorEnabled);
3035
3090
  };
3036
3091
 
3037
- // src/ui/picker-viewport.ts
3038
- var DEFAULT_TERMINAL_HEIGHT = 24;
3039
- var MIN_VISIBLE_CANDIDATE_ROWS = 3;
3040
- var BASE_CHROME_ROWS = 3;
3041
- var SECTION_HEADER_ROWS = 2;
3042
- var HIDDEN_COUNT_INDICATOR_ROWS = 2;
3043
- var rowBudget = ({ terminalHeight, stackedPanelRows }) => {
3044
- const reserved = BASE_CHROME_ROWS + SECTION_HEADER_ROWS + HIDDEN_COUNT_INDICATOR_ROWS + stackedPanelRows;
3045
- return Math.max(MIN_VISIBLE_CANDIDATE_ROWS, terminalHeight - reserved);
3046
- };
3047
- var computeViewport = (total, selectedIndex, budget) => {
3048
- if (total <= budget) {
3049
- return { start: 0, end: total, hiddenAbove: 0, hiddenBelow: 0 };
3050
- }
3051
- const HALF_DIVISOR = 2;
3052
- const half = Math.floor(budget / HALF_DIVISOR);
3053
- const maxStart = total - budget;
3054
- const start = Math.min(maxStart, Math.max(0, selectedIndex - half));
3055
- const end = start + budget;
3056
- return { start, end, hiddenAbove: start, hiddenBelow: total - end };
3057
- };
3058
-
3059
3092
  // src/ui/picker-layout.ts
3060
3093
  var MAX_PATH_LENGTH = 40;
3061
3094
  var MAX_BRANCH_COLUMN_WIDTH = 24;
@@ -3126,6 +3159,15 @@ var toCandidateRow = (candidate, options) => ({
3126
3159
  kindLabel: candidateKindLabel(candidate).padEnd(KIND_COLUMN_WIDTH, " "),
3127
3160
  pathLabel: candidatePathLabel(candidate, options.homeDir, options.pathMaxLength)
3128
3161
  });
3162
+ var sectionRows = (label, section, entries, rowOptions) => {
3163
+ if (entries.length === 0) {
3164
+ return [];
3165
+ }
3166
+ return [
3167
+ { kind: "header", label },
3168
+ ...entries.map((entry) => toCandidateRow(entry.candidate, { ...rowOptions, index: entry.index, section }))
3169
+ ];
3170
+ };
3129
3171
  var buildDisplayRows = (candidates, homeDir, indexOffset = 0, columnWidths) => {
3130
3172
  const { branchWidth, pathMaxLength } = columnWidths ?? {
3131
3173
  branchWidth: branchColumnWidth(candidates),
@@ -3137,32 +3179,11 @@ var buildDisplayRows = (candidates, homeDir, indexOffset = 0, columnWidths) => {
3137
3179
  }));
3138
3180
  const worktreeEntries = indexed.filter((entry) => isWorktreeCandidate(entry.candidate));
3139
3181
  const branchEntries = indexed.filter((entry) => isCreatableCandidate(entry.candidate));
3140
- const rows = [];
3141
- if (worktreeEntries.length > 0) {
3142
- rows.push({ kind: "header", label: "WORKTREES" });
3143
- for (const entry of worktreeEntries) {
3144
- rows.push(toCandidateRow(entry.candidate, {
3145
- index: entry.index,
3146
- section: "worktree",
3147
- branchWidth,
3148
- pathMaxLength,
3149
- homeDir
3150
- }));
3151
- }
3152
- }
3153
- if (branchEntries.length > 0) {
3154
- rows.push({ kind: "header", label: "BRANCHES — Enter creates a worktree" });
3155
- for (const entry of branchEntries) {
3156
- rows.push(toCandidateRow(entry.candidate, {
3157
- index: entry.index,
3158
- section: "branch",
3159
- branchWidth,
3160
- pathMaxLength,
3161
- homeDir
3162
- }));
3163
- }
3164
- }
3165
- return rows;
3182
+ const rowOptions = { branchWidth, pathMaxLength, homeDir };
3183
+ return [
3184
+ ...sectionRows("WORKTREES", "worktree", worktreeEntries, rowOptions),
3185
+ ...sectionRows("BRANCHES — Enter creates a worktree", "branch", branchEntries, rowOptions)
3186
+ ];
3166
3187
  };
3167
3188
 
3168
3189
  // src/domain/actions.ts
@@ -3183,7 +3204,7 @@ var availableActions = (candidate) => {
3183
3204
  }
3184
3205
  return actions;
3185
3206
  };
3186
- var requiresDeleteConfirmation = (candidate) => candidate.kind === "worktree" && candidate.worktree.kind === "external";
3207
+ var requiresDeleteConfirmation = (candidate) => candidate.kind === "worktree" && (candidate.worktree.kind === "external" || candidate.worktree.prunable);
3187
3208
  var requiresSwitchRootConfirmation = (candidate) => candidate.kind === "worktree" && candidate.worktree.kind === "external";
3188
3209
 
3189
3210
  // src/ui/side-panel.ts
@@ -3239,6 +3260,9 @@ var buildConfirmPanelRows = ({
3239
3260
  if (action === "switchRoot") {
3240
3261
  rows.push([{ text: "This will put it into detached HEAD.", style: "dim" }]);
3241
3262
  }
3263
+ if (action === "delete" && candidate.kind === "worktree" && candidate.worktree.prunable) {
3264
+ rows.push([{ text: "Prunable, not dirty-checked:", style: "dim" }], [{ text: "removes just its registration.", style: "dim" }]);
3265
+ }
3242
3266
  rows.push([{ text: "(y/N)", style: "dim" }]);
3243
3267
  return rows;
3244
3268
  };
@@ -3277,6 +3301,131 @@ var footerHintForWidth = (kind, width) => {
3277
3301
  return truncateToWidth(short, width);
3278
3302
  };
3279
3303
 
3304
+ // src/ui/picker-viewport.ts
3305
+ var DEFAULT_TERMINAL_HEIGHT = 24;
3306
+ var MIN_VISIBLE_CANDIDATE_ROWS = 3;
3307
+ var BASE_CHROME_ROWS = 3;
3308
+ var SECTION_HEADER_ROWS = 2;
3309
+ var HIDDEN_COUNT_INDICATOR_ROWS = 2;
3310
+ var rowBudget = ({ terminalHeight, stackedPanelRows }) => {
3311
+ const reserved = BASE_CHROME_ROWS + SECTION_HEADER_ROWS + HIDDEN_COUNT_INDICATOR_ROWS + stackedPanelRows;
3312
+ return Math.max(MIN_VISIBLE_CANDIDATE_ROWS, terminalHeight - reserved);
3313
+ };
3314
+ var computeViewport = (total, selectedIndex, budget) => {
3315
+ if (total <= budget) {
3316
+ return { start: 0, end: total, hiddenAbove: 0, hiddenBelow: 0 };
3317
+ }
3318
+ const HALF_DIVISOR2 = 2;
3319
+ const half = Math.floor(budget / HALF_DIVISOR2);
3320
+ const maxStart = total - budget;
3321
+ const start = Math.min(maxStart, Math.max(0, selectedIndex - half));
3322
+ const end = start + budget;
3323
+ return { start, end, hiddenAbove: start, hiddenBelow: total - end };
3324
+ };
3325
+
3326
+ // src/ui/picker-render.ts
3327
+ var QUERY_PREFIX = "hop: ";
3328
+ var queryLine = (query, maxWidth) => {
3329
+ const hint = query.length === 0 ? " (type to filter)" : "";
3330
+ const budget = Math.max(0, maxWidth - QUERY_PREFIX.length - hint.length);
3331
+ return [
3332
+ { text: QUERY_PREFIX },
3333
+ { text: truncateToWidthKeepingTail(query, budget), style: "cyan" },
3334
+ { text: hint, style: "dim" }
3335
+ ];
3336
+ };
3337
+ var hiddenCountLine = (count, direction) => {
3338
+ if (count === 0) {
3339
+ return null;
3340
+ }
3341
+ const arrow = direction === "above" ? "↑ " : "↓ ";
3342
+ const word = direction === "above" ? "above" : "below";
3343
+ return [
3344
+ {
3345
+ text: `${arrow}${count} more ${word} (keep typing to narrow down)`,
3346
+ style: "dim"
3347
+ }
3348
+ ];
3349
+ };
3350
+ var candidateRowLine = (row, selected) => {
3351
+ const marker = selected ? "❯ " : " ";
3352
+ const text = `${marker}${row.statusMarker} ${row.branchLabel} ${row.kindLabel} ${row.pathLabel}`;
3353
+ if (selected) {
3354
+ return [{ text, style: "inverse" }];
3355
+ }
3356
+ return [{ text, style: row.section === "branch" ? "dim" : "plain" }];
3357
+ };
3358
+ var displayRowLine = (row, clampedIndex) => row.kind === "header" ? [{ text: row.label, style: "bold-dim" }] : candidateRowLine(row, row.index === clampedIndex);
3359
+ var buildListLines = ({
3360
+ query,
3361
+ maxWidth,
3362
+ rows,
3363
+ clampedIndex,
3364
+ hiddenAbove,
3365
+ hiddenBelow,
3366
+ footerHint
3367
+ }) => {
3368
+ const lines = [queryLine(query, maxWidth)];
3369
+ if (rows.length === 0) {
3370
+ lines.push([{ text: "No matches.", style: "dim" }]);
3371
+ }
3372
+ const above = hiddenCountLine(hiddenAbove, "above");
3373
+ if (above !== null) {
3374
+ lines.push(above);
3375
+ }
3376
+ for (const row of rows) {
3377
+ lines.push(displayRowLine(row, clampedIndex));
3378
+ }
3379
+ const below = hiddenCountLine(hiddenBelow, "below");
3380
+ if (below !== null) {
3381
+ lines.push(below);
3382
+ }
3383
+ lines.push([{ text: `(${LEGEND_TEXT})`, style: "dim" }], [{ text: footerHint, style: "dim" }]);
3384
+ return lines;
3385
+ };
3386
+ var buildSidePanelLines = (mode, panelIndex, busy) => {
3387
+ if (mode.kind === "panel") {
3388
+ return wrapInBox(buildActionPanelRows({
3389
+ candidate: mode.candidate,
3390
+ panelIndex,
3391
+ error: mode.error,
3392
+ busy
3393
+ }), SIDE_PANEL_WIDTH);
3394
+ }
3395
+ if (mode.kind === "confirm") {
3396
+ return wrapInBox(buildConfirmPanelRows({ candidate: mode.candidate, action: mode.action }), SIDE_PANEL_WIDTH);
3397
+ }
3398
+ return null;
3399
+ };
3400
+ var renderPickerFrame = ({
3401
+ snapshot,
3402
+ width,
3403
+ height,
3404
+ colorEnabled: color
3405
+ }) => {
3406
+ const { query, filtered, clampedIndex, mode, panelIndex, busy } = snapshot;
3407
+ const narrow = isNarrowTerminal(width);
3408
+ const right = buildSidePanelLines(mode, panelIndex, busy);
3409
+ const stacked = right !== null && narrow;
3410
+ const listColumnWidth = right !== null && !stacked ? width - GUTTER.length - SIDE_PANEL_WIDTH : width;
3411
+ const stackedPanelRows = stacked && right !== null ? right.length : 0;
3412
+ const budget = rowBudget({ terminalHeight: height, stackedPanelRows });
3413
+ const viewport = computeViewport(filtered.length, clampedIndex, budget);
3414
+ const visible = filtered.slice(viewport.start, viewport.end);
3415
+ const columnWidths = constrainRowColumnWidths(rawBranchColumnWidth(visible), listColumnWidth);
3416
+ const rows = buildDisplayRows(visible, homedir(), viewport.start, columnWidths);
3417
+ const left = buildListLines({
3418
+ query,
3419
+ maxWidth: listColumnWidth,
3420
+ rows,
3421
+ clampedIndex,
3422
+ hiddenAbove: viewport.hiddenAbove,
3423
+ hiddenBelow: viewport.hiddenBelow,
3424
+ footerHint: footerHintForWidth(right === null ? "list" : "panel", listColumnWidth)
3425
+ }).map((line) => truncateLineToWidth(line, listColumnWidth));
3426
+ return buildFrame({ left, right, stacked, colorEnabled: color });
3427
+ };
3428
+
3280
3429
  // src/ui/picker-keys.ts
3281
3430
  var isCtrlJByte = (input) => input === `
3282
3431
  `;
@@ -3363,7 +3512,7 @@ var handleConfirmInput = (input, key, confirmMode, ctx) => {
3363
3512
  };
3364
3513
  var runOrConfirm = (candidate, action, ctx) => {
3365
3514
  if ((action === "delete" || action === "switchRoot") && requiresConfirmation(candidate, action)) {
3366
- ctx.setMode({ kind: "confirm", action, candidate, error: null });
3515
+ ctx.setMode({ kind: "confirm", action, candidate });
3367
3516
  return;
3368
3517
  }
3369
3518
  ctx.runAction(candidate, action);
@@ -3441,7 +3590,7 @@ var handleListInput = (input, key, ctx) => {
3441
3590
  }
3442
3591
  case "openPanel": {
3443
3592
  if (ctx.selectedCandidate !== undefined) {
3444
- ctx.setPanelIndex(0);
3593
+ ctx.setPanelIndex(() => 0);
3445
3594
  ctx.setMode({
3446
3595
  kind: "panel",
3447
3596
  candidate: ctx.selectedCandidate,
@@ -3455,24 +3604,14 @@ var handleListInput = (input, key, ctx) => {
3455
3604
  ctx.setMode({
3456
3605
  kind: "confirm",
3457
3606
  action: "delete",
3458
- candidate: ctx.selectedCandidate,
3459
- error: null
3607
+ candidate: ctx.selectedCandidate
3460
3608
  });
3461
3609
  }
3462
3610
  break;
3463
3611
  }
3464
3612
  case "rootSwitchShortcut": {
3465
3613
  if (ctx.selectedCandidate !== undefined && availableActions(ctx.selectedCandidate).includes("switchRoot")) {
3466
- if (requiresSwitchRootConfirmation(ctx.selectedCandidate)) {
3467
- ctx.setMode({
3468
- kind: "confirm",
3469
- action: "switchRoot",
3470
- candidate: ctx.selectedCandidate,
3471
- error: null
3472
- });
3473
- } else {
3474
- ctx.runAction(ctx.selectedCandidate, "switchRoot");
3475
- }
3614
+ runOrConfirm(ctx.selectedCandidate, "switchRoot", ctx);
3476
3615
  }
3477
3616
  break;
3478
3617
  }
@@ -3503,20 +3642,18 @@ var runMutation = async (callbacks, candidate, action) => {
3503
3642
  if (action === "switchRoot") {
3504
3643
  const result2 = await callbacks.switchRootHere(candidate);
3505
3644
  if (!result2.ok || result2.path === undefined) {
3506
- const transition = panelErrorTransition(candidate, result2.message ?? "Failed to switch root.");
3507
3645
  return {
3508
3646
  type: "patch",
3509
- patch: { mode: transition.mode, panelIndex: transition.panelIndex }
3647
+ patch: panelErrorTransition(candidate, result2.message ?? "Failed to switch root.")
3510
3648
  };
3511
3649
  }
3512
3650
  return { type: "exit", outcome: { type: "path", path: result2.path } };
3513
3651
  }
3514
3652
  const result = await callbacks.deleteWorktree(candidate);
3515
3653
  if (!result.ok) {
3516
- const transition = panelErrorTransition(candidate, result.message ?? "Failed to delete worktree.");
3517
3654
  return {
3518
3655
  type: "patch",
3519
- patch: { mode: transition.mode, panelIndex: transition.panelIndex }
3656
+ patch: panelErrorTransition(candidate, result.message ?? "Failed to delete worktree.")
3520
3657
  };
3521
3658
  }
3522
3659
  const fresh = await callbacks.reloadCandidates();
@@ -3602,10 +3739,7 @@ var createPickerStore = (initialCandidates, callbacks, onExit, onCancel) => {
3602
3739
  const setQuery = (updater) => {
3603
3740
  setState({ query: updater(state.query) });
3604
3741
  };
3605
- const setPanelIndexByValue = (value) => {
3606
- setState({ panelIndex: value });
3607
- };
3608
- const setPanelIndexByUpdater = (updater) => {
3742
+ const setPanelIndex = (updater) => {
3609
3743
  setState({ panelIndex: updater(state.panelIndex) });
3610
3744
  };
3611
3745
  const setMode = (mode) => {
@@ -3624,13 +3758,12 @@ var createPickerStore = (initialCandidates, callbacks, onExit, onCancel) => {
3624
3758
  handlePanelInput(input, key, mode, {
3625
3759
  panelIndex,
3626
3760
  runAction,
3627
- setPanelIndex: setPanelIndexByUpdater,
3761
+ setPanelIndex,
3628
3762
  setMode
3629
3763
  });
3630
3764
  return;
3631
3765
  }
3632
- const filtered = getFiltered(state);
3633
- const clampedIndex = Math.min(state.index, Math.max(filtered.length - 1, 0));
3766
+ const { filtered, clampedIndex } = getSnapshot();
3634
3767
  handleListInput(input, key, {
3635
3768
  selectedCandidate: filtered[clampedIndex],
3636
3769
  filteredLength: filtered.length,
@@ -3638,7 +3771,7 @@ var createPickerStore = (initialCandidates, callbacks, onExit, onCancel) => {
3638
3771
  onCancel: guardedOnCancel,
3639
3772
  setIndex,
3640
3773
  setQuery,
3641
- setPanelIndex: setPanelIndexByValue,
3774
+ setPanelIndex,
3642
3775
  setMode
3643
3776
  });
3644
3777
  };
@@ -3652,12 +3785,10 @@ var createPickerStore = (initialCandidates, callbacks, onExit, onCancel) => {
3652
3785
  };
3653
3786
 
3654
3787
  // src/ui/alt-screen.ts
3655
- var ASCII_ESCAPE_CODE = 27;
3656
- var ESCAPE = String.fromCodePoint(ASCII_ESCAPE_CODE);
3657
- var ENTER_ALT_SCREEN = `${ESCAPE}[?1049h`;
3658
- var LEAVE_ALT_SCREEN = `${ESCAPE}[?1049l`;
3659
- var HIDE_CURSOR = `${ESCAPE}[?25l`;
3660
- var SHOW_CURSOR = `${ESCAPE}[?25h`;
3788
+ var ENTER_ALT_SCREEN = `${ESC}[?1049h`;
3789
+ var LEAVE_ALT_SCREEN = `${ESC}[?1049l`;
3790
+ var HIDE_CURSOR = `${ESC}[?25l`;
3791
+ var SHOW_CURSOR = `${ESC}[?25h`;
3661
3792
  var enterAltScreen = (target) => {
3662
3793
  if (!target.isTTY) {
3663
3794
  return;
@@ -3848,6 +3979,10 @@ var pastedTextToEvents = (text) => [...text].filter((char) => isPrintableChar(ch
3848
3979
  var CSI_INTRODUCER_CODE = "[".codePointAt(0);
3849
3980
  var SS3_INTRODUCER_CODE = "O".codePointAt(0);
3850
3981
  var MAX_PENDING_SEQUENCE_BYTES = 32;
3982
+ var PASTE_END_MARKER = Buffer.concat([
3983
+ Buffer.from([ESC2]),
3984
+ Buffer.from(`[${BRACKETED_PASTE_END}`)
3985
+ ]);
3851
3986
 
3852
3987
  class PickerKeyParser {
3853
3988
  buffer = Buffer.alloc(0);
@@ -3876,19 +4011,18 @@ class PickerKeyParser {
3876
4011
  if (this.buffer.length === 0) {
3877
4012
  return null;
3878
4013
  }
3879
- const endMarker = Buffer.concat([Buffer.from([ESC2]), Buffer.from(`[${BRACKETED_PASTE_END}`)]);
3880
- const markerIndex = this.buffer.indexOf(endMarker);
4014
+ const markerIndex = this.buffer.indexOf(PASTE_END_MARKER);
3881
4015
  if (markerIndex !== -1) {
3882
4016
  const text2 = decodeUtf8(this.buffer.subarray(0, markerIndex));
3883
- this.buffer = this.buffer.subarray(markerIndex + endMarker.length);
4017
+ this.buffer = this.buffer.subarray(markerIndex + PASTE_END_MARKER.length);
3884
4018
  this.inPaste = false;
3885
4019
  return pastedTextToEvents(text2);
3886
4020
  }
3887
- const maxOverlap = Math.min(endMarker.length - 1, this.buffer.length);
4021
+ const maxOverlap = Math.min(PASTE_END_MARKER.length - 1, this.buffer.length);
3888
4022
  let overlap = 0;
3889
4023
  for (let candidate = maxOverlap;candidate > 0; candidate -= 1) {
3890
4024
  const tail = this.buffer.subarray(this.buffer.length - candidate);
3891
- if (tail.equals(endMarker.subarray(0, candidate))) {
4025
+ if (tail.equals(PASTE_END_MARKER.subarray(0, candidate))) {
3892
4026
  overlap = candidate;
3893
4027
  break;
3894
4028
  }
@@ -3985,9 +4119,8 @@ class PickerKeyParser {
3985
4119
  }
3986
4120
 
3987
4121
  // src/ui/terminal-session.ts
3988
- var ESC3 = "\x1B";
3989
- var ENABLE_BRACKETED_PASTE = `${ESC3}[?2004h`;
3990
- var DISABLE_BRACKETED_PASTE = `${ESC3}[?2004l`;
4122
+ var ENABLE_BRACKETED_PASTE = `${ESC}[?2004h`;
4123
+ var DISABLE_BRACKETED_PASTE = `${ESC}[?2004l`;
3991
4124
  var PENDING_ESCAPE_TIMEOUT_MS = 25;
3992
4125
  var EXIT_SIGTERM = 143;
3993
4126
  var EXIT_SIGHUP = 129;
@@ -4152,107 +4285,6 @@ var runTerminalSession = (createHandlers, streams = defaultStreams()) => new Pro
4152
4285
  var DEFAULT_TERMINAL_WIDTH = 80;
4153
4286
  var terminalDimension = (value, fallback) => value !== undefined && value > 0 ? value : fallback;
4154
4287
  var colorEnabled = (stderr) => stderr.isTTY === true && (process.env["NO_COLOR"] ?? "") === "";
4155
- var QUERY_PREFIX = "hop: ";
4156
- var queryLine = (query, maxWidth) => {
4157
- const hint = query.length === 0 ? " (type to filter)" : "";
4158
- const budget = Math.max(0, maxWidth - QUERY_PREFIX.length - hint.length);
4159
- return [
4160
- { text: QUERY_PREFIX },
4161
- { text: truncateToWidthKeepingTail(query, budget), style: "cyan" },
4162
- { text: hint, style: "dim" }
4163
- ];
4164
- };
4165
- var hiddenCountLine = (count, direction) => {
4166
- if (count === 0) {
4167
- return null;
4168
- }
4169
- const arrow = direction === "above" ? "↑ " : "↓ ";
4170
- const word = direction === "above" ? "above" : "below";
4171
- return [
4172
- {
4173
- text: `${arrow}${count} more ${word} (keep typing to narrow down)`,
4174
- style: "dim"
4175
- }
4176
- ];
4177
- };
4178
- var candidateRowLine = (row, selected) => {
4179
- const marker = selected ? "❯ " : " ";
4180
- const text = `${marker}${row.statusMarker} ${row.branchLabel} ${row.kindLabel} ${row.pathLabel}`;
4181
- if (selected) {
4182
- return [{ text, style: "inverse" }];
4183
- }
4184
- return [{ text, style: row.section === "branch" ? "dim" : "plain" }];
4185
- };
4186
- var displayRowLine = (row, clampedIndex) => row.kind === "header" ? [{ text: row.label, style: "bold-dim" }] : candidateRowLine(row, row.index === clampedIndex);
4187
- var buildListLines = ({
4188
- query,
4189
- maxWidth,
4190
- rows,
4191
- clampedIndex,
4192
- hiddenAbove,
4193
- hiddenBelow,
4194
- footerHint
4195
- }) => {
4196
- const lines = [queryLine(query, maxWidth)];
4197
- if (rows.length === 0) {
4198
- lines.push([{ text: "No matches.", style: "dim" }]);
4199
- }
4200
- const above = hiddenCountLine(hiddenAbove, "above");
4201
- if (above !== null) {
4202
- lines.push(above);
4203
- }
4204
- for (const row of rows) {
4205
- lines.push(displayRowLine(row, clampedIndex));
4206
- }
4207
- const below = hiddenCountLine(hiddenBelow, "below");
4208
- if (below !== null) {
4209
- lines.push(below);
4210
- }
4211
- lines.push([{ text: `(${LEGEND_TEXT})`, style: "dim" }], [{ text: footerHint, style: "dim" }]);
4212
- return lines;
4213
- };
4214
- var buildSidePanelLines = (mode, panelIndex, busy) => {
4215
- if (mode.kind === "panel") {
4216
- return wrapInBox(buildActionPanelRows({
4217
- candidate: mode.candidate,
4218
- panelIndex,
4219
- error: mode.error,
4220
- busy
4221
- }), SIDE_PANEL_WIDTH);
4222
- }
4223
- if (mode.kind === "confirm") {
4224
- return wrapInBox(buildConfirmPanelRows({ candidate: mode.candidate, action: mode.action }), SIDE_PANEL_WIDTH);
4225
- }
4226
- return null;
4227
- };
4228
- var renderPickerFrame = ({
4229
- snapshot,
4230
- width,
4231
- height,
4232
- colorEnabled: color
4233
- }) => {
4234
- const { query, filtered, clampedIndex, mode, panelIndex, busy } = snapshot;
4235
- const narrow = isNarrowTerminal(width);
4236
- const right = buildSidePanelLines(mode, panelIndex, busy);
4237
- const stacked = right !== null && narrow;
4238
- const listColumnWidth = right !== null && !stacked ? width - GUTTER.length - SIDE_PANEL_WIDTH : width;
4239
- const stackedPanelRows = stacked && right !== null ? right.length : 0;
4240
- const budget = rowBudget({ terminalHeight: height, stackedPanelRows });
4241
- const viewport = computeViewport(filtered.length, clampedIndex, budget);
4242
- const visible = filtered.slice(viewport.start, viewport.end);
4243
- const columnWidths = constrainRowColumnWidths(rawBranchColumnWidth(visible), listColumnWidth);
4244
- const rows = buildDisplayRows(visible, homedir(), viewport.start, columnWidths);
4245
- const left = buildListLines({
4246
- query,
4247
- maxWidth: listColumnWidth,
4248
- rows,
4249
- clampedIndex,
4250
- hiddenAbove: viewport.hiddenAbove,
4251
- hiddenBelow: viewport.hiddenBelow,
4252
- footerHint: footerHintForWidth(right === null ? "list" : "panel", listColumnWidth)
4253
- }).map((line) => truncateLineToWidth(line, listColumnWidth));
4254
- return buildFrame({ left, right, stacked, colorEnabled: color });
4255
- };
4256
4288
  var isCtrlC = (event2) => event2.key.ctrl && event2.input === "c";
4257
4289
  var runPicker = (candidates, callbacks) => runTerminalSession(({ requestRender, finish }) => {
4258
4290
  const store = createPickerStore(candidates, callbacks, (outcome) => finish(outcome), (reason) => finish({ type: "cancelled", reason }));
@@ -4278,8 +4310,7 @@ var runPicker = (candidates, callbacks) => runTerminalSession(({ requestRender,
4278
4310
  var loadPickCandidates = async (git, fs, json) => {
4279
4311
  const pickResult = await pick(git, fs, { cwd: process.cwd() });
4280
4312
  if (!pickResult.ok) {
4281
- render("pick", pickResult, json);
4282
- process.exitCode = pickResult.exitCode;
4313
+ reportResult("pick", pickResult, json);
4283
4314
  return null;
4284
4315
  }
4285
4316
  return pickResult.data?.candidates ?? [];
@@ -4293,6 +4324,7 @@ var deleteWorktree = async (git, fs, candidate) => {
4293
4324
  cwd: process.cwd(),
4294
4325
  branch,
4295
4326
  ...candidate.kind === "worktree" ? { expectedPath: candidate.worktree.path } : {},
4327
+ allowPrunable: candidate.kind === "worktree" && candidate.worktree.prunable,
4296
4328
  force: false,
4297
4329
  ext: false
4298
4330
  });
@@ -4343,15 +4375,969 @@ var renderSwitchRootOutcome = (path, outcome, json) => {
4343
4375
  const branch = outcome?.branch ?? null;
4344
4376
  const detachedHolder = outcome?.detachedHolder ?? null;
4345
4377
  const warnings = outcome?.warnings ?? [];
4346
- if (json) {
4347
- render("root", ok({ path, data: { branch, switched: true, detachedHolder }, warnings }), true);
4348
- return;
4378
+ render("root", ok({ path, data: { branch, switched: true, detachedHolder }, warnings }), json);
4379
+ };
4380
+
4381
+ // src/domain/install-roots.ts
4382
+ var MANAGER_ROOTS = [
4383
+ {
4384
+ markers: ["/Cellar/", "/Caskroom/"],
4385
+ manager: "Homebrew",
4386
+ update: '"brew upgrade"',
4387
+ ownsNpmPrefix: true
4388
+ },
4389
+ {
4390
+ markers: ["/nix/store/"],
4391
+ manager: "Nix",
4392
+ update: "Nix (the store is read-only)",
4393
+ ownsNpmPrefix: true
4394
+ },
4395
+ {
4396
+ markers: ["/aquaproj-aqua/", "/aqua/pkgs/"],
4397
+ manager: "aqua",
4398
+ update: "aqua",
4399
+ ownsNpmPrefix: false
4400
+ },
4401
+ { markers: ["/.proto/tools/"], manager: "proto", update: "proto", ownsNpmPrefix: false }
4402
+ ];
4403
+ var reasonWithin = (path, roots) => {
4404
+ const root2 = roots.find(({ markers }) => markers.some((marker) => path.includes(marker)));
4405
+ return root2 === undefined ? null : `installed by ${root2.manager} (${path}); update it with ${root2.update}`;
4406
+ };
4407
+ var managerRootReason = (binaryPath) => reasonWithin(binaryPath, MANAGER_ROOTS);
4408
+ var managerNpmPrefixReason = (prefix) => reasonWithin(prefix, MANAGER_ROOTS.filter(({ ownsNpmPrefix }) => ownsNpmPrefix));
4409
+
4410
+ // src/domain/self-update.ts
4411
+ var NPM_PACKAGE = "@n-seiji/nuthatch";
4412
+ var NPM_PACKAGE_LATEST = `${NPM_PACKAGE}@latest`;
4413
+ var GITHUB_REPO = "n-seiji/nuthatch";
4414
+ var GITHUB_LATEST_RELEASE_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
4415
+ var NPM_LATEST_URL = `https://registry.npmjs.org/${NPM_PACKAGE.replace("/", "%2F")}/latest`;
4416
+ var releaseAssetUrl = (version, assetName) => `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${assetName}`;
4417
+ var VERSION_PATTERN = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$/u;
4418
+ var TAG_PATTERN = /^v(?<version>\d+\.\d+\.\d+)$/u;
4419
+ var SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/iu;
4420
+ var PATH_SEPARATOR2 = ":";
4421
+ var parseVersion = (text) => {
4422
+ const groups = VERSION_PATTERN.exec(text)?.groups;
4423
+ if (groups?.major === undefined || groups.minor === undefined || groups.patch === undefined) {
4424
+ throw new Error(`unrecognized version "${text}" (expected x.y.z)`);
4425
+ }
4426
+ return [Number(groups.major), Number(groups.minor), Number(groups.patch)];
4427
+ };
4428
+ var compareVersions = (a2, b2) => {
4429
+ const [aMajor, aMinor, aPatch] = parseVersion(a2);
4430
+ const [bMajor, bMinor, bPatch] = parseVersion(b2);
4431
+ return aMajor - bMajor || aMinor - bMinor || aPatch - bPatch;
4432
+ };
4433
+ var requireVersion = (text) => {
4434
+ parseVersion(text);
4435
+ return text;
4436
+ };
4437
+ var versionFromTag = (tag) => {
4438
+ const version = TAG_PATTERN.exec(tag)?.groups?.version;
4439
+ if (version === undefined) {
4440
+ throw new Error(`unrecognized release tag "${tag}" (expected vX.Y.Z)`);
4441
+ }
4442
+ return version;
4443
+ };
4444
+ var parseChecksumFile = (text, assetName) => {
4445
+ const [digest, file] = text.trim().split(/\s+/u);
4446
+ if (digest === undefined || !SHA256_HEX_PATTERN.test(digest)) {
4447
+ return { ok: false, reason: "is malformed" };
4448
+ }
4449
+ const named = file?.replace(/^\*/u, "");
4450
+ if (named !== undefined && named !== assetName && !named.endsWith(`/${assetName}`)) {
4451
+ return { ok: false, reason: `is for "${named}", not ${assetName}` };
4452
+ }
4453
+ return { ok: true, digest: digest.toLowerCase() };
4454
+ };
4455
+ var pathExecutableCandidates = (name, pathEnv, preferredDir = null) => {
4456
+ const dirs = [
4457
+ ...preferredDir === null ? [] : [preferredDir],
4458
+ ...(pathEnv ?? "").split(PATH_SEPARATOR2)
4459
+ ].map((entry) => entry.trim()).filter((entry) => entry.startsWith("/"));
4460
+ return [...new Set(dirs.map((dir) => `${dir.endsWith("/") ? dir.slice(0, -1) : dir}/${name}`))];
4461
+ };
4462
+ var isWriteDenied = (error) => error instanceof Error && ("code" in error) && (error.code === "EACCES" || error.code === "EPERM" || error.code === "EROFS");
4463
+
4464
+ // src/domain/install-script.ts
4465
+ var NPX_MARKER = "/_npx/";
4466
+ var BUNX_MARKER = "/bunx-";
4467
+ var BUN_GLOBAL_MARKER = "/.bun/install/global/node_modules/";
4468
+ var NPM_GLOBAL_MARKER = `/lib/node_modules/${NPM_PACKAGE}/`;
4469
+ var NODE_MODULES_MARKER = `/node_modules/${NPM_PACKAGE}/`;
4470
+ var PNPM_GLOBAL_MARKER = "/pnpm/global/";
4471
+ var YARN_GLOBAL_MARKER = "/yarn/global/";
4472
+ var unsupported = (reason) => ({ kind: "unsupported", reason });
4473
+ var npmGlobalPrefixOf = (scriptPath) => {
4474
+ const index = scriptPath.lastIndexOf(NPM_GLOBAL_MARKER);
4475
+ return index > 0 ? scriptPath.slice(0, index) : null;
4476
+ };
4477
+ var otherNodeModulesReason = (scriptPath) => {
4478
+ if (scriptPath.includes(PNPM_GLOBAL_MARKER)) {
4479
+ return `installed globally with pnpm, which hop does not drive; run "pnpm add -g ${NPM_PACKAGE_LATEST}" yourself`;
4349
4480
  }
4350
- process.stdout.write(`${path}
4351
- `);
4352
- for (const warning of warnings) {
4353
- process.stderr.write(`warning: ${warning}
4354
- `);
4481
+ if (scriptPath.includes(YARN_GLOBAL_MARKER)) {
4482
+ return `installed globally with yarn, which hop does not drive; run "yarn global add ${NPM_PACKAGE_LATEST}" yourself`;
4483
+ }
4484
+ return "installed as a project dependency (a local node_modules); update it in that project";
4485
+ };
4486
+ var detectScriptInstall = (scriptPath, npmGlobalPrefix) => {
4487
+ if (scriptPath.includes(NPX_MARKER)) {
4488
+ return unsupported("running through npx, which already fetches the published version on every run");
4489
+ }
4490
+ if (scriptPath.includes(BUNX_MARKER)) {
4491
+ return unsupported("running through bunx, which already fetches the published version on every run");
4492
+ }
4493
+ if (scriptPath.includes(BUN_GLOBAL_MARKER)) {
4494
+ return { kind: "bun" };
4495
+ }
4496
+ if (npmGlobalPrefix !== null) {
4497
+ const managedBy = managerNpmPrefixReason(npmGlobalPrefix);
4498
+ return managedBy === null ? { kind: "npm", prefix: npmGlobalPrefix } : unsupported(managedBy);
4499
+ }
4500
+ if (scriptPath.includes(NODE_MODULES_MARKER)) {
4501
+ return unsupported(otherNodeModulesReason(scriptPath));
4502
+ }
4503
+ return unsupported("running from a source checkout; update it with git (git pull)");
4504
+ };
4505
+
4506
+ // src/domain/posix-path.ts
4507
+ var withoutTrailingSlash = (path) => path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
4508
+ var parentDir = (path) => {
4509
+ const trimmed = withoutTrailingSlash(path);
4510
+ const index = trimmed.lastIndexOf("/");
4511
+ if (index === -1) {
4512
+ return ".";
4513
+ }
4514
+ return index === 0 ? "/" : trimmed.slice(0, index);
4515
+ };
4516
+ var baseName = (path) => {
4517
+ const trimmed = withoutTrailingSlash(path);
4518
+ return trimmed.slice(trimmed.lastIndexOf("/") + 1);
4519
+ };
4520
+
4521
+ // src/domain/mise-tool.ts
4522
+ var MISE_WALK_LIMIT = 8;
4523
+ var HOP_BACKEND_PATTERN = /^[a-z0-9_-]+:@?n-seiji\/nuthatch$/iu;
4524
+ var HOP_TOOL_DIR_PATTERN = /(?:^|-)n-seiji-nuthatch$/iu;
4525
+ var parseMiseBackendToml = (text) => {
4526
+ const match = /^\s*full\s*=\s*"(?<tool>[^"]+)"\s*$/mu.exec(text);
4527
+ return match?.groups?.tool ?? null;
4528
+ };
4529
+ var parseLegacyMiseBackend = (text) => {
4530
+ const lines = text.split(`
4531
+ `).map((line) => line.trim()).filter((line) => line.length > 0);
4532
+ return lines.at(-1) ?? null;
4533
+ };
4534
+ var withoutToolOptions = (backend) => backend.replace(/\[[^\]]*\]$/u, "");
4535
+ var isHopBackend = (backend) => HOP_BACKEND_PATTERN.test(backend);
4536
+ var isHopToolDirName = (name) => HOP_TOOL_DIR_PATTERN.test(name);
4537
+ var ancestorDirs = (path, limit) => {
4538
+ const dirs = [];
4539
+ let dir = parentDir(path);
4540
+ while (dirs.length < limit && dir !== "/" && dir !== ".") {
4541
+ dirs.push(dir);
4542
+ dir = parentDir(dir);
4543
+ }
4544
+ return dirs;
4545
+ };
4546
+ var miseToolDir = (path) => ancestorDirs(path, MISE_WALK_LIMIT).find((dir) => baseName(parentDir(dir)) === "installs") ?? null;
4547
+
4548
+ // src/domain/install-method.ts
4549
+ var RELEASE_ASSETS = new Map([
4550
+ ["darwin-arm64", "hop-darwin-arm64"],
4551
+ ["darwin-x64", "hop-darwin-x64"],
4552
+ ["linux-x64", "hop-linux-x64"]
4553
+ ]);
4554
+ var releaseAssetName = (platform2, arch) => RELEASE_ASSETS.get(`${platform2}-${arch}`) ?? null;
4555
+ var GITHUB_BACKEND_PREFIXES = ["github:", "ubi:", "aqua:"];
4556
+ var NPM_BACKEND_PREFIX = "npm:";
4557
+ var unsupported2 = (reason) => ({
4558
+ kind: "unsupported",
4559
+ reason
4560
+ });
4561
+ var miseChannel = (tool) => {
4562
+ if (tool.startsWith(NPM_BACKEND_PREFIX)) {
4563
+ return "npm";
4564
+ }
4565
+ return GITHUB_BACKEND_PREFIXES.some((prefix) => tool.startsWith(prefix)) ? "github" : null;
4566
+ };
4567
+ var insideVersionManager = (dir, marker) => unsupported2(`installed under a version manager's install dir (${dir}) ${marker}; update it with the tool that installed it`);
4568
+ var detectUnmarkedToolDir = (dir, compiled) => {
4569
+ if (isHopToolDirName(baseName(dir))) {
4570
+ return unsupported2(`installed by mise, but its backend is unknown (no readable marker in ${dir}); run "mise upgrade" yourself`);
4571
+ }
4572
+ return compiled ? insideVersionManager(dir, "without a readable mise marker") : null;
4573
+ };
4574
+ var detectMise = ({ mise, compiled }) => {
4575
+ if (mise === null) {
4576
+ return null;
4577
+ }
4578
+ if (mise.backend === null) {
4579
+ return detectUnmarkedToolDir(mise.dir, compiled);
4580
+ }
4581
+ const tool = withoutToolOptions(mise.backend);
4582
+ if (!isHopBackend(tool)) {
4583
+ return compiled ? insideVersionManager(mise.dir, `whose marker names "${tool}", not hop`) : null;
4584
+ }
4585
+ const channel = miseChannel(tool);
4586
+ return channel === null ? unsupported2(`mise installed hop through "${tool}", which is neither a GitHub nor an npm backend; run "mise upgrade ${tool}" yourself`) : { kind: "mise", tool, channel };
4587
+ };
4588
+ var detectStandalone = (facts) => {
4589
+ const { executablePath, platform: platform2, arch } = facts;
4590
+ if (executablePath === null) {
4591
+ return unsupported2("cannot tell where the hop binary is installed");
4592
+ }
4593
+ const managedBy = managerRootReason(executablePath);
4594
+ if (managedBy !== null) {
4595
+ return unsupported2(managedBy);
4596
+ }
4597
+ const assetName = releaseAssetName(platform2, arch);
4598
+ if (assetName === null) {
4599
+ return unsupported2(`there is no prebuilt binary for ${platform2}-${arch}; install with npm instead (npm i -g ${NPM_PACKAGE})`);
4600
+ }
4601
+ return {
4602
+ kind: "standalone",
4603
+ binaryPath: executablePath,
4604
+ installDir: parentDir(executablePath),
4605
+ assetName
4606
+ };
4607
+ };
4608
+ var detectInstallMethod = (facts) => {
4609
+ const mise = detectMise(facts);
4610
+ if (mise !== null) {
4611
+ return mise;
4612
+ }
4613
+ return facts.compiled ? detectStandalone(facts) : detectScriptInstall(facts.scriptPath ?? "", facts.npmGlobalPrefix);
4614
+ };
4615
+ var latestChannel = (method) => {
4616
+ if (method.kind === "mise") {
4617
+ return method.channel;
4618
+ }
4619
+ return method.kind === "standalone" ? "github" : "npm";
4620
+ };
4621
+ var upgradeCommand = (method) => {
4622
+ if (method.kind === "mise") {
4623
+ return ["mise", "upgrade", method.tool];
4624
+ }
4625
+ return method.kind === "npm" ? ["npm", "install", "-g", "--prefix", method.prefix, NPM_PACKAGE_LATEST] : ["bun", "add", "-g", NPM_PACKAGE_LATEST];
4626
+ };
4627
+ var miseDryRunCommand = (method) => [
4628
+ "mise",
4629
+ "upgrade",
4630
+ "--dry-run-code",
4631
+ method.tool
4632
+ ];
4633
+ var preferredProgramDir = (method) => method.kind === "npm" ? `${method.prefix}/bin` : null;
4634
+
4635
+ // src/commands/self-update-attempt.ts
4636
+ var attempt = async (run) => {
4637
+ try {
4638
+ return { ok: true, value: await run() };
4639
+ } catch (error) {
4640
+ return { ok: false, error };
4641
+ }
4642
+ };
4643
+
4644
+ // src/commands/self-update-delegated.ts
4645
+ var MISE_WOULD_NOT_UPGRADE = 0;
4646
+ var runLogged = (port, term, executable, [, ...args]) => {
4647
+ const command = [executable, ...args];
4648
+ term.logStderr(`Running: ${command.join(" ")}`);
4649
+ return attempt(() => port.runCommand(command));
4650
+ };
4651
+ var miseNothingToUpgradeWarning = ({ tool }, { current, latest }) => [
4652
+ `mise has nothing to upgrade for ${tool}, so hop stays at ${current} although ${latest} is the latest release.`,
4653
+ "Likely causes: the version is pinned in your global mise config, a minimum_release_age setting holds the release back,",
4654
+ `or ${tool} is not in your global mise config (hop runs mise from your home directory, so a project's mise.toml does not count).`,
4655
+ `Run "mise upgrade --dry-run ${tool}" from your home directory to see what mise decides.`
4656
+ ].join(" ");
4657
+ var runDelegated = async (port, term, method, base) => {
4658
+ const argv2 = upgradeCommand(method);
4659
+ const [program] = argv2;
4660
+ const command = argv2.join(" ");
4661
+ const executable = await port.resolveExecutable(program, preferredProgramDir(method));
4662
+ if (executable === null) {
4663
+ return fail(EXIT_GENERAL_ERROR, `Cannot find "${program}" on PATH. Run it yourself: ${command}`);
4664
+ }
4665
+ if (method.kind === "mise") {
4666
+ const question = miseDryRunCommand(method);
4667
+ const answer = await runLogged(port, term, executable, question);
4668
+ if (!answer.ok) {
4669
+ return fail(EXIT_GENERAL_ERROR, `Failed to run ${question.join(" ")}: ${messageOf(answer.error)}`);
4670
+ }
4671
+ if (answer.value === MISE_WOULD_NOT_UPGRADE) {
4672
+ return ok({ data: base, warnings: [miseNothingToUpgradeWarning(method, base)] });
4673
+ }
4674
+ }
4675
+ const run = await runLogged(port, term, executable, argv2);
4676
+ if (!run.ok) {
4677
+ return fail(EXIT_GENERAL_ERROR, `Failed to run ${command}: ${messageOf(run.error)}`);
4678
+ }
4679
+ if (run.value !== 0) {
4680
+ return fail(EXIT_GENERAL_ERROR, `${command} exited with code ${run.value}.`);
4681
+ }
4682
+ term.logStderr(`${command} finished; run "hop --version" to see which version you have now.`);
4683
+ return ok({ data: { ...base, action: "delegated", command: [...argv2] } });
4684
+ };
4685
+
4686
+ // src/commands/self-update-standalone.ts
4687
+ var checksumRejection = (port, assetName, checksumText, bytes) => {
4688
+ const expected = parseChecksumFile(checksumText, assetName);
4689
+ if (!expected.ok) {
4690
+ return fail(EXIT_SAFE_REJECTION, `The checksum file ${assetName}.sha256 ${expected.reason}; refusing to install an unverified binary.`);
4691
+ }
4692
+ const actual = port.sha256Hex(bytes);
4693
+ if (actual !== expected.digest) {
4694
+ return fail(EXIT_SAFE_REJECTION, `Checksum mismatch for ${assetName} (expected ${expected.digest}, got ${actual}); refusing to install it. Nothing was changed.`);
4695
+ }
4696
+ return null;
4697
+ };
4698
+ var replaceFailureMessage = (method, error) => isWriteDenied(error) ? `Cannot write ${method.installDir}; reinstall hop somewhere writable (install.sh uses ~/.local/bin).` : `Failed to replace ${method.binaryPath}: ${messageOf(error)}`;
4699
+ var replaceStandalone = async (port, term, method, base) => {
4700
+ const { assetName, binaryPath } = method;
4701
+ const checksumAsset = `${assetName}.sha256`;
4702
+ const writable = await attempt(() => port.assertWritableDir(method.installDir));
4703
+ if (!writable.ok) {
4704
+ return fail(EXIT_GENERAL_ERROR, replaceFailureMessage(method, writable.error));
4705
+ }
4706
+ term.logStderr(`Downloading ${assetName} v${base.latest}...`);
4707
+ const checksum = await attempt(() => port.downloadReleaseText(base.latest, checksumAsset));
4708
+ if (!checksum.ok) {
4709
+ return fail(EXIT_GENERAL_ERROR, `Failed to download ${checksumAsset}: ${messageOf(checksum.error)}`);
4710
+ }
4711
+ const binary = await attempt(() => port.downloadReleaseAsset(base.latest, assetName));
4712
+ if (!binary.ok) {
4713
+ return fail(EXIT_GENERAL_ERROR, `Failed to download ${assetName}: ${messageOf(binary.error)}`);
4714
+ }
4715
+ const rejection = checksumRejection(port, assetName, checksum.value, binary.value);
4716
+ if (rejection !== null) {
4717
+ return rejection;
4718
+ }
4719
+ term.logStderr(`Checksum verified. Replacing ${binaryPath}...`);
4720
+ const replaced = await attempt(() => port.replaceExecutable(binaryPath, binary.value));
4721
+ if (!replaced.ok) {
4722
+ return fail(EXIT_GENERAL_ERROR, replaceFailureMessage(method, replaced.error));
4723
+ }
4724
+ term.logStderr(`Updated hop ${base.current} -> ${base.latest}.`);
4725
+ return ok({ data: { ...base, action: "replaced" } });
4726
+ };
4727
+
4728
+ // src/commands/self-update.ts
4729
+ var fetchLatest = (port, method) => latestChannel(method) === "npm" ? port.latestNpmVersion() : port.latestGithubVersion();
4730
+ var checkCommand = (method) => method.kind === "standalone" ? null : [...upgradeCommand(method)];
4731
+ var describeCheck = (method, base) => {
4732
+ const how = method.kind === "standalone" ? `replaces ${method.binaryPath}` : `runs: ${upgradeCommand(method).join(" ")}`;
4733
+ return `Update available: ${base.current} -> ${base.latest}. "hop --update" ${how}.`;
4734
+ };
4735
+ var selfUpdate = async (port, term, options) => {
4736
+ const facts = await attempt(() => port.installFacts());
4737
+ if (!facts.ok) {
4738
+ return fail(EXIT_GENERAL_ERROR, `Cannot update hop: could not tell how it was installed (${messageOf(facts.error)}).`);
4739
+ }
4740
+ const method = detectInstallMethod(facts.value);
4741
+ if (method.kind === "unsupported") {
4742
+ return fail(EXIT_GENERAL_ERROR, `Cannot update hop: ${method.reason}.`);
4743
+ }
4744
+ term.logStderr("Checking for the latest release...");
4745
+ const release = await attempt(async () => {
4746
+ const latest = await fetchLatest(port, method);
4747
+ return {
4748
+ latest,
4749
+ updateAvailable: compareVersions(latest, options.currentVersion) > 0
4750
+ };
4751
+ });
4752
+ if (!release.ok) {
4753
+ return fail(EXIT_GENERAL_ERROR, `Failed to check for the latest release: ${messageOf(release.error)}`);
4754
+ }
4755
+ const base = {
4756
+ current: options.currentVersion,
4757
+ latest: release.value.latest,
4758
+ updateAvailable: release.value.updateAvailable,
4759
+ method: method.kind,
4760
+ action: "none",
4761
+ command: null
4762
+ };
4763
+ if (!base.updateAvailable) {
4764
+ term.logStderr(`hop ${base.current} is up to date (latest release: ${base.latest}).`);
4765
+ return ok({ data: base });
4766
+ }
4767
+ if (options.check) {
4768
+ term.logStderr(describeCheck(method, base));
4769
+ return ok({ data: { ...base, command: checkCommand(method) } });
4770
+ }
4771
+ return method.kind === "standalone" ? replaceStandalone(port, term, method, base) : runDelegated(port, term, method, base);
4772
+ };
4773
+
4774
+ // src/infra/install-facts.ts
4775
+ import { readFile as readFile2, realpath } from "node:fs/promises";
4776
+ import { join as join3 } from "node:path";
4777
+ var MISE_BACKEND_TOML = ".mise.backend.toml";
4778
+ var MISE_BACKEND_LEGACY = ".mise.backend";
4779
+ var realpathOrNull = async (path) => {
4780
+ try {
4781
+ return await realpath(path);
4782
+ } catch {
4783
+ return null;
4784
+ }
4785
+ };
4786
+ var realpathOrSelf = async (path) => await realpathOrNull(path) ?? path;
4787
+ var readTextOrNull = async (path) => {
4788
+ try {
4789
+ return await readFile2(path, "utf8");
4790
+ } catch {
4791
+ return null;
4792
+ }
4793
+ };
4794
+ var readMiseBackend = async (dir) => {
4795
+ const toml = await readTextOrNull(join3(dir, MISE_BACKEND_TOML));
4796
+ const fromToml = toml === null ? null : parseMiseBackendToml(toml);
4797
+ if (fromToml !== null) {
4798
+ return fromToml;
4799
+ }
4800
+ const legacy = await readTextOrNull(join3(dir, MISE_BACKEND_LEGACY));
4801
+ return legacy === null ? null : parseLegacyMiseBackend(legacy);
4802
+ };
4803
+ var findMiseTool = async (path) => {
4804
+ const dir = path === null ? null : miseToolDir(path);
4805
+ return dir === null ? null : { dir, backend: await readMiseBackend(dir) };
4806
+ };
4807
+ var verifiedNpmPrefix = async (scriptPath) => {
4808
+ const prefix = scriptPath === null ? null : npmGlobalPrefixOf(scriptPath);
4809
+ if (prefix === null) {
4810
+ return null;
4811
+ }
4812
+ return await realpathOrNull(join3(prefix, "bin", "hop")) === scriptPath ? prefix : null;
4813
+ };
4814
+ var readInstallFacts = async (source) => {
4815
+ const executablePath = source.compiled ? await realpathOrSelf(source.execPath) : null;
4816
+ const scriptPath = source.compiled || source.argv1 === undefined ? null : await realpathOrSelf(source.argv1);
4817
+ return {
4818
+ compiled: source.compiled,
4819
+ executablePath,
4820
+ scriptPath,
4821
+ mise: await findMiseTool(executablePath ?? scriptPath),
4822
+ npmGlobalPrefix: await verifiedNpmPrefix(scriptPath),
4823
+ platform: source.platform,
4824
+ arch: source.arch
4825
+ };
4826
+ };
4827
+
4828
+ // src/infra/package-manager.ts
4829
+ import { spawn } from "node:child_process";
4830
+ import { constants } from "node:fs";
4831
+ import { access, stat } from "node:fs/promises";
4832
+ import { homedir as homedir2 } from "node:os";
4833
+ import { basename as basename2 } from "node:path";
4834
+ var STDERR_FD = 2;
4835
+ var isExecutableFile = async (path) => {
4836
+ try {
4837
+ const stats = await stat(path);
4838
+ if (!stats.isFile()) {
4839
+ return false;
4840
+ }
4841
+ await access(path, constants.X_OK);
4842
+ return true;
4843
+ } catch {
4844
+ return false;
4845
+ }
4846
+ };
4847
+ var resolveExecutable = async (name, pathEnv, preferredDir = null) => {
4848
+ const probes = pathExecutableCandidates(name, pathEnv, preferredDir).map(async (candidate) => await isExecutableFile(candidate) ? candidate : null);
4849
+ for (const probe of probes) {
4850
+ const found = await probe;
4851
+ if (found !== null) {
4852
+ return found;
4853
+ }
4854
+ }
4855
+ return null;
4856
+ };
4857
+ var runCommand2 = (argv2) => new Promise((resolve, reject) => {
4858
+ const [program, ...args] = argv2;
4859
+ const child = spawn(program, args, {
4860
+ cwd: homedir2(),
4861
+ stdio: ["inherit", STDERR_FD, STDERR_FD]
4862
+ });
4863
+ child.once("error", reject);
4864
+ child.once("close", (code, signal) => {
4865
+ if (code === null) {
4866
+ reject(new Error(`${basename2(program)} was killed by signal ${signal ?? "unknown"}`));
4867
+ return;
4868
+ }
4869
+ resolve(code);
4870
+ });
4871
+ });
4872
+
4873
+ // src/infra/release-binary.ts
4874
+ import { createHash, randomBytes } from "node:crypto";
4875
+ import { constants as constants2 } from "node:fs";
4876
+ import { access as access2, open, realpath as realpath2, rename, rm as rm3 } from "node:fs/promises";
4877
+ import { basename as basename3, dirname as dirname2, join as join4 } from "node:path";
4878
+ var EXECUTABLE_MODE = 493;
4879
+ var TEMP_SUFFIX_BYTES = 6;
4880
+ var sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
4881
+ var removeQuietly = async (path) => {
4882
+ try {
4883
+ await rm3(path, { force: true });
4884
+ } catch {}
4885
+ };
4886
+ var writeDurably = async (path, bytes) => {
4887
+ const handle = await open(path, "wx", EXECUTABLE_MODE);
4888
+ try {
4889
+ await handle.writeFile(bytes);
4890
+ await handle.chmod(EXECUTABLE_MODE);
4891
+ await handle.sync();
4892
+ } finally {
4893
+ await handle.close();
4894
+ }
4895
+ };
4896
+ var replaceExecutable = async (path, bytes) => {
4897
+ const target = await realpath2(path);
4898
+ const suffix = randomBytes(TEMP_SUFFIX_BYTES).toString("hex");
4899
+ const temp = join4(dirname2(target), `.${basename3(target)}.update-${suffix}`);
4900
+ try {
4901
+ await writeDurably(temp, bytes);
4902
+ await rename(temp, target);
4903
+ } catch (error) {
4904
+ await removeQuietly(temp);
4905
+ throw error;
4906
+ }
4907
+ };
4908
+ var assertWritableDir = (dir) => access2(dir, constants2.W_OK | constants2.X_OK);
4909
+
4910
+ // node_modules/valibot/dist/index.mjs
4911
+ var store$4;
4912
+ var DEFAULT_CONFIG = {
4913
+ lang: undefined,
4914
+ message: undefined,
4915
+ abortEarly: undefined,
4916
+ abortPipeEarly: undefined
4917
+ };
4918
+ function getGlobalConfig(config$1) {
4919
+ if (!config$1 && !store$4)
4920
+ return DEFAULT_CONFIG;
4921
+ return {
4922
+ lang: config$1?.lang ?? store$4?.lang,
4923
+ message: config$1?.message,
4924
+ abortEarly: config$1?.abortEarly ?? store$4?.abortEarly,
4925
+ abortPipeEarly: config$1?.abortPipeEarly ?? store$4?.abortPipeEarly
4926
+ };
4927
+ }
4928
+ var store$3;
4929
+ function getGlobalMessage(lang) {
4930
+ return store$3?.get(lang);
4931
+ }
4932
+ var store$2;
4933
+ function getSchemaMessage(lang) {
4934
+ return store$2?.get(lang);
4935
+ }
4936
+ var store$1;
4937
+ function getSpecificMessage(reference, lang) {
4938
+ return store$1?.get(reference)?.get(lang);
4939
+ }
4940
+ function _stringify(input) {
4941
+ const type = typeof input;
4942
+ if (type === "string")
4943
+ return `"${input}"`;
4944
+ if (type === "number" || type === "bigint" || type === "boolean")
4945
+ return `${input}`;
4946
+ if (type === "object" || type === "function")
4947
+ return (input && Object.getPrototypeOf(input)?.constructor?.name) ?? "null";
4948
+ return type;
4949
+ }
4950
+ function _addIssue(context, label, dataset, config$1, other) {
4951
+ const input = other && "input" in other ? other.input : dataset.value;
4952
+ const expected = other?.expected ?? context.expects ?? null;
4953
+ const received = other?.received ?? /* @__PURE__ */ _stringify(input);
4954
+ const issue = {
4955
+ kind: context.kind,
4956
+ type: context.type,
4957
+ input,
4958
+ expected,
4959
+ received,
4960
+ message: `Invalid ${label}: ${expected ? `Expected ${expected} but r` : "R"}eceived ${received}`,
4961
+ requirement: context.requirement,
4962
+ path: other?.path,
4963
+ issues: other?.issues,
4964
+ lang: config$1.lang,
4965
+ abortEarly: config$1.abortEarly,
4966
+ abortPipeEarly: config$1.abortPipeEarly
4967
+ };
4968
+ const isSchema = context.kind === "schema";
4969
+ const message$1 = other?.message ?? context.message ?? /* @__PURE__ */ getSpecificMessage(context.reference, issue.lang) ?? (isSchema ? /* @__PURE__ */ getSchemaMessage(issue.lang) : null) ?? config$1.message ?? /* @__PURE__ */ getGlobalMessage(issue.lang);
4970
+ if (message$1 !== undefined)
4971
+ issue.message = typeof message$1 === "function" ? message$1(issue) : message$1;
4972
+ if (isSchema)
4973
+ dataset.typed = false;
4974
+ if (dataset.issues)
4975
+ dataset.issues.push(issue);
4976
+ else
4977
+ dataset.issues = [issue];
4978
+ }
4979
+ var _standardCache = /* @__PURE__ */ new WeakMap;
4980
+ function _getStandardProps(context) {
4981
+ let cached = _standardCache.get(context);
4982
+ if (!cached) {
4983
+ cached = {
4984
+ version: 1,
4985
+ vendor: "valibot",
4986
+ validate(value$1) {
4987
+ return context["~run"]({ value: value$1 }, /* @__PURE__ */ getGlobalConfig());
4988
+ }
4989
+ };
4990
+ _standardCache.set(context, cached);
4991
+ }
4992
+ return cached;
4993
+ }
4994
+ function getFallback(schema, dataset, config$1) {
4995
+ return typeof schema.fallback === "function" ? schema.fallback(dataset, config$1) : schema.fallback;
4996
+ }
4997
+ function getDefault(schema, dataset, config$1) {
4998
+ return typeof schema.default === "function" ? schema.default(dataset, config$1) : schema.default;
4999
+ }
5000
+ function object(entries$1, message$1) {
5001
+ return {
5002
+ kind: "schema",
5003
+ type: "object",
5004
+ reference: object,
5005
+ expects: "Object",
5006
+ async: false,
5007
+ entries: entries$1,
5008
+ message: message$1,
5009
+ get "~standard"() {
5010
+ return /* @__PURE__ */ _getStandardProps(this);
5011
+ },
5012
+ "~run"(dataset, config$1) {
5013
+ const input = dataset.value;
5014
+ if (input && typeof input === "object") {
5015
+ dataset.typed = true;
5016
+ dataset.value = {};
5017
+ for (const key in this.entries) {
5018
+ const valueSchema = this.entries[key];
5019
+ if (key in input || (valueSchema.type === "exact_optional" || valueSchema.type === "optional" || valueSchema.type === "nullish") && valueSchema.default !== undefined) {
5020
+ const value$1 = key in input ? input[key] : /* @__PURE__ */ getDefault(valueSchema);
5021
+ const valueDataset = valueSchema["~run"]({ value: value$1 }, config$1);
5022
+ if (valueDataset.issues) {
5023
+ const pathItem = {
5024
+ type: "object",
5025
+ origin: "value",
5026
+ input,
5027
+ key,
5028
+ value: value$1
5029
+ };
5030
+ for (const issue of valueDataset.issues) {
5031
+ if (issue.path)
5032
+ issue.path.unshift(pathItem);
5033
+ else
5034
+ issue.path = [pathItem];
5035
+ dataset.issues?.push(issue);
5036
+ }
5037
+ if (!dataset.issues)
5038
+ dataset.issues = valueDataset.issues;
5039
+ if (config$1.abortEarly) {
5040
+ dataset.typed = false;
5041
+ break;
5042
+ }
5043
+ }
5044
+ if (!valueDataset.typed)
5045
+ dataset.typed = false;
5046
+ dataset.value[key] = valueDataset.value;
5047
+ } else if (valueSchema.fallback !== undefined)
5048
+ dataset.value[key] = /* @__PURE__ */ getFallback(valueSchema);
5049
+ else if (valueSchema.type !== "exact_optional" && valueSchema.type !== "optional" && valueSchema.type !== "nullish") {
5050
+ _addIssue(this, "key", dataset, config$1, {
5051
+ input: undefined,
5052
+ expected: `"${key}"`,
5053
+ path: [{
5054
+ type: "object",
5055
+ origin: "key",
5056
+ input,
5057
+ key,
5058
+ value: input[key]
5059
+ }]
5060
+ });
5061
+ if (config$1.abortEarly)
5062
+ break;
5063
+ }
5064
+ }
5065
+ } else
5066
+ _addIssue(this, "type", dataset, config$1);
5067
+ return dataset;
5068
+ }
5069
+ };
5070
+ }
5071
+ function string(message$1) {
5072
+ return {
5073
+ kind: "schema",
5074
+ type: "string",
5075
+ reference: string,
5076
+ expects: "string",
5077
+ async: false,
5078
+ message: message$1,
5079
+ get "~standard"() {
5080
+ return /* @__PURE__ */ _getStandardProps(this);
5081
+ },
5082
+ "~run"(dataset, config$1) {
5083
+ if (typeof dataset.value === "string")
5084
+ dataset.typed = true;
5085
+ else
5086
+ _addIssue(this, "type", dataset, config$1);
5087
+ return dataset;
5088
+ }
5089
+ };
5090
+ }
5091
+ function safeParse(schema, input, config$1) {
5092
+ const dataset = schema["~run"]({ value: input }, /* @__PURE__ */ getGlobalConfig(config$1));
5093
+ return {
5094
+ typed: dataset.typed,
5095
+ success: !dataset.issues,
5096
+ output: dataset.value,
5097
+ issues: dataset.issues
5098
+ };
5099
+ }
5100
+
5101
+ // src/infra/capped-body.ts
5102
+ var tooLarge = (maxBytes) => new Error(`the response is larger than ${maxBytes} bytes`);
5103
+ var concatenate = (chunks, total) => {
5104
+ const bytes = new Uint8Array(total);
5105
+ let offset = 0;
5106
+ for (const chunk of chunks) {
5107
+ bytes.set(chunk, offset);
5108
+ offset += chunk.length;
5109
+ }
5110
+ return bytes;
5111
+ };
5112
+ var readCapped = async (response, maxBytes) => {
5113
+ const declared = Math.trunc(Number(response.headers.get("content-length") ?? ""));
5114
+ if (declared > maxBytes) {
5115
+ await response.body?.cancel();
5116
+ throw tooLarge(maxBytes);
5117
+ }
5118
+ const chunks = [];
5119
+ let received = 0;
5120
+ for await (const chunk of response.body ?? []) {
5121
+ received += chunk.length;
5122
+ if (received > maxBytes) {
5123
+ throw tooLarge(maxBytes);
5124
+ }
5125
+ chunks.push(chunk);
5126
+ }
5127
+ return concatenate(chunks, received);
5128
+ };
5129
+
5130
+ // src/infra/release-http.ts
5131
+ var METADATA_TIMEOUT_MS = 15000;
5132
+ var ASSET_TIMEOUT_MS = 300000;
5133
+ var METADATA_MAX_BYTES = 1048576;
5134
+ var CHECKSUM_MAX_BYTES = 4096;
5135
+ var BINARY_MAX_BYTES = 268435456;
5136
+ var GITHUB_API_ORIGIN = "https://api.github.com/";
5137
+ var HTTP_FORBIDDEN = 403;
5138
+ var HTTP_TOO_MANY_REQUESTS = 429;
5139
+ var GithubLatestSchema = object({ tag_name: string() });
5140
+ var NpmLatestSchema = object({ version: string() });
5141
+ var DECODER = new TextDecoder;
5142
+ var send = async (fetcher, url, { headers, timeoutMs }) => {
5143
+ try {
5144
+ return await fetcher(url, {
5145
+ headers,
5146
+ redirect: "follow",
5147
+ signal: AbortSignal.timeout(timeoutMs)
5148
+ });
5149
+ } catch (error) {
5150
+ throw new Error(`GET ${url} failed: ${messageOf(error)}`, { cause: error });
5151
+ }
5152
+ };
5153
+ var rateLimitHint = (url, response) => {
5154
+ const refused = response.status === HTTP_FORBIDDEN || response.status === HTTP_TOO_MANY_REQUESTS;
5155
+ const exhausted = refused && response.headers.get("x-ratelimit-remaining") === "0";
5156
+ return exhausted && url.startsWith(GITHUB_API_ORIGIN) ? " (the unauthenticated GitHub API rate limit was hit; try again later)" : "";
5157
+ };
5158
+ var discardBody = async (response) => {
5159
+ try {
5160
+ await response.body?.cancel();
5161
+ } catch {}
5162
+ };
5163
+ var get = async (fetcher, url, options) => {
5164
+ const response = await send(fetcher, url, options);
5165
+ if (!response.ok) {
5166
+ await discardBody(response);
5167
+ throw new Error(`GET ${url} failed: HTTP ${response.status}${rateLimitHint(url, response)}`);
5168
+ }
5169
+ if (response.url.startsWith("http://")) {
5170
+ await discardBody(response);
5171
+ throw new Error(`GET ${url} failed: redirected to ${response.url}, which is not HTTPS`);
5172
+ }
5173
+ return response;
5174
+ };
5175
+ var readBody = async (url, read) => {
5176
+ try {
5177
+ return await read();
5178
+ } catch (error) {
5179
+ throw new Error(`GET ${url} failed while reading the response: ${messageOf(error)}`, {
5180
+ cause: error
5181
+ });
5182
+ }
5183
+ };
5184
+ var getBytes = async (fetcher, url, options) => {
5185
+ const response = await get(fetcher, url, options);
5186
+ return await readBody(url, () => readCapped(response, options.maxBytes));
5187
+ };
5188
+ var getJson = async (fetcher, url, schema, options) => {
5189
+ const bytes = await getBytes(fetcher, url, options);
5190
+ const body = await readBody(url, () => JSON.parse(DECODER.decode(bytes)));
5191
+ const parsed = safeParse(schema, body);
5192
+ if (!parsed.success) {
5193
+ throw new Error(`GET ${url} returned an unexpected response`);
5194
+ }
5195
+ return parsed.output;
5196
+ };
5197
+ var createReleaseHttp = (userAgent, fetcher = fetch) => ({
5198
+ async latestGithubVersion() {
5199
+ const release = await getJson(fetcher, GITHUB_LATEST_RELEASE_URL, GithubLatestSchema, {
5200
+ headers: {
5201
+ Accept: "application/vnd.github+json",
5202
+ "User-Agent": userAgent
5203
+ },
5204
+ timeoutMs: METADATA_TIMEOUT_MS,
5205
+ maxBytes: METADATA_MAX_BYTES
5206
+ });
5207
+ return versionFromTag(release.tag_name);
5208
+ },
5209
+ async latestNpmVersion() {
5210
+ const latest = await getJson(fetcher, NPM_LATEST_URL, NpmLatestSchema, {
5211
+ headers: { Accept: "application/json", "User-Agent": userAgent },
5212
+ timeoutMs: METADATA_TIMEOUT_MS,
5213
+ maxBytes: METADATA_MAX_BYTES
5214
+ });
5215
+ return requireVersion(latest.version);
5216
+ },
5217
+ async downloadReleaseAsset(version, assetName) {
5218
+ return await getBytes(fetcher, releaseAssetUrl(version, assetName), {
5219
+ headers: { Accept: "application/octet-stream", "User-Agent": userAgent },
5220
+ timeoutMs: ASSET_TIMEOUT_MS,
5221
+ maxBytes: BINARY_MAX_BYTES
5222
+ });
5223
+ },
5224
+ async downloadReleaseText(version, assetName) {
5225
+ const bytes = await getBytes(fetcher, releaseAssetUrl(version, assetName), {
5226
+ headers: { Accept: "text/plain", "User-Agent": userAgent },
5227
+ timeoutMs: METADATA_TIMEOUT_MS,
5228
+ maxBytes: CHECKSUM_MAX_BYTES
5229
+ });
5230
+ return DECODER.decode(bytes);
5231
+ }
5232
+ });
5233
+
5234
+ // src/infra/self-update.ts
5235
+ var createSelfUpdatePort = ({
5236
+ compiled,
5237
+ version
5238
+ }) => ({
5239
+ installFacts: () => readInstallFacts({
5240
+ compiled,
5241
+ execPath: process.execPath,
5242
+ argv1: process.argv[1],
5243
+ platform: process.platform,
5244
+ arch: process.arch
5245
+ }),
5246
+ ...createReleaseHttp(`hop/${version}`),
5247
+ sha256Hex,
5248
+ assertWritableDir,
5249
+ replaceExecutable,
5250
+ resolveExecutable: (name, preferredDir) => resolveExecutable(name, process.env.PATH, preferredDir),
5251
+ runCommand: runCommand2
5252
+ });
5253
+ // package.json
5254
+ var package_default = {
5255
+ name: "@n-seiji/nuthatch",
5256
+ version: "0.1.5",
5257
+ description: "git worktree manager (hop)",
5258
+ homepage: "https://github.com/n-seiji/nuthatch#readme",
5259
+ bugs: {
5260
+ url: "https://github.com/n-seiji/nuthatch/issues"
5261
+ },
5262
+ license: "GPL-3.0-only",
5263
+ repository: {
5264
+ type: "git",
5265
+ url: "git+https://github.com/n-seiji/nuthatch.git"
5266
+ },
5267
+ bin: {
5268
+ hop: "dist/cli.js"
5269
+ },
5270
+ files: [
5271
+ "dist",
5272
+ "shell"
5273
+ ],
5274
+ type: "module",
5275
+ publishConfig: {
5276
+ access: "public"
5277
+ },
5278
+ scripts: {
5279
+ build: "bun build ./src/cli.ts --outfile dist/cli.js --target node --format esm",
5280
+ typecheck: "tsc --noEmit",
5281
+ lint: "oxlint .",
5282
+ "lint:fix": "oxlint . --fix",
5283
+ format: "oxfmt --write .",
5284
+ "format:check": "oxfmt --check .",
5285
+ test: "bun test"
5286
+ },
5287
+ dependencies: {
5288
+ citty: "^0.1.6",
5289
+ valibot: "^1.4.2"
5290
+ },
5291
+ devDependencies: {
5292
+ "@types/bun": "latest",
5293
+ oxfmt: "^0.66.0",
5294
+ oxlint: "^1.81.0",
5295
+ typescript: "^5.6.0"
5296
+ },
5297
+ engines: {
5298
+ node: ">=22"
5299
+ }
5300
+ };
5301
+
5302
+ // src/version.ts
5303
+ var VERSION = package_default.version;
5304
+
5305
+ // src/cli-update.ts
5306
+ var UPDATE_ARGS = {
5307
+ check: { type: "boolean", description: "Only report; change nothing" },
5308
+ json: { type: "boolean", description: "Output JSON" }
5309
+ };
5310
+ var UPDATE_FLAGS = new Set(["--check", "--json"]);
5311
+ var parseUpdateArgs = (tokens) => {
5312
+ const stray = tokens.filter((token) => !UPDATE_FLAGS.has(token));
5313
+ if (stray.length > 0) {
5314
+ return {
5315
+ ok: false,
5316
+ json: wantsJson(tokens),
5317
+ message: `Unknown argument for --update: ${stray.join(" ")}. Usage: hop --update [--check] [--json]`
5318
+ };
5319
+ }
5320
+ const args = parseArgs([...tokens], UPDATE_ARGS);
5321
+ return { ok: true, check: Boolean(args.check), json: Boolean(args.json) };
5322
+ };
5323
+ var runUpdate = async (rawArgs, term) => {
5324
+ try {
5325
+ const parsed = parseUpdateArgs(rawArgs.slice(1));
5326
+ if (!parsed.ok) {
5327
+ reportResult("update", fail(EXIT_USAGE_ERROR, parsed.message), parsed.json);
5328
+ return;
5329
+ }
5330
+ const port = createSelfUpdatePort({
5331
+ compiled: isRunningAsCompiledBinary(),
5332
+ version: VERSION
5333
+ });
5334
+ const result = await selfUpdate(port, term, {
5335
+ currentVersion: VERSION,
5336
+ check: parsed.check
5337
+ });
5338
+ reportResult("update", result, parsed.json);
5339
+ } catch (error) {
5340
+ reportFatalError("update", wantsJson(rawArgs), error);
4355
5341
  }
4356
5342
  };
4357
5343
 
@@ -4373,11 +5359,11 @@ var classifyGarbage = (input) => {
4373
5359
  };
4374
5360
 
4375
5361
  // src/commands/clean-candidates.ts
4376
- var buildCleanCandidates = async (git, context, ext) => {
5362
+ var buildCleanCandidates = async (git, fs, context, ext) => {
4377
5363
  const defaultRef = await git.resolveDefaultBranchRef(context.rootPath);
4378
5364
  const targets = context.worktrees.filter((wt) => wt.kind === "managed" || ext && wt.kind === "external");
4379
5365
  const results = await Promise.all(targets.map(async (wt) => {
4380
- const reason = await classifyWorktree(wt, { git, defaultRef, context });
5366
+ const reason = await classifyWorktree(wt, { git, fs, defaultRef, context });
4381
5367
  if (reason === null) {
4382
5368
  return null;
4383
5369
  }
@@ -4385,7 +5371,7 @@ var buildCleanCandidates = async (git, context, ext) => {
4385
5371
  }));
4386
5372
  return results.filter((candidate) => candidate !== null);
4387
5373
  };
4388
- var classifyWorktree = async (wt, { git, defaultRef, context }) => {
5374
+ var classifyWorktree = async (wt, { git, fs, defaultRef, context }) => {
4389
5375
  if (wt.prunable) {
4390
5376
  return classifyGarbage({
4391
5377
  prunable: true,
@@ -4399,15 +5385,15 @@ var classifyWorktree = async (wt, { git, defaultRef, context }) => {
4399
5385
  return null;
4400
5386
  }
4401
5387
  const { rootPath, worktrees } = context;
4402
- const [isClean, upstreamGone] = await Promise.all([
4403
- wt.bare ? Promise.resolve(false) : (async () => !await git.isDirty(wt.path, otherWorktreePaths(worktrees, wt.path)))(),
5388
+ const [dirtyState, upstreamGone] = await Promise.all([
5389
+ worktreeDirtyState(git, fs, worktrees, wt),
4404
5390
  git.isUpstreamGone(rootPath, wt.branch)
4405
5391
  ]);
4406
5392
  const mergedIntoDefault = defaultRef === null ? "unknown" : await git.isAncestor(rootPath, wt.branch, defaultRef);
4407
5393
  const allCommitsReachableFromDefault = defaultRef === null ? "unknown" : await git.hasEquivalentCommits(rootPath, wt.branch, defaultRef);
4408
5394
  const input = {
4409
5395
  prunable: false,
4410
- clean: isClean,
5396
+ clean: dirtyState === false,
4411
5397
  mergedIntoDefault,
4412
5398
  upstreamGone,
4413
5399
  allCommitsReachableFromDefault
@@ -4418,7 +5404,7 @@ var classifyWorktree = async (wt, { git, defaultRef, context }) => {
4418
5404
  // src/commands/clean.ts
4419
5405
  var clean = async (git, fs, term, options) => {
4420
5406
  const context = await loadRepoContext(git, fs, options.cwd);
4421
- const candidates = await buildCleanCandidates(git, context, options.ext);
5407
+ const candidates = await buildCleanCandidates(git, fs, context, options.ext);
4422
5408
  if (options.dryRun) {
4423
5409
  return ok({ data: { candidates } });
4424
5410
  }
@@ -4437,20 +5423,13 @@ var clean = async (git, fs, term, options) => {
4437
5423
  return ok({ data: { candidates, removed: [] } });
4438
5424
  }
4439
5425
  }
4440
- const execution = await executeClean({
5426
+ return executeClean({
4441
5427
  git,
4442
5428
  fs,
4443
5429
  context,
4444
5430
  candidates,
4445
5431
  cleanOptions: options
4446
5432
  });
4447
- if (!execution.ok) {
4448
- return execution.rejection;
4449
- }
4450
- return ok({
4451
- data: { candidates, removed: execution.removed },
4452
- ...execution.warnings.length === 0 ? {} : { warnings: execution.warnings }
4453
- });
4454
5433
  };
4455
5434
  var removeCandidate = async ({
4456
5435
  git,
@@ -4460,16 +5439,16 @@ var removeCandidate = async ({
4460
5439
  worktrees
4461
5440
  }) => {
4462
5441
  if (nestedWorktrees(worktrees, candidate.path).length > 0) {
4463
- return false;
5442
+ return "nested";
4464
5443
  }
4465
5444
  try {
4466
5445
  await git.removeWorktree(rootPath, candidate.path, false);
4467
5446
  if (deleteBranch && candidate.branch.length > 0) {
4468
5447
  await git.deleteBranch(rootPath, candidate.branch);
4469
5448
  }
4470
- return true;
5449
+ return "removed";
4471
5450
  } catch {
4472
- return false;
5451
+ return "failed";
4473
5452
  }
4474
5453
  };
4475
5454
  var canDeletePrunableBranch = async (git, rootPath, candidate, defaultRef) => {
@@ -4490,21 +5469,16 @@ var canDeletePrunableBranch = async (git, rootPath, candidate, defaultRef) => {
4490
5469
  } catch {}
4491
5470
  return false;
4492
5471
  };
4493
- var executeClean = async ({
5472
+ var executeClean = ({
4494
5473
  git,
4495
5474
  fs,
4496
5475
  context,
4497
5476
  candidates,
4498
5477
  cleanOptions
4499
- }) => {
4500
- const acquisition = await acquireRepoLockOrRejection(context.commonDir);
4501
- if (!acquisition.ok) {
4502
- return { ok: false, rejection: acquisition.rejection };
4503
- }
4504
- const { lock } = acquisition;
5478
+ }) => withRepoLock(context.commonDir, async () => {
4505
5479
  try {
4506
5480
  const fresh = await loadRepoContext(git, fs, context.rootPath);
4507
- const freshCandidates = await buildCleanCandidates(git, fresh, cleanOptions.ext);
5481
+ const freshCandidates = await buildCleanCandidates(git, fs, fresh, cleanOptions.ext);
4508
5482
  const freshByPath = new Map(freshCandidates.map((candidate) => [candidate.path, candidate]));
4509
5483
  const stillValid = candidates.flatMap((candidate) => {
4510
5484
  const freshCandidate = freshByPath.get(candidate.path);
@@ -4519,30 +5493,29 @@ var executeClean = async ({
4519
5493
  if (cleanOptions.withBranch && candidate.reason === "prunable" && candidate.branch.length > 0 && !branchSafe) {
4520
5494
  warnings.push(`Keeping branch "${candidate.branch}": prunable worktree is not confirmed merged or gone.`);
4521
5495
  }
4522
- if (nestedWorktrees(fresh.worktrees, candidate.path).length > 0) {
5496
+ const outcome = await removeCandidate({
5497
+ git,
5498
+ rootPath: fresh.rootPath,
5499
+ candidate,
5500
+ deleteBranch,
5501
+ worktrees: fresh.worktrees
5502
+ });
5503
+ if (outcome === "removed") {
5504
+ removed.push(candidate.branch);
5505
+ } else if (outcome === "nested") {
4523
5506
  warnings.push(`Skipped ${candidate.path}: still contains a registered worktree.`);
4524
- } else {
4525
- const success = await removeCandidate({
4526
- git,
4527
- rootPath: fresh.rootPath,
4528
- candidate,
4529
- deleteBranch,
4530
- worktrees: fresh.worktrees
4531
- });
4532
- if (success) {
4533
- removed.push(candidate.branch);
4534
- }
4535
5507
  }
4536
5508
  }
4537
- return { ok: true, removed, warnings };
5509
+ return ok({
5510
+ data: { candidates: [...candidates], removed },
5511
+ ...warnings.length === 0 ? {} : { warnings }
5512
+ });
4538
5513
  } catch (error) {
4539
- throw new Error(`Failed to clean worktrees: ${error.message}`, {
5514
+ throw new Error(`Failed to clean worktrees: ${messageOf(error)}`, {
4540
5515
  cause: error
4541
5516
  });
4542
- } finally {
4543
- await lock.release();
4544
5517
  }
4545
- };
5518
+ });
4546
5519
 
4547
5520
  // src/commands/init.ts
4548
5521
  var ZSH_TEMPLATE = `# nuthatch shell integration for zsh.
@@ -4601,7 +5574,7 @@ var renderInit = (options) => {
4601
5574
  };
4602
5575
 
4603
5576
  // src/commands/jump.ts
4604
- import { join as join3 } from "node:path";
5577
+ import { join as join5 } from "node:path";
4605
5578
 
4606
5579
  // src/domain/sanitize.ts
4607
5580
  var MAX_DIR_NAME_LENGTH = 200;
@@ -4618,10 +5591,10 @@ var shortHash = (input) => {
4618
5591
  }
4619
5592
  return (hash >>> 0).toString(HEX_RADIX).padStart(FULL_HASH_HEX_LENGTH, "0").slice(0, HASH_LENGTH);
4620
5593
  };
4621
- var baseName = (branch) => branch.replaceAll("/", "__");
5594
+ var baseName2 = (branch) => branch.replaceAll("/", "__");
4622
5595
  var truncate = (name, maxLength) => name.length > maxLength ? name.slice(0, maxLength) : name;
4623
5596
  var sanitizeBranchName = (branch, isTaken) => {
4624
- const base = truncate(baseName(branch), MAX_DIR_NAME_LENGTH);
5597
+ const base = truncate(baseName2(branch), MAX_DIR_NAME_LENGTH);
4625
5598
  if (!isTaken(base)) {
4626
5599
  return base;
4627
5600
  }
@@ -4637,7 +5610,8 @@ var sanitizeBranchName = (branch, isTaken) => {
4637
5610
  };
4638
5611
 
4639
5612
  // src/commands/jump.ts
4640
- var jump = async (git, fs, term, options) => {
5613
+ var existingWorktreeResult = (branch, path) => ok({ path, data: { branch, created: false } });
5614
+ var jump = async (git, fs, options) => {
4641
5615
  if (options.target === "-") {
4642
5616
  const previous = process.env.OLDPWD;
4643
5617
  if (previous === undefined || previous.length === 0) {
@@ -4651,57 +5625,35 @@ var jump = async (git, fs, term, options) => {
4651
5625
  const context = await loadRepoContext(git, fs, options.cwd);
4652
5626
  const existing = context.worktrees.find((wt) => wt.branch === options.target);
4653
5627
  if (existing !== undefined) {
4654
- return ok({
4655
- path: existing.path,
4656
- data: { branch: options.target, created: false }
4657
- });
5628
+ return existingWorktreeResult(options.target, existing.path);
4658
5629
  }
4659
5630
  if (!options.create) {
4660
- if (!term.isTTY()) {
4661
- return fail(EXIT_SAFE_REJECTION, `No worktree for branch "${options.target}". Re-run with --create to create it.`);
4662
- }
4663
5631
  return fail(EXIT_SAFE_REJECTION, `No worktree for branch "${options.target}". Re-run with --create to create it.`);
4664
5632
  }
4665
- const localBranches = await git.listBranches(context.rootPath);
4666
- const branchExistsLocally = localBranches.includes(options.target);
4667
- let track = options.track;
4668
- if (!branchExistsLocally && track === undefined) {
4669
- const remotes = await git.remotesWithBranch(context.rootPath, options.target);
4670
- if (remotes.includes("origin")) {
4671
- track = `origin/${options.target}`;
4672
- } else if (remotes.length === 1) {
4673
- track = `${remotes[0]}/${options.target}`;
4674
- } else if (remotes.length > 1) {
4675
- return fail(EXIT_USAGE_ERROR, `Branch "${options.target}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`);
4676
- }
5633
+ const checkout = await resolveBranchCheckout(git, context.rootPath, options.target, options.track);
5634
+ if (!checkout.ok) {
5635
+ return checkout.rejection;
4677
5636
  }
4678
5637
  const managedDirNames = await fs.listDirNames(context.managedRoot);
4679
5638
  const existingDirNames = new Set(managedDirNames.map((name) => name.toLowerCase()));
4680
5639
  const dirName = sanitizeBranchName(options.target, (candidate) => existingDirNames.has(candidate.toLowerCase()));
4681
- const targetPath = join3(context.managedRoot, dirName);
4682
- const lock = await acquireRepoLock(context.commonDir);
4683
- try {
4684
- const fresh = await loadRepoContext(git, fs, options.cwd);
4685
- const racedExisting = fresh.worktrees.find((wt) => wt.branch === options.target);
4686
- if (racedExisting !== undefined) {
5640
+ const targetPath = join5(context.managedRoot, dirName);
5641
+ return withRepoLock(context.commonDir, async () => {
5642
+ try {
5643
+ const fresh = await loadRepoContext(git, fs, options.cwd);
5644
+ const racedExisting = fresh.worktrees.find((wt) => wt.branch === options.target);
5645
+ if (racedExisting !== undefined) {
5646
+ return existingWorktreeResult(options.target, racedExisting.path);
5647
+ }
5648
+ await fs.mkdir(context.managedRoot);
5649
+ await git.addWorktree(context.rootPath, targetPath, options.target, checkout.options);
4687
5650
  return ok({
4688
- path: racedExisting.path,
4689
- data: { branch: options.target, created: false }
5651
+ path: targetPath,
5652
+ data: { branch: options.target, created: true }
4690
5653
  });
5654
+ } catch (error) {
5655
+ return fail(EXIT_SAFE_REJECTION, `Failed to create worktree: ${messageOf(error)}`);
4691
5656
  }
4692
- await fs.mkdir(context.managedRoot);
4693
- await git.addWorktree(context.rootPath, targetPath, options.target, {
4694
- createBranch: !branchExistsLocally,
4695
- ...track === undefined ? {} : { track }
4696
- });
4697
- } catch (error) {
4698
- return fail(EXIT_SAFE_REJECTION, `Failed to create worktree: ${error.message}`);
4699
- } finally {
4700
- await lock.release();
4701
- }
4702
- return ok({
4703
- path: targetPath,
4704
- data: { branch: options.target, created: true }
4705
5657
  });
4706
5658
  };
4707
5659
 
@@ -4709,13 +5661,13 @@ var jump = async (git, fs, term, options) => {
4709
5661
  var ls = async (git, fs, options) => {
4710
5662
  const context = await loadRepoContext(git, fs, options.cwd);
4711
5663
  const entries = await Promise.all(context.worktrees.map(async (wt) => {
4712
- const [dirty, aheadBehind] = await Promise.all([
4713
- wt.bare ? Promise.resolve(false) : git.isDirty(wt.path, otherWorktreePaths(context.worktrees, wt.path)),
5664
+ const [dirtyState, aheadBehind] = await Promise.all([
5665
+ worktreeDirtyState(git, fs, context.worktrees, wt),
4714
5666
  wt.branch === null ? Promise.resolve(null) : git.aheadBehind(context.rootPath, wt.branch)
4715
5667
  ]);
4716
5668
  return {
4717
5669
  ...wt,
4718
- dirty,
5670
+ dirty: dirtyState ?? false,
4719
5671
  ahead: aheadBehind?.ahead ?? null,
4720
5672
  behind: aheadBehind?.behind ?? null
4721
5673
  };
@@ -4755,26 +5707,26 @@ import { execFile as execFileCb } from "node:child_process";
4755
5707
  import { promisify } from "node:util";
4756
5708
 
4757
5709
  // src/domain/dirty.ts
4758
- var filterOutNestedWorktreePaths = (relativePaths, targetPath, otherWorktreePaths2) => {
5710
+ var filterOutNestedWorktreePaths = (relativePaths, targetPath, otherWorktreePaths) => {
4759
5711
  const target = stripTrailingSlash(targetPath);
4760
- const others = otherWorktreePaths2.map((path) => stripTrailingSlash(path)).filter((path) => path !== target);
5712
+ const others = otherWorktreePaths.map((path) => stripTrailingSlash(path)).filter((path) => path !== target);
4761
5713
  if (others.length === 0) {
4762
5714
  return [...relativePaths];
4763
5715
  }
4764
5716
  return relativePaths.filter((relativePath) => {
4765
5717
  const absolutePath = stripTrailingSlash(`${target}/${relativePath}`);
4766
- return !others.some((other) => isSameOrNested(absolutePath, other));
5718
+ return !others.some((other) => absolutePath === other || isWithin(other, absolutePath));
4767
5719
  });
4768
5720
  };
4769
- var isDirtyFromStatus = (statusOutput, targetPath, otherWorktreePaths2) => {
5721
+ var isDirtyFromStatus = (statusOutput, targetPath, otherWorktreePaths) => {
4770
5722
  const paths = parseStatusPaths(statusOutput);
4771
- return filterOutNestedWorktreePaths(paths, targetPath, otherWorktreePaths2).length > 0;
5723
+ return filterOutNestedWorktreePaths(paths, targetPath, otherWorktreePaths).length > 0;
4772
5724
  };
4773
5725
  var stripTrailingSlash = (path) => path.endsWith("/") ? path.slice(0, -1) : path;
4774
- var isSameOrNested = (childPath, otherPath) => childPath === otherPath || childPath.startsWith(`${otherPath}/`);
4775
- var isRenameOrCopyCode = (code) => code.includes("R") || code.includes("C");
5726
+ var STATUS_CODE_WIDTH = 3;
4776
5727
  var RECORDS_PER_RENAME_OR_COPY = 2;
4777
5728
  var RECORDS_PER_ORDINARY_ENTRY = 1;
5729
+ var isRenameOrCopyCode = (code) => code.includes("R") || code.includes("C");
4778
5730
  var parseStatusPaths = (statusOutput) => {
4779
5731
  const records = statusOutput.split("\x00").filter((record) => record.length > 0);
4780
5732
  const paths = [];
@@ -4791,18 +5743,17 @@ var parseStatusPaths = (statusOutput) => {
4791
5743
  }
4792
5744
  return paths;
4793
5745
  };
4794
- var STATUS_CODE_WIDTH = 3;
4795
5746
 
4796
5747
  // src/infra/git-executable.ts
4797
- import { constants } from "node:fs";
4798
- import { access, stat } from "node:fs/promises";
4799
- var isExecutableFile = async (path) => {
5748
+ import { constants as constants3 } from "node:fs";
5749
+ import { access as access3, stat as stat2 } from "node:fs/promises";
5750
+ var isExecutableFile2 = async (path) => {
4800
5751
  try {
4801
- const stats = await stat(path);
5752
+ const stats = await stat2(path);
4802
5753
  if (!stats.isFile()) {
4803
5754
  return false;
4804
5755
  }
4805
- await access(path, constants.X_OK);
5756
+ await access3(path, constants3.X_OK);
4806
5757
  return true;
4807
5758
  } catch {
4808
5759
  return false;
@@ -4813,7 +5764,7 @@ var resolveGitExecutable = async (env2) => {
4813
5764
  override: env2[GIT_EXECUTABLE_OVERRIDE_ENV],
4814
5765
  path: env2.PATH
4815
5766
  });
4816
- const probes = candidates.map(async (candidate) => await isExecutableFile(candidate) ? candidate : undefined);
5767
+ const probes = candidates.map(async (candidate) => await isExecutableFile2(candidate) ? candidate : undefined);
4817
5768
  for (const probe of probes) {
4818
5769
  const found = await probe;
4819
5770
  if (found !== undefined) {
@@ -4867,9 +5818,9 @@ var createWorktreeMethods = () => ({
4867
5818
  const out = await run(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
4868
5819
  return out.trim();
4869
5820
  },
4870
- async isDirty(path, otherWorktreePaths2) {
5821
+ async isDirty(path, otherWorktreePaths) {
4871
5822
  const status = await run(path, ["status", "--porcelain", "-z", "--untracked-files=all"]);
4872
- return isDirtyFromStatus(status, path, otherWorktreePaths2);
5823
+ return isDirtyFromStatus(status, path, otherWorktreePaths);
4873
5824
  },
4874
5825
  async addWorktree(cwd, path, branch, options) {
4875
5826
  const args = ["worktree", "add"];
@@ -5052,7 +6003,7 @@ var createTermPort = () => ({
5052
6003
  var USAGE = `Usage: hop [command] [options]
5053
6004
 
5054
6005
  hop Pick a worktree/branch interactively and cd into it (TTY); lists worktrees otherwise
5055
- hop <branch> Create-or-jump: cd into <branch>'s worktree, creating it on demand
6006
+ hop <branch> Create-or-jump: cd into <branch>'s worktree (pass --create to create a missing one)
5056
6007
  hop root cd into the root clone
5057
6008
  hop - cd back to the previous worktree
5058
6009
  hop -- <branch> Escape a branch name that collides with a reserved command (ls/rm/clean/root/init/help)
@@ -5064,12 +6015,16 @@ var USAGE = `Usage: hop [command] [options]
5064
6015
  hop root - Switch the root clone back
5065
6016
  hop init zsh Print the zsh shell integration (eval "$(hop init zsh)")
5066
6017
 
6018
+ hop --update Update hop to the latest release, the way it was installed
6019
+ hop --update --check Only report whether an update is available; change nothing
6020
+ hop --version Print hop's version
6021
+
5067
6022
  Interactive picker keys:
5068
6023
  Enter cd into the selected candidate
5069
6024
  Tab, →, Ctrl+L, Ctrl+F Open the action panel, as a column beside the list
5070
6025
  (stacks below it instead on narrow terminals)
5071
6026
  Ctrl+X Delete the selected worktree (y/N confirmation)
5072
- Ctrl+R Switch the root clone to the selected branch, immediately
6027
+ Ctrl+R Switch the root clone to the selected branch (y/N first for an external worktree)
5073
6028
  ↑/↓, Ctrl+P/N, Ctrl+K/J Move the selection (arrow, emacs, and vim keys all work)
5074
6029
  Esc Cancel (exit 0, no output)
5075
6030
  Ctrl+C Cancel like an interrupt (exit 130, same as SIGINT)
@@ -5077,7 +6032,7 @@ Interactive picker keys:
5077
6032
  action, c/d/r to run cd/delete/switchRoot directly, Esc/Tab/←/Ctrl+H to close
5078
6033
 
5079
6034
  Options:
5080
- --create Create the worktree when jumping to a branch without one (required outside a TTY)
6035
+ --create Create the worktree when jumping to a branch without one (always required, TTY or not)
5081
6036
  --json Output JSON instead of plain text
5082
6037
  --force Force removal even if the worktree is dirty (hop rm)
5083
6038
  --ext [deprecated, no-op for hop rm] hop rm no longer requires it to
@@ -5092,8 +6047,9 @@ Options:
5092
6047
  var git = createGitPort();
5093
6048
  var fs = createFsPort();
5094
6049
  var term = createTermPort();
5095
- var applyExitCode = (result) => {
5096
- process.exitCode = result.exitCode;
6050
+ var runLs = async (json) => {
6051
+ const result = await ls(git, fs, { cwd: process.cwd() });
6052
+ reportResult("ls", result, json);
5097
6053
  };
5098
6054
  var lsCommand = defineCommand({
5099
6055
  meta: { name: "ls", description: "List worktrees" },
@@ -5101,9 +6057,7 @@ var lsCommand = defineCommand({
5101
6057
  json: { type: "boolean", description: "Output JSON" }
5102
6058
  },
5103
6059
  async run({ args }) {
5104
- const result = await ls(git, fs, { cwd: process.cwd() });
5105
- render("ls", result, Boolean(args.json));
5106
- applyExitCode(result);
6060
+ await runLs(Boolean(args.json));
5107
6061
  }
5108
6062
  });
5109
6063
  var rmCommand = defineCommand({
@@ -5128,11 +6082,10 @@ var rmCommand = defineCommand({
5128
6082
  force: Boolean(args.force),
5129
6083
  ext: Boolean(args.ext)
5130
6084
  });
5131
- render("rm", result, Boolean(args.json));
5132
- applyExitCode(result);
6085
+ reportResult("rm", result, Boolean(args.json));
5133
6086
  }
5134
6087
  });
5135
- var rootCommand = createRootCommand(git, fs, applyExitCode);
6088
+ var rootCommand = createRootCommand(git, fs);
5136
6089
  var cleanCommand = defineCommand({
5137
6090
  meta: {
5138
6091
  name: "clean",
@@ -5153,8 +6106,7 @@ var cleanCommand = defineCommand({
5153
6106
  dryRun: Boolean(args.dryRun),
5154
6107
  yes: Boolean(args.yes)
5155
6108
  });
5156
- render("clean", result, Boolean(args.json));
5157
- applyExitCode(result);
6109
+ reportResult("clean", result, Boolean(args.json));
5158
6110
  }
5159
6111
  });
5160
6112
  var initCommand = defineCommand({
@@ -5178,14 +6130,13 @@ var initCommand = defineCommand({
5178
6130
  }
5179
6131
  });
5180
6132
  var runJump = async (target, options) => {
5181
- const result = await jump(git, fs, term, {
6133
+ const result = await jump(git, fs, {
5182
6134
  cwd: process.cwd(),
5183
6135
  target,
5184
6136
  create: options.create,
5185
6137
  ...options.track === undefined ? {} : { track: options.track }
5186
6138
  });
5187
- render("jump", result, options.json);
5188
- applyExitCode(result);
6139
+ reportResult("jump", result, options.json);
5189
6140
  };
5190
6141
  var RESERVED_COMMANDS = {
5191
6142
  ls: lsCommand,
@@ -5234,12 +6185,7 @@ var runInteractivePick = async (json) => {
5234
6185
  path: selected.worktree.path,
5235
6186
  data: { branch: selected.worktree.branch, created: false }
5236
6187
  });
5237
- if (json) {
5238
- render("pick", result, true);
5239
- } else {
5240
- process.stdout.write(`${selected.worktree.path}
5241
- `);
5242
- }
6188
+ render("pick", result, json);
5243
6189
  return;
5244
6190
  }
5245
6191
  await runJump(selected.branch, { create: true, json });
@@ -5251,9 +6197,7 @@ var runJumpFromArgs = async (rawArgs) => {
5251
6197
  await runInteractivePick(Boolean(args.json));
5252
6198
  return;
5253
6199
  }
5254
- const result = await ls(git, fs, { cwd: process.cwd() });
5255
- render("ls", result, Boolean(args.json));
5256
- applyExitCode(result);
6200
+ await runLs(Boolean(args.json));
5257
6201
  return;
5258
6202
  }
5259
6203
  await runJump(String(args.target), {
@@ -5281,6 +6225,16 @@ var main = async () => {
5281
6225
  process.exitCode = 0;
5282
6226
  return;
5283
6227
  }
6228
+ if (isVersionRequest(rawArgs)) {
6229
+ process.stdout.write(`${VERSION}
6230
+ `);
6231
+ process.exitCode = EXIT_SUCCESS;
6232
+ return;
6233
+ }
6234
+ if (isUpdateRequest(rawArgs)) {
6235
+ await runUpdate(rawArgs, term);
6236
+ return;
6237
+ }
5284
6238
  const dispatch = dispatchCliArgs(rawArgs, Object.keys(RESERVED_COMMANDS));
5285
6239
  try {
5286
6240
  await dispatchAndRun(dispatch);