@n-seiji/nuthatch 0.1.3 → 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 +47 -6
  2. package/README.md +47 -9
  3. package/dist/cli.js +1601 -530
  4. package/package.json +2 -2
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 === "--") {
@@ -2054,6 +2057,31 @@ var dispatchCliArgs = (rawArgs, reservedNames) => {
2054
2057
  return { kind: "jump", args: rawArgs };
2055
2058
  };
2056
2059
 
2060
+ // src/domain/git-executable.ts
2061
+ var GIT_EXECUTABLE_OVERRIDE_ENV = "HOP_GIT";
2062
+ var GIT_NOT_FOUND_ERROR_CODE = "HOP_GIT_NOT_FOUND";
2063
+ var GIT_NOT_FOUND_HINT = `Install git, or set ${GIT_EXECUTABLE_OVERRIDE_ENV} to its absolute path.`;
2064
+ var GIT_FALLBACK_BIN_DIRS = [
2065
+ "/opt/homebrew/bin",
2066
+ "/usr/local/bin",
2067
+ "/usr/bin",
2068
+ "/bin"
2069
+ ];
2070
+ var PATH_SEPARATOR = ":";
2071
+ var joinBin = (dir) => {
2072
+ const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
2073
+ return `${base}/git`;
2074
+ };
2075
+ var gitExecutableCandidates = (input) => {
2076
+ const override = input.override?.trim() ?? "";
2077
+ if (override.length > 0) {
2078
+ return [override];
2079
+ }
2080
+ const dirs = (input.path ?? "").split(PATH_SEPARATOR).map((entry) => entry.trim()).filter((entry) => entry.startsWith("/"));
2081
+ return [...new Set([...dirs, ...GIT_FALLBACK_BIN_DIRS].map((dir) => joinBin(dir)))];
2082
+ };
2083
+ var gitNotFoundMessage = (candidates) => `git executable not found. Looked in: ${candidates.join(", ")}. ${GIT_NOT_FOUND_HINT}`;
2084
+
2057
2085
  // src/domain/result.ts
2058
2086
  var EXIT_SUCCESS = 0;
2059
2087
  var EXIT_GENERAL_ERROR = 1;
@@ -2072,6 +2100,78 @@ var fail = (exitCode, errorMessage, warnings) => ({
2072
2100
  ...warnings === undefined || warnings.length === 0 ? {} : { warnings }
2073
2101
  });
2074
2102
 
2103
+ // src/domain/fatal-error.ts
2104
+ var fieldsOf = (error) => typeof error === "object" && error !== null ? error : {};
2105
+ var messageOf = (error) => error instanceof Error ? error.message : String(error);
2106
+ var trimmedStringOf = (value) => typeof value === "string" ? value.trim() : "";
2107
+ var isSpawnFailure = (fields) => fields.code === "ENOENT" && typeof fields.syscall === "string" && fields.syscall.startsWith("spawn");
2108
+ var isGitFailure = (fields) => typeof fields.code === "number" || typeof fields.signal === "string" && fields.signal !== "";
2109
+ var describeFatalError = (error) => {
2110
+ const fields = fieldsOf(error);
2111
+ if (isSpawnFailure(fields)) {
2112
+ const binary = trimmedStringOf(fields.path) || "git";
2113
+ return {
2114
+ message: `failed to run ${binary}: no such executable. ${GIT_NOT_FOUND_HINT}`,
2115
+ exitCode: EXIT_GENERAL_ERROR
2116
+ };
2117
+ }
2118
+ if (isGitFailure(fields)) {
2119
+ const stderr = trimmedStringOf(fields.stderr);
2120
+ return {
2121
+ message: stderr.length > 0 ? stderr : messageOf(error),
2122
+ exitCode: EXIT_SAFE_REJECTION
2123
+ };
2124
+ }
2125
+ return { message: messageOf(error), exitCode: EXIT_GENERAL_ERROR };
2126
+ };
2127
+
2128
+ // src/render.ts
2129
+ var PLAIN_JSON_INDENT = 2;
2130
+ var render = (command, result, json) => {
2131
+ if (json) {
2132
+ const envelope = {
2133
+ schemaVersion: 1,
2134
+ command,
2135
+ data: result.ok ? result.data : undefined,
2136
+ warnings: result.warnings ?? []
2137
+ };
2138
+ process.stdout.write(`${JSON.stringify(envelope)}
2139
+ `);
2140
+ if (!result.ok && result.errorMessage !== undefined) {
2141
+ process.stderr.write(`${result.errorMessage}
2142
+ `);
2143
+ }
2144
+ return;
2145
+ }
2146
+ if (result.path !== undefined) {
2147
+ process.stdout.write(`${result.path}
2148
+ `);
2149
+ } else if (result.ok && result.data !== undefined) {
2150
+ process.stdout.write(`${JSON.stringify(result.data, null, PLAIN_JSON_INDENT)}
2151
+ `);
2152
+ }
2153
+ for (const warning of result.warnings ?? []) {
2154
+ process.stderr.write(`warning: ${warning}
2155
+ `);
2156
+ }
2157
+ if (!result.ok && result.errorMessage !== undefined) {
2158
+ process.stderr.write(`${result.errorMessage}
2159
+ `);
2160
+ }
2161
+ };
2162
+ var reportResult = (command, result, json) => {
2163
+ render(command, result, json);
2164
+ process.exitCode = result.exitCode;
2165
+ };
2166
+
2167
+ // src/cli-fatal.ts
2168
+ var wantsJson = (args) => args.some((arg) => arg === "--json" || arg.startsWith("--json="));
2169
+ var reportFatalError = (command, json, error) => {
2170
+ const fatal = describeFatalError(error);
2171
+ const result = fail(fatal.exitCode, `hop: ${fatal.message}`);
2172
+ reportResult(command, result, json);
2173
+ };
2174
+
2075
2175
  // src/infra/lock.ts
2076
2176
  import { randomUUID } from "node:crypto";
2077
2177
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
@@ -2168,17 +2268,27 @@ var acquireRepoLock = async (commonDir, ttlMs = DEFAULT_TTL_MS) => {
2168
2268
  }
2169
2269
  };
2170
2270
  };
2171
- var acquireRepoLockOrRejection = async (commonDir, ttlMs) => {
2271
+ var acquireUnlessHeld = async (commonDir) => {
2172
2272
  try {
2173
- const lock = await acquireRepoLock(commonDir, ttlMs);
2174
- return { ok: true, lock };
2273
+ return await acquireRepoLock(commonDir);
2175
2274
  } catch (error) {
2176
2275
  if (error instanceof LockHeldError) {
2177
- return { ok: false, rejection: fail(EXIT_SAFE_REJECTION, error.message) };
2276
+ return error;
2178
2277
  }
2179
2278
  throw error;
2180
2279
  }
2181
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
+ };
2182
2292
 
2183
2293
  // src/infra/repo.ts
2184
2294
  import { basename, dirname, join as join2 } from "node:path";
@@ -2286,6 +2396,21 @@ var parsePorcelain = (output) => {
2286
2396
  return result;
2287
2397
  };
2288
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
+
2289
2414
  // src/infra/repo.ts
2290
2415
  var loadRepoContext = async (git, fs, cwd) => {
2291
2416
  const [commonDir, porcelainOut] = await Promise.all([
@@ -2309,8 +2434,34 @@ var loadRepoContext = async (git, fs, cwd) => {
2309
2434
  }));
2310
2435
  return { rootPath, managedRoot, commonDir, worktrees };
2311
2436
  };
2312
- var otherWorktreePaths = (worktrees, path) => nestedWorktrees(worktrees, path).map((wt) => wt.path);
2437
+ var nestedWorktreePaths = (worktrees, path) => nestedWorktrees(worktrees, path).map((wt) => wt.path);
2313
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
+ };
2314
2465
  var realpathOrRaw = async (fs, path) => {
2315
2466
  try {
2316
2467
  return await fs.realpath(path);
@@ -2321,6 +2472,7 @@ var realpathOrRaw = async (fs, path) => {
2321
2472
 
2322
2473
  // src/commands/root-holder-swap.ts
2323
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").`);
2324
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.`);
2325
2477
  var resolveHolderSwap = async ({
2326
2478
  git,
@@ -2360,7 +2512,10 @@ var resolveHolderSwap = async ({
2360
2512
  rejection: holderRejection(target, freshHolder.path, `is locked by git${freshHolder.lockReason === null ? "" : ` (${freshHolder.lockReason})`}`)
2361
2513
  };
2362
2514
  }
2363
- 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);
2364
2519
  if (holderDirty) {
2365
2520
  return {
2366
2521
  rejection: holderRejection(target, freshHolder.path, "has uncommitted or untracked changes")
@@ -2373,20 +2528,19 @@ var resolveHolderSwap = async ({
2373
2528
  // src/commands/root.ts
2374
2529
  var root = async (git, fs, options) => {
2375
2530
  const context = await loadRepoContext(git, fs, options.cwd);
2376
- const rootWorktree = context.worktrees.find((wt) => wt.kind === "root");
2377
2531
  if (options.target === undefined) {
2378
2532
  return ok({
2379
2533
  path: context.rootPath,
2380
2534
  data: {
2381
- branch: rootWorktree?.branch ?? null,
2535
+ branch: rootBranchOf(context),
2382
2536
  switched: false,
2383
2537
  detachedHolder: null
2384
2538
  }
2385
2539
  });
2386
2540
  }
2387
- const dirty = await git.isDirty(context.rootPath, otherWorktreePaths(context.worktrees, context.rootPath));
2388
- if (dirty) {
2389
- 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;
2390
2544
  }
2391
2545
  if (options.target === "-") {
2392
2546
  return switchAndReport({
@@ -2398,57 +2552,64 @@ var root = async (git, fs, options) => {
2398
2552
  holderPolicy: { allowExternal: true }
2399
2553
  });
2400
2554
  }
2401
- const localBranches = await git.listBranches(context.rootPath);
2402
- const branchExistsLocally = localBranches.includes(options.target);
2403
- const { track: initialTrack } = options;
2404
- let track = initialTrack;
2405
- if (!branchExistsLocally && track === undefined) {
2406
- const remotes = await git.remotesWithBranch(context.rootPath, options.target);
2407
- const [firstRemote] = remotes;
2408
- if (remotes.includes("origin")) {
2409
- track = `origin/${options.target}`;
2410
- } else if (remotes.length === 1 && firstRemote !== undefined) {
2411
- track = `${firstRemote}/${options.target}`;
2412
- } else if (remotes.length > 1) {
2413
- return fail(EXIT_USAGE_ERROR, `Branch "${options.target}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`);
2414
- }
2555
+ const checkout = await resolveBranchCheckout(git, context.rootPath, options.target, options.track);
2556
+ if (!checkout.ok) {
2557
+ return checkout.rejection;
2415
2558
  }
2416
2559
  return switchAndReport({
2417
2560
  git,
2418
2561
  fs,
2419
2562
  context,
2420
2563
  target: options.target,
2421
- switchOptions: {
2422
- createBranch: !branchExistsLocally,
2423
- ...track === undefined ? {} : { track }
2424
- },
2564
+ switchOptions: checkout.options,
2425
2565
  holderPolicy: {
2426
2566
  allowExternal: options.allowExternalHolderSwap ?? true,
2427
2567
  ...options.expectedHolderPath === undefined ? {} : { expectedPath: options.expectedHolderPath }
2428
2568
  }
2429
2569
  });
2430
2570
  };
2431
- 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 = ({
2432
2598
  git,
2433
2599
  fs,
2434
2600
  context,
2435
2601
  target,
2436
2602
  switchOptions,
2437
2603
  holderPolicy
2438
- }) => {
2439
- const acquisition = await acquireRepoLockOrRejection(context.commonDir);
2440
- if (!acquisition.ok) {
2441
- return acquisition.rejection;
2442
- }
2443
- const { lock } = acquisition;
2604
+ }) => withRepoLock(context.commonDir, async () => {
2444
2605
  let detachedHolder = null;
2445
2606
  let previousBranch = null;
2446
2607
  try {
2447
2608
  const fresh = await loadRepoContext(git, fs, context.rootPath);
2448
- previousBranch = fresh.worktrees.find((wt) => wt.kind === "root")?.branch ?? null;
2449
- const stillDirty = await git.isDirty(fresh.rootPath, otherWorktreePaths(fresh.worktrees, fresh.rootPath));
2450
- if (stillDirty) {
2451
- 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;
2452
2613
  }
2453
2614
  const holderSwap = await resolveHolderSwap({
2454
2615
  git,
@@ -2465,7 +2626,7 @@ var switchAndReport = async ({
2465
2626
  let resolvedBranch = target;
2466
2627
  if (target === "-") {
2467
2628
  const afterSwitch = await loadRepoContext(git, fs, fresh.rootPath);
2468
- resolvedBranch = afterSwitch.worktrees.find((wt) => wt.kind === "root")?.branch ?? null;
2629
+ resolvedBranch = rootBranchOf(afterSwitch);
2469
2630
  }
2470
2631
  return ok({
2471
2632
  path: fresh.rootPath,
@@ -2477,61 +2638,14 @@ var switchAndReport = async ({
2477
2638
  warnings: detachedHolder === null ? [] : [`Put ${detachedHolder.path} into detached HEAD`]
2478
2639
  });
2479
2640
  } catch (error) {
2480
- const rollbackWarnings = [];
2481
- if (previousBranch !== null) {
2482
- try {
2483
- await git.switchBranch(context.rootPath, previousBranch, {});
2484
- } catch {
2485
- rollbackWarnings.push(`Could not restore ${context.rootPath} to its original branch (${previousBranch})`);
2486
- }
2487
- }
2488
- if (detachedHolder !== null) {
2489
- try {
2490
- await git.switchBranch(detachedHolder.path, detachedHolder.branch, {});
2491
- } catch {
2492
- rollbackWarnings.push(`Could not restore ${detachedHolder.path} to its original branch (${detachedHolder.branch}); it remains in detached HEAD`);
2493
- }
2494
- }
2495
- return fail(EXIT_SAFE_REJECTION, `Failed to switch root: ${error.message}`, rollbackWarnings);
2496
- } finally {
2497
- await lock.release();
2498
- }
2499
- };
2500
-
2501
- // src/render.ts
2502
- var PLAIN_JSON_INDENT = 2;
2503
- var render = (command, result, json) => {
2504
- if (json) {
2505
- const envelope = {
2506
- schemaVersion: 1,
2507
- command,
2508
- data: result.ok ? result.data : undefined,
2509
- warnings: result.warnings ?? []
2510
- };
2511
- process.stdout.write(`${JSON.stringify(envelope)}
2512
- `);
2513
- if (!result.ok && result.errorMessage !== undefined) {
2514
- process.stderr.write(`${result.errorMessage}
2515
- `);
2516
- }
2517
- return;
2518
- }
2519
- if (result.path !== undefined) {
2520
- process.stdout.write(`${result.path}
2521
- `);
2522
- } else if (result.ok && result.data !== undefined) {
2523
- process.stdout.write(`${JSON.stringify(result.data, null, PLAIN_JSON_INDENT)}
2524
- `);
2525
- }
2526
- for (const warning of result.warnings ?? []) {
2527
- process.stderr.write(`warning: ${warning}
2528
- `);
2529
- }
2530
- if (!result.ok && result.errorMessage !== undefined) {
2531
- process.stderr.write(`${result.errorMessage}
2532
- `);
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);
2533
2647
  }
2534
- };
2648
+ });
2535
2649
 
2536
2650
  // src/cli-root-command.ts
2537
2651
  var ROOT_PREVIOUS_TOKEN = " hop-root-previous ";
@@ -2553,7 +2667,7 @@ var rewriteRootPreviousToken = (args) => {
2553
2667
  return rewritten;
2554
2668
  };
2555
2669
  var resolveRootTarget = (branch) => branch === ROOT_PREVIOUS_TOKEN ? "-" : branch;
2556
- var createRootCommand = (git, fs, applyExitCode) => defineCommand({
2670
+ var createRootCommand = (git, fs) => defineCommand({
2557
2671
  meta: {
2558
2672
  name: "root",
2559
2673
  description: "cd to the root clone, or temporarily switch its branch"
@@ -2577,8 +2691,7 @@ var createRootCommand = (git, fs, applyExitCode) => defineCommand({
2577
2691
  ...target === undefined ? {} : { target: String(target) },
2578
2692
  ...args.track === undefined ? {} : { track: String(args.track) }
2579
2693
  });
2580
- render("root", result, Boolean(args.json));
2581
- applyExitCode(result);
2694
+ reportResult("root", result, Boolean(args.json));
2582
2695
  }
2583
2696
  });
2584
2697
 
@@ -2618,7 +2731,7 @@ var pick = async (git, fs, options) => {
2618
2731
  git.listRemoteBranches(context.rootPath),
2619
2732
  Promise.all(context.worktrees.map(async (worktree) => [
2620
2733
  worktree.path,
2621
- worktree.bare ? null : await git.isDirty(worktree.path, otherWorktreePaths(context.worktrees, worktree.path))
2734
+ await worktreeDirtyState(git, fs, context.worktrees, worktree)
2622
2735
  ]))
2623
2736
  ]);
2624
2737
  const dirtyByPath = new Map(dirtyEntries);
@@ -2629,6 +2742,8 @@ var pick = async (git, fs, options) => {
2629
2742
  // src/commands/rm.ts
2630
2743
  var EXT_DEPRECATION_WARNING = "--ext is deprecated and no longer required: hop rm can now remove external worktrees without it.";
2631
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.`;
2632
2747
  var resolveTarget = (worktrees, branch, expectedPath) => {
2633
2748
  const matches = worktrees.filter((wt) => wt.branch === branch);
2634
2749
  if (expectedPath !== undefined) {
@@ -2655,7 +2770,7 @@ var nestedWorktreeRejection = (branch, targetPath, worktrees) => {
2655
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:
2656
2771
  ${listing}`);
2657
2772
  };
2658
- var targetSafetyRejection = async (git, context, target, options) => {
2773
+ var targetSafetyRejection = async ({ git, fs, context, options }, target) => {
2659
2774
  const nestedRejection = nestedWorktreeRejection(options.branch, target.path, context.worktrees);
2660
2775
  if (nestedRejection !== null) {
2661
2776
  return nestedRejection;
@@ -2663,61 +2778,64 @@ var targetSafetyRejection = async (git, context, target, options) => {
2663
2778
  if (target.locked) {
2664
2779
  return lockedRejection(options.branch, target.lockReason);
2665
2780
  }
2666
- 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) {
2667
2785
  return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
2668
2786
  }
2669
2787
  return null;
2670
2788
  };
2671
- var rm2 = async (git, fs, options) => {
2672
- const finish = (result) => options.ext ? {
2673
- ...result,
2674
- warnings: [...result.warnings ?? [], EXT_DEPRECATION_WARNING]
2675
- } : result;
2676
- const context = await loadRepoContext(git, fs, options.cwd);
2789
+ var validateRemoval = async (check) => {
2790
+ const { context, options } = check;
2677
2791
  const resolved = resolveTarget(context.worktrees, options.branch, options.expectedPath);
2678
2792
  if (resolved.rejection !== undefined) {
2679
- return finish(resolved.rejection);
2793
+ return { ok: false, rejection: resolved.rejection };
2680
2794
  }
2681
2795
  const { target } = resolved;
2682
2796
  if (target === undefined) {
2683
- 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
+ };
2684
2801
  }
2685
2802
  if (target.kind === "root") {
2686
- return finish(fail(EXIT_USAGE_ERROR, "Cannot remove the root clone."));
2687
- }
2688
- const safetyRejection = await targetSafetyRejection(git, context, target, options);
2689
- if (safetyRejection !== null) {
2690
- return finish(safetyRejection);
2803
+ return { ok: false, rejection: fail(EXIT_USAGE_ERROR, "Cannot remove the root clone.") };
2691
2804
  }
2692
- const acquisition = await acquireRepoLockOrRejection(context.commonDir);
2693
- if (!acquisition.ok) {
2694
- 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;
2695
2817
  }
2696
- const { lock } = acquisition;
2697
- try {
2698
- const fresh = await loadRepoContext(git, fs, options.cwd);
2699
- const freshResolved = resolveTarget(fresh.worktrees, options.branch, options.expectedPath);
2700
- if (freshResolved.rejection !== undefined) {
2701
- return finish(freshResolved.rejection);
2702
- }
2703
- const freshTarget = freshResolved.target;
2704
- if (freshTarget === undefined) {
2705
- return finish(fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`));
2706
- }
2707
- const freshSafetyRejection = await targetSafetyRejection(git, fresh, freshTarget, options);
2708
- if (freshSafetyRejection !== null) {
2709
- 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)}`);
2710
2833
  }
2711
- await git.removeWorktree(context.rootPath, freshTarget.path, options.force);
2712
- return finish(ok({ data: { branch: options.branch, path: freshTarget.path } }));
2713
- } catch (error) {
2714
- return finish(fail(EXIT_SAFE_REJECTION, `Failed to remove worktree: ${error.message}`));
2715
- } finally {
2716
- await lock.release();
2717
- }
2834
+ });
2718
2835
  };
2836
+ var rm2 = async (git, fs, options) => withExtWarning(options.ext, await removeWorktree(git, fs, options));
2719
2837
 
2720
- // src/ui/picker.ts
2838
+ // src/ui/picker-render.ts
2721
2839
  import { homedir } from "node:os";
2722
2840
 
2723
2841
  // src/domain/display-width.ts
@@ -2796,6 +2914,19 @@ var padToWidth = (value, width) => {
2796
2914
  return current >= width ? value : value + " ".repeat(width - current);
2797
2915
  };
2798
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
+ };
2799
2930
  var truncateToWidth = (value, width, ellipsis = DEFAULT_ELLIPSIS) => {
2800
2931
  if (displayWidth(value) <= width) {
2801
2932
  return value;
@@ -2804,17 +2935,7 @@ var truncateToWidth = (value, width, ellipsis = DEFAULT_ELLIPSIS) => {
2804
2935
  if (budget < WIDTH_ZERO) {
2805
2936
  return "";
2806
2937
  }
2807
- let kept = "";
2808
- let usedWidth = WIDTH_ZERO;
2809
- for (const grapheme of graphemes(value)) {
2810
- const nextWidth = usedWidth + graphemeWidth(grapheme);
2811
- if (nextWidth > budget) {
2812
- break;
2813
- }
2814
- kept += grapheme;
2815
- usedWidth = nextWidth;
2816
- }
2817
- return kept + ellipsis;
2938
+ return takeWithinWidth(graphemes(value), budget).join("") + ellipsis;
2818
2939
  };
2819
2940
  var truncateToWidthKeepingTail = (value, width, ellipsis = DEFAULT_ELLIPSIS) => {
2820
2941
  if (displayWidth(value) <= width) {
@@ -2824,21 +2945,14 @@ var truncateToWidthKeepingTail = (value, width, ellipsis = DEFAULT_ELLIPSIS) =>
2824
2945
  if (budget < WIDTH_ZERO) {
2825
2946
  return "";
2826
2947
  }
2827
- let kept = "";
2828
- let usedWidth = WIDTH_ZERO;
2829
- for (const grapheme of graphemes(value).toReversed()) {
2830
- const nextWidth = usedWidth + graphemeWidth(grapheme);
2831
- if (nextWidth > budget) {
2832
- break;
2833
- }
2834
- kept = grapheme + kept;
2835
- usedWidth = nextWidth;
2836
- }
2837
- return ellipsis + kept;
2948
+ const keptFromEnd = takeWithinWidth(graphemes(value).toReversed(), budget);
2949
+ return ellipsis + keptFromEnd.toReversed().join("");
2838
2950
  };
2839
2951
 
2840
- // src/ui/picker-frame.ts
2952
+ // src/ui/ansi.ts
2841
2953
  var ESC = "\x1B";
2954
+
2955
+ // src/ui/picker-frame.ts
2842
2956
  var CURSOR_HOME = `${ESC}[H`;
2843
2957
  var CLEAR_TO_END = `${ESC}[J`;
2844
2958
  var SGR_RESET = `${ESC}[0m`;
@@ -2975,28 +3089,6 @@ var buildFrameLines = (left, right, stacked, colorEnabled) => {
2975
3089
  return buildSideBySide(left, right, colorEnabled);
2976
3090
  };
2977
3091
 
2978
- // src/ui/picker-viewport.ts
2979
- var DEFAULT_TERMINAL_HEIGHT = 24;
2980
- var MIN_VISIBLE_CANDIDATE_ROWS = 3;
2981
- var BASE_CHROME_ROWS = 3;
2982
- var SECTION_HEADER_ROWS = 2;
2983
- var HIDDEN_COUNT_INDICATOR_ROWS = 2;
2984
- var rowBudget = ({ terminalHeight, stackedPanelRows }) => {
2985
- const reserved = BASE_CHROME_ROWS + SECTION_HEADER_ROWS + HIDDEN_COUNT_INDICATOR_ROWS + stackedPanelRows;
2986
- return Math.max(MIN_VISIBLE_CANDIDATE_ROWS, terminalHeight - reserved);
2987
- };
2988
- var computeViewport = (total, selectedIndex, budget) => {
2989
- if (total <= budget) {
2990
- return { start: 0, end: total, hiddenAbove: 0, hiddenBelow: 0 };
2991
- }
2992
- const HALF_DIVISOR = 2;
2993
- const half = Math.floor(budget / HALF_DIVISOR);
2994
- const maxStart = total - budget;
2995
- const start = Math.min(maxStart, Math.max(0, selectedIndex - half));
2996
- const end = start + budget;
2997
- return { start, end, hiddenAbove: start, hiddenBelow: total - end };
2998
- };
2999
-
3000
3092
  // src/ui/picker-layout.ts
3001
3093
  var MAX_PATH_LENGTH = 40;
3002
3094
  var MAX_BRANCH_COLUMN_WIDTH = 24;
@@ -3067,6 +3159,15 @@ var toCandidateRow = (candidate, options) => ({
3067
3159
  kindLabel: candidateKindLabel(candidate).padEnd(KIND_COLUMN_WIDTH, " "),
3068
3160
  pathLabel: candidatePathLabel(candidate, options.homeDir, options.pathMaxLength)
3069
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
+ };
3070
3171
  var buildDisplayRows = (candidates, homeDir, indexOffset = 0, columnWidths) => {
3071
3172
  const { branchWidth, pathMaxLength } = columnWidths ?? {
3072
3173
  branchWidth: branchColumnWidth(candidates),
@@ -3078,32 +3179,11 @@ var buildDisplayRows = (candidates, homeDir, indexOffset = 0, columnWidths) => {
3078
3179
  }));
3079
3180
  const worktreeEntries = indexed.filter((entry) => isWorktreeCandidate(entry.candidate));
3080
3181
  const branchEntries = indexed.filter((entry) => isCreatableCandidate(entry.candidate));
3081
- const rows = [];
3082
- if (worktreeEntries.length > 0) {
3083
- rows.push({ kind: "header", label: "WORKTREES" });
3084
- for (const entry of worktreeEntries) {
3085
- rows.push(toCandidateRow(entry.candidate, {
3086
- index: entry.index,
3087
- section: "worktree",
3088
- branchWidth,
3089
- pathMaxLength,
3090
- homeDir
3091
- }));
3092
- }
3093
- }
3094
- if (branchEntries.length > 0) {
3095
- rows.push({ kind: "header", label: "BRANCHES — Enter creates a worktree" });
3096
- for (const entry of branchEntries) {
3097
- rows.push(toCandidateRow(entry.candidate, {
3098
- index: entry.index,
3099
- section: "branch",
3100
- branchWidth,
3101
- pathMaxLength,
3102
- homeDir
3103
- }));
3104
- }
3105
- }
3106
- 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
+ ];
3107
3187
  };
3108
3188
 
3109
3189
  // src/domain/actions.ts
@@ -3124,7 +3204,7 @@ var availableActions = (candidate) => {
3124
3204
  }
3125
3205
  return actions;
3126
3206
  };
3127
- var requiresDeleteConfirmation = (candidate) => candidate.kind === "worktree" && candidate.worktree.kind === "external";
3207
+ var requiresDeleteConfirmation = (candidate) => candidate.kind === "worktree" && (candidate.worktree.kind === "external" || candidate.worktree.prunable);
3128
3208
  var requiresSwitchRootConfirmation = (candidate) => candidate.kind === "worktree" && candidate.worktree.kind === "external";
3129
3209
 
3130
3210
  // src/ui/side-panel.ts
@@ -3180,6 +3260,9 @@ var buildConfirmPanelRows = ({
3180
3260
  if (action === "switchRoot") {
3181
3261
  rows.push([{ text: "This will put it into detached HEAD.", style: "dim" }]);
3182
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
+ }
3183
3266
  rows.push([{ text: "(y/N)", style: "dim" }]);
3184
3267
  return rows;
3185
3268
  };
@@ -3218,17 +3301,142 @@ var footerHintForWidth = (kind, width) => {
3218
3301
  return truncateToWidth(short, width);
3219
3302
  };
3220
3303
 
3221
- // src/ui/picker-keys.ts
3222
- var isCtrlJByte = (input) => input === `
3223
- `;
3224
- var resolvePickerKeyAction = (input, key) => {
3225
- if (key.escape) {
3226
- return { type: "cancel", reason: "esc" };
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 };
3227
3317
  }
3228
- if (key.ctrl && input === "c") {
3229
- return { type: "cancel", reason: "ctrlC" };
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;
3230
3340
  }
3231
- if (key.return) {
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
+
3429
+ // src/ui/picker-keys.ts
3430
+ var isCtrlJByte = (input) => input === `
3431
+ `;
3432
+ var resolvePickerKeyAction = (input, key) => {
3433
+ if (key.escape) {
3434
+ return { type: "cancel", reason: "esc" };
3435
+ }
3436
+ if (key.ctrl && input === "c") {
3437
+ return { type: "cancel", reason: "ctrlC" };
3438
+ }
3439
+ if (key.return) {
3232
3440
  return { type: "select" };
3233
3441
  }
3234
3442
  if (key.tab || key.rightArrow || key.ctrl && (input === "l" || input === "f")) {
@@ -3304,7 +3512,7 @@ var handleConfirmInput = (input, key, confirmMode, ctx) => {
3304
3512
  };
3305
3513
  var runOrConfirm = (candidate, action, ctx) => {
3306
3514
  if ((action === "delete" || action === "switchRoot") && requiresConfirmation(candidate, action)) {
3307
- ctx.setMode({ kind: "confirm", action, candidate, error: null });
3515
+ ctx.setMode({ kind: "confirm", action, candidate });
3308
3516
  return;
3309
3517
  }
3310
3518
  ctx.runAction(candidate, action);
@@ -3382,7 +3590,7 @@ var handleListInput = (input, key, ctx) => {
3382
3590
  }
3383
3591
  case "openPanel": {
3384
3592
  if (ctx.selectedCandidate !== undefined) {
3385
- ctx.setPanelIndex(0);
3593
+ ctx.setPanelIndex(() => 0);
3386
3594
  ctx.setMode({
3387
3595
  kind: "panel",
3388
3596
  candidate: ctx.selectedCandidate,
@@ -3396,24 +3604,14 @@ var handleListInput = (input, key, ctx) => {
3396
3604
  ctx.setMode({
3397
3605
  kind: "confirm",
3398
3606
  action: "delete",
3399
- candidate: ctx.selectedCandidate,
3400
- error: null
3607
+ candidate: ctx.selectedCandidate
3401
3608
  });
3402
3609
  }
3403
3610
  break;
3404
3611
  }
3405
3612
  case "rootSwitchShortcut": {
3406
3613
  if (ctx.selectedCandidate !== undefined && availableActions(ctx.selectedCandidate).includes("switchRoot")) {
3407
- if (requiresSwitchRootConfirmation(ctx.selectedCandidate)) {
3408
- ctx.setMode({
3409
- kind: "confirm",
3410
- action: "switchRoot",
3411
- candidate: ctx.selectedCandidate,
3412
- error: null
3413
- });
3414
- } else {
3415
- ctx.runAction(ctx.selectedCandidate, "switchRoot");
3416
- }
3614
+ runOrConfirm(ctx.selectedCandidate, "switchRoot", ctx);
3417
3615
  }
3418
3616
  break;
3419
3617
  }
@@ -3444,20 +3642,18 @@ var runMutation = async (callbacks, candidate, action) => {
3444
3642
  if (action === "switchRoot") {
3445
3643
  const result2 = await callbacks.switchRootHere(candidate);
3446
3644
  if (!result2.ok || result2.path === undefined) {
3447
- const transition = panelErrorTransition(candidate, result2.message ?? "Failed to switch root.");
3448
3645
  return {
3449
3646
  type: "patch",
3450
- patch: { mode: transition.mode, panelIndex: transition.panelIndex }
3647
+ patch: panelErrorTransition(candidate, result2.message ?? "Failed to switch root.")
3451
3648
  };
3452
3649
  }
3453
3650
  return { type: "exit", outcome: { type: "path", path: result2.path } };
3454
3651
  }
3455
3652
  const result = await callbacks.deleteWorktree(candidate);
3456
3653
  if (!result.ok) {
3457
- const transition = panelErrorTransition(candidate, result.message ?? "Failed to delete worktree.");
3458
3654
  return {
3459
3655
  type: "patch",
3460
- patch: { mode: transition.mode, panelIndex: transition.panelIndex }
3656
+ patch: panelErrorTransition(candidate, result.message ?? "Failed to delete worktree.")
3461
3657
  };
3462
3658
  }
3463
3659
  const fresh = await callbacks.reloadCandidates();
@@ -3543,10 +3739,7 @@ var createPickerStore = (initialCandidates, callbacks, onExit, onCancel) => {
3543
3739
  const setQuery = (updater) => {
3544
3740
  setState({ query: updater(state.query) });
3545
3741
  };
3546
- const setPanelIndexByValue = (value) => {
3547
- setState({ panelIndex: value });
3548
- };
3549
- const setPanelIndexByUpdater = (updater) => {
3742
+ const setPanelIndex = (updater) => {
3550
3743
  setState({ panelIndex: updater(state.panelIndex) });
3551
3744
  };
3552
3745
  const setMode = (mode) => {
@@ -3565,13 +3758,12 @@ var createPickerStore = (initialCandidates, callbacks, onExit, onCancel) => {
3565
3758
  handlePanelInput(input, key, mode, {
3566
3759
  panelIndex,
3567
3760
  runAction,
3568
- setPanelIndex: setPanelIndexByUpdater,
3761
+ setPanelIndex,
3569
3762
  setMode
3570
3763
  });
3571
3764
  return;
3572
3765
  }
3573
- const filtered = getFiltered(state);
3574
- const clampedIndex = Math.min(state.index, Math.max(filtered.length - 1, 0));
3766
+ const { filtered, clampedIndex } = getSnapshot();
3575
3767
  handleListInput(input, key, {
3576
3768
  selectedCandidate: filtered[clampedIndex],
3577
3769
  filteredLength: filtered.length,
@@ -3579,7 +3771,7 @@ var createPickerStore = (initialCandidates, callbacks, onExit, onCancel) => {
3579
3771
  onCancel: guardedOnCancel,
3580
3772
  setIndex,
3581
3773
  setQuery,
3582
- setPanelIndex: setPanelIndexByValue,
3774
+ setPanelIndex,
3583
3775
  setMode
3584
3776
  });
3585
3777
  };
@@ -3593,12 +3785,10 @@ var createPickerStore = (initialCandidates, callbacks, onExit, onCancel) => {
3593
3785
  };
3594
3786
 
3595
3787
  // src/ui/alt-screen.ts
3596
- var ASCII_ESCAPE_CODE = 27;
3597
- var ESCAPE = String.fromCodePoint(ASCII_ESCAPE_CODE);
3598
- var ENTER_ALT_SCREEN = `${ESCAPE}[?1049h`;
3599
- var LEAVE_ALT_SCREEN = `${ESCAPE}[?1049l`;
3600
- var HIDE_CURSOR = `${ESCAPE}[?25l`;
3601
- 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`;
3602
3792
  var enterAltScreen = (target) => {
3603
3793
  if (!target.isTTY) {
3604
3794
  return;
@@ -3789,6 +3979,10 @@ var pastedTextToEvents = (text) => [...text].filter((char) => isPrintableChar(ch
3789
3979
  var CSI_INTRODUCER_CODE = "[".codePointAt(0);
3790
3980
  var SS3_INTRODUCER_CODE = "O".codePointAt(0);
3791
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
+ ]);
3792
3986
 
3793
3987
  class PickerKeyParser {
3794
3988
  buffer = Buffer.alloc(0);
@@ -3817,19 +4011,18 @@ class PickerKeyParser {
3817
4011
  if (this.buffer.length === 0) {
3818
4012
  return null;
3819
4013
  }
3820
- const endMarker = Buffer.concat([Buffer.from([ESC2]), Buffer.from(`[${BRACKETED_PASTE_END}`)]);
3821
- const markerIndex = this.buffer.indexOf(endMarker);
4014
+ const markerIndex = this.buffer.indexOf(PASTE_END_MARKER);
3822
4015
  if (markerIndex !== -1) {
3823
4016
  const text2 = decodeUtf8(this.buffer.subarray(0, markerIndex));
3824
- this.buffer = this.buffer.subarray(markerIndex + endMarker.length);
4017
+ this.buffer = this.buffer.subarray(markerIndex + PASTE_END_MARKER.length);
3825
4018
  this.inPaste = false;
3826
4019
  return pastedTextToEvents(text2);
3827
4020
  }
3828
- const maxOverlap = Math.min(endMarker.length - 1, this.buffer.length);
4021
+ const maxOverlap = Math.min(PASTE_END_MARKER.length - 1, this.buffer.length);
3829
4022
  let overlap = 0;
3830
4023
  for (let candidate = maxOverlap;candidate > 0; candidate -= 1) {
3831
4024
  const tail = this.buffer.subarray(this.buffer.length - candidate);
3832
- if (tail.equals(endMarker.subarray(0, candidate))) {
4025
+ if (tail.equals(PASTE_END_MARKER.subarray(0, candidate))) {
3833
4026
  overlap = candidate;
3834
4027
  break;
3835
4028
  }
@@ -3926,9 +4119,8 @@ class PickerKeyParser {
3926
4119
  }
3927
4120
 
3928
4121
  // src/ui/terminal-session.ts
3929
- var ESC3 = "\x1B";
3930
- var ENABLE_BRACKETED_PASTE = `${ESC3}[?2004h`;
3931
- var DISABLE_BRACKETED_PASTE = `${ESC3}[?2004l`;
4122
+ var ENABLE_BRACKETED_PASTE = `${ESC}[?2004h`;
4123
+ var DISABLE_BRACKETED_PASTE = `${ESC}[?2004l`;
3932
4124
  var PENDING_ESCAPE_TIMEOUT_MS = 25;
3933
4125
  var EXIT_SIGTERM = 143;
3934
4126
  var EXIT_SIGHUP = 129;
@@ -4093,107 +4285,6 @@ var runTerminalSession = (createHandlers, streams = defaultStreams()) => new Pro
4093
4285
  var DEFAULT_TERMINAL_WIDTH = 80;
4094
4286
  var terminalDimension = (value, fallback) => value !== undefined && value > 0 ? value : fallback;
4095
4287
  var colorEnabled = (stderr) => stderr.isTTY === true && (process.env["NO_COLOR"] ?? "") === "";
4096
- var QUERY_PREFIX = "hop: ";
4097
- var queryLine = (query, maxWidth) => {
4098
- const hint = query.length === 0 ? " (type to filter)" : "";
4099
- const budget = Math.max(0, maxWidth - QUERY_PREFIX.length - hint.length);
4100
- return [
4101
- { text: QUERY_PREFIX },
4102
- { text: truncateToWidthKeepingTail(query, budget), style: "cyan" },
4103
- { text: hint, style: "dim" }
4104
- ];
4105
- };
4106
- var hiddenCountLine = (count, direction) => {
4107
- if (count === 0) {
4108
- return null;
4109
- }
4110
- const arrow = direction === "above" ? "↑ " : "↓ ";
4111
- const word = direction === "above" ? "above" : "below";
4112
- return [
4113
- {
4114
- text: `${arrow}${count} more ${word} (keep typing to narrow down)`,
4115
- style: "dim"
4116
- }
4117
- ];
4118
- };
4119
- var candidateRowLine = (row, selected) => {
4120
- const marker = selected ? "❯ " : " ";
4121
- const text = `${marker}${row.statusMarker} ${row.branchLabel} ${row.kindLabel} ${row.pathLabel}`;
4122
- if (selected) {
4123
- return [{ text, style: "inverse" }];
4124
- }
4125
- return [{ text, style: row.section === "branch" ? "dim" : "plain" }];
4126
- };
4127
- var displayRowLine = (row, clampedIndex) => row.kind === "header" ? [{ text: row.label, style: "bold-dim" }] : candidateRowLine(row, row.index === clampedIndex);
4128
- var buildListLines = ({
4129
- query,
4130
- maxWidth,
4131
- rows,
4132
- clampedIndex,
4133
- hiddenAbove,
4134
- hiddenBelow,
4135
- footerHint
4136
- }) => {
4137
- const lines = [queryLine(query, maxWidth)];
4138
- if (rows.length === 0) {
4139
- lines.push([{ text: "No matches.", style: "dim" }]);
4140
- }
4141
- const above = hiddenCountLine(hiddenAbove, "above");
4142
- if (above !== null) {
4143
- lines.push(above);
4144
- }
4145
- for (const row of rows) {
4146
- lines.push(displayRowLine(row, clampedIndex));
4147
- }
4148
- const below = hiddenCountLine(hiddenBelow, "below");
4149
- if (below !== null) {
4150
- lines.push(below);
4151
- }
4152
- lines.push([{ text: `(${LEGEND_TEXT})`, style: "dim" }], [{ text: footerHint, style: "dim" }]);
4153
- return lines;
4154
- };
4155
- var buildSidePanelLines = (mode, panelIndex, busy) => {
4156
- if (mode.kind === "panel") {
4157
- return wrapInBox(buildActionPanelRows({
4158
- candidate: mode.candidate,
4159
- panelIndex,
4160
- error: mode.error,
4161
- busy
4162
- }), SIDE_PANEL_WIDTH);
4163
- }
4164
- if (mode.kind === "confirm") {
4165
- return wrapInBox(buildConfirmPanelRows({ candidate: mode.candidate, action: mode.action }), SIDE_PANEL_WIDTH);
4166
- }
4167
- return null;
4168
- };
4169
- var renderPickerFrame = ({
4170
- snapshot,
4171
- width,
4172
- height,
4173
- colorEnabled: color
4174
- }) => {
4175
- const { query, filtered, clampedIndex, mode, panelIndex, busy } = snapshot;
4176
- const narrow = isNarrowTerminal(width);
4177
- const right = buildSidePanelLines(mode, panelIndex, busy);
4178
- const stacked = right !== null && narrow;
4179
- const listColumnWidth = right !== null && !stacked ? width - GUTTER.length - SIDE_PANEL_WIDTH : width;
4180
- const stackedPanelRows = stacked && right !== null ? right.length : 0;
4181
- const budget = rowBudget({ terminalHeight: height, stackedPanelRows });
4182
- const viewport = computeViewport(filtered.length, clampedIndex, budget);
4183
- const visible = filtered.slice(viewport.start, viewport.end);
4184
- const columnWidths = constrainRowColumnWidths(rawBranchColumnWidth(visible), listColumnWidth);
4185
- const rows = buildDisplayRows(visible, homedir(), viewport.start, columnWidths);
4186
- const left = buildListLines({
4187
- query,
4188
- maxWidth: listColumnWidth,
4189
- rows,
4190
- clampedIndex,
4191
- hiddenAbove: viewport.hiddenAbove,
4192
- hiddenBelow: viewport.hiddenBelow,
4193
- footerHint: footerHintForWidth(right === null ? "list" : "panel", listColumnWidth)
4194
- }).map((line) => truncateLineToWidth(line, listColumnWidth));
4195
- return buildFrame({ left, right, stacked, colorEnabled: color });
4196
- };
4197
4288
  var isCtrlC = (event2) => event2.key.ctrl && event2.input === "c";
4198
4289
  var runPicker = (candidates, callbacks) => runTerminalSession(({ requestRender, finish }) => {
4199
4290
  const store = createPickerStore(candidates, callbacks, (outcome) => finish(outcome), (reason) => finish({ type: "cancelled", reason }));
@@ -4219,8 +4310,7 @@ var runPicker = (candidates, callbacks) => runTerminalSession(({ requestRender,
4219
4310
  var loadPickCandidates = async (git, fs, json) => {
4220
4311
  const pickResult = await pick(git, fs, { cwd: process.cwd() });
4221
4312
  if (!pickResult.ok) {
4222
- render("pick", pickResult, json);
4223
- process.exitCode = pickResult.exitCode;
4313
+ reportResult("pick", pickResult, json);
4224
4314
  return null;
4225
4315
  }
4226
4316
  return pickResult.data?.candidates ?? [];
@@ -4234,6 +4324,7 @@ var deleteWorktree = async (git, fs, candidate) => {
4234
4324
  cwd: process.cwd(),
4235
4325
  branch,
4236
4326
  ...candidate.kind === "worktree" ? { expectedPath: candidate.worktree.path } : {},
4327
+ allowPrunable: candidate.kind === "worktree" && candidate.worktree.prunable,
4237
4328
  force: false,
4238
4329
  ext: false
4239
4330
  });
@@ -4284,52 +4375,1006 @@ var renderSwitchRootOutcome = (path, outcome, json) => {
4284
4375
  const branch = outcome?.branch ?? null;
4285
4376
  const detachedHolder = outcome?.detachedHolder ?? null;
4286
4377
  const warnings = outcome?.warnings ?? [];
4287
- if (json) {
4288
- render("root", ok({ path, data: { branch, switched: true, detachedHolder }, warnings }), true);
4289
- 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)`);
4290
4441
  }
4291
- process.stdout.write(`${path}
4292
- `);
4293
- for (const warning of warnings) {
4294
- process.stderr.write(`warning: ${warning}
4295
- `);
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}` };
4296
4452
  }
4453
+ return { ok: true, digest: digest.toLowerCase() };
4297
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");
4298
4463
 
4299
- // src/domain/garbage.ts
4300
- var classifyGarbage = (input) => {
4301
- if (input.prunable) {
4302
- return "prunable";
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`;
4303
4480
  }
4304
- if (!input.clean) {
4305
- return null;
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`;
4306
4483
  }
4307
- if (input.mergedIntoDefault === true) {
4308
- return "merged";
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");
4309
4489
  }
4310
- if (input.upstreamGone && input.allCommitsReachableFromDefault === true) {
4311
- return "gone";
4490
+ if (scriptPath.includes(BUNX_MARKER)) {
4491
+ return unsupported("running through bunx, which already fetches the published version on every run");
4312
4492
  }
4313
- return null;
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)");
4314
4504
  };
4315
4505
 
4316
- // src/commands/clean-candidates.ts
4317
- var buildCleanCandidates = async (git, context, ext) => {
4318
- const defaultRef = await git.resolveDefaultBranchRef(context.rootPath);
4319
- const targets = context.worktrees.filter((wt) => wt.kind === "managed" || ext && wt.kind === "external");
4320
- const results = await Promise.all(targets.map(async (wt) => {
4321
- const reason = await classifyWorktree(wt, { git, defaultRef, context });
4322
- if (reason === null) {
4323
- return null;
4324
- }
4325
- return { branch: wt.branch ?? "", path: wt.path, reason };
4326
- }));
4327
- return results.filter((candidate) => candidate !== null);
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);
4328
4515
  };
4329
- var classifyWorktree = async (wt, { git, defaultRef, context }) => {
4330
- if (wt.prunable) {
4331
- return classifyGarbage({
4332
- prunable: true,
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);
5341
+ }
5342
+ };
5343
+
5344
+ // src/domain/garbage.ts
5345
+ var classifyGarbage = (input) => {
5346
+ if (input.prunable) {
5347
+ return "prunable";
5348
+ }
5349
+ if (!input.clean) {
5350
+ return null;
5351
+ }
5352
+ if (input.mergedIntoDefault === true) {
5353
+ return "merged";
5354
+ }
5355
+ if (input.upstreamGone && input.allCommitsReachableFromDefault === true) {
5356
+ return "gone";
5357
+ }
5358
+ return null;
5359
+ };
5360
+
5361
+ // src/commands/clean-candidates.ts
5362
+ var buildCleanCandidates = async (git, fs, context, ext) => {
5363
+ const defaultRef = await git.resolveDefaultBranchRef(context.rootPath);
5364
+ const targets = context.worktrees.filter((wt) => wt.kind === "managed" || ext && wt.kind === "external");
5365
+ const results = await Promise.all(targets.map(async (wt) => {
5366
+ const reason = await classifyWorktree(wt, { git, fs, defaultRef, context });
5367
+ if (reason === null) {
5368
+ return null;
5369
+ }
5370
+ return { branch: wt.branch ?? "", path: wt.path, reason };
5371
+ }));
5372
+ return results.filter((candidate) => candidate !== null);
5373
+ };
5374
+ var classifyWorktree = async (wt, { git, fs, defaultRef, context }) => {
5375
+ if (wt.prunable) {
5376
+ return classifyGarbage({
5377
+ prunable: true,
4333
5378
  clean: false,
4334
5379
  mergedIntoDefault: "unknown",
4335
5380
  upstreamGone: false,
@@ -4340,15 +5385,15 @@ var classifyWorktree = async (wt, { git, defaultRef, context }) => {
4340
5385
  return null;
4341
5386
  }
4342
5387
  const { rootPath, worktrees } = context;
4343
- const [isClean, upstreamGone] = await Promise.all([
4344
- 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),
4345
5390
  git.isUpstreamGone(rootPath, wt.branch)
4346
5391
  ]);
4347
5392
  const mergedIntoDefault = defaultRef === null ? "unknown" : await git.isAncestor(rootPath, wt.branch, defaultRef);
4348
5393
  const allCommitsReachableFromDefault = defaultRef === null ? "unknown" : await git.hasEquivalentCommits(rootPath, wt.branch, defaultRef);
4349
5394
  const input = {
4350
5395
  prunable: false,
4351
- clean: isClean,
5396
+ clean: dirtyState === false,
4352
5397
  mergedIntoDefault,
4353
5398
  upstreamGone,
4354
5399
  allCommitsReachableFromDefault
@@ -4359,7 +5404,7 @@ var classifyWorktree = async (wt, { git, defaultRef, context }) => {
4359
5404
  // src/commands/clean.ts
4360
5405
  var clean = async (git, fs, term, options) => {
4361
5406
  const context = await loadRepoContext(git, fs, options.cwd);
4362
- const candidates = await buildCleanCandidates(git, context, options.ext);
5407
+ const candidates = await buildCleanCandidates(git, fs, context, options.ext);
4363
5408
  if (options.dryRun) {
4364
5409
  return ok({ data: { candidates } });
4365
5410
  }
@@ -4378,20 +5423,13 @@ var clean = async (git, fs, term, options) => {
4378
5423
  return ok({ data: { candidates, removed: [] } });
4379
5424
  }
4380
5425
  }
4381
- const execution = await executeClean({
5426
+ return executeClean({
4382
5427
  git,
4383
5428
  fs,
4384
5429
  context,
4385
5430
  candidates,
4386
5431
  cleanOptions: options
4387
5432
  });
4388
- if (!execution.ok) {
4389
- return execution.rejection;
4390
- }
4391
- return ok({
4392
- data: { candidates, removed: execution.removed },
4393
- ...execution.warnings.length === 0 ? {} : { warnings: execution.warnings }
4394
- });
4395
5433
  };
4396
5434
  var removeCandidate = async ({
4397
5435
  git,
@@ -4401,16 +5439,16 @@ var removeCandidate = async ({
4401
5439
  worktrees
4402
5440
  }) => {
4403
5441
  if (nestedWorktrees(worktrees, candidate.path).length > 0) {
4404
- return false;
5442
+ return "nested";
4405
5443
  }
4406
5444
  try {
4407
5445
  await git.removeWorktree(rootPath, candidate.path, false);
4408
5446
  if (deleteBranch && candidate.branch.length > 0) {
4409
5447
  await git.deleteBranch(rootPath, candidate.branch);
4410
5448
  }
4411
- return true;
5449
+ return "removed";
4412
5450
  } catch {
4413
- return false;
5451
+ return "failed";
4414
5452
  }
4415
5453
  };
4416
5454
  var canDeletePrunableBranch = async (git, rootPath, candidate, defaultRef) => {
@@ -4431,21 +5469,16 @@ var canDeletePrunableBranch = async (git, rootPath, candidate, defaultRef) => {
4431
5469
  } catch {}
4432
5470
  return false;
4433
5471
  };
4434
- var executeClean = async ({
5472
+ var executeClean = ({
4435
5473
  git,
4436
5474
  fs,
4437
5475
  context,
4438
5476
  candidates,
4439
5477
  cleanOptions
4440
- }) => {
4441
- const acquisition = await acquireRepoLockOrRejection(context.commonDir);
4442
- if (!acquisition.ok) {
4443
- return { ok: false, rejection: acquisition.rejection };
4444
- }
4445
- const { lock } = acquisition;
5478
+ }) => withRepoLock(context.commonDir, async () => {
4446
5479
  try {
4447
5480
  const fresh = await loadRepoContext(git, fs, context.rootPath);
4448
- const freshCandidates = await buildCleanCandidates(git, fresh, cleanOptions.ext);
5481
+ const freshCandidates = await buildCleanCandidates(git, fs, fresh, cleanOptions.ext);
4449
5482
  const freshByPath = new Map(freshCandidates.map((candidate) => [candidate.path, candidate]));
4450
5483
  const stillValid = candidates.flatMap((candidate) => {
4451
5484
  const freshCandidate = freshByPath.get(candidate.path);
@@ -4460,30 +5493,29 @@ var executeClean = async ({
4460
5493
  if (cleanOptions.withBranch && candidate.reason === "prunable" && candidate.branch.length > 0 && !branchSafe) {
4461
5494
  warnings.push(`Keeping branch "${candidate.branch}": prunable worktree is not confirmed merged or gone.`);
4462
5495
  }
4463
- 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") {
4464
5506
  warnings.push(`Skipped ${candidate.path}: still contains a registered worktree.`);
4465
- } else {
4466
- const success = await removeCandidate({
4467
- git,
4468
- rootPath: fresh.rootPath,
4469
- candidate,
4470
- deleteBranch,
4471
- worktrees: fresh.worktrees
4472
- });
4473
- if (success) {
4474
- removed.push(candidate.branch);
4475
- }
4476
5507
  }
4477
5508
  }
4478
- return { ok: true, removed, warnings };
5509
+ return ok({
5510
+ data: { candidates: [...candidates], removed },
5511
+ ...warnings.length === 0 ? {} : { warnings }
5512
+ });
4479
5513
  } catch (error) {
4480
- throw new Error(`Failed to clean worktrees: ${error.message}`, {
5514
+ throw new Error(`Failed to clean worktrees: ${messageOf(error)}`, {
4481
5515
  cause: error
4482
5516
  });
4483
- } finally {
4484
- await lock.release();
4485
5517
  }
4486
- };
5518
+ });
4487
5519
 
4488
5520
  // src/commands/init.ts
4489
5521
  var ZSH_TEMPLATE = `# nuthatch shell integration for zsh.
@@ -4542,7 +5574,7 @@ var renderInit = (options) => {
4542
5574
  };
4543
5575
 
4544
5576
  // src/commands/jump.ts
4545
- import { join as join3 } from "node:path";
5577
+ import { join as join5 } from "node:path";
4546
5578
 
4547
5579
  // src/domain/sanitize.ts
4548
5580
  var MAX_DIR_NAME_LENGTH = 200;
@@ -4559,10 +5591,10 @@ var shortHash = (input) => {
4559
5591
  }
4560
5592
  return (hash >>> 0).toString(HEX_RADIX).padStart(FULL_HASH_HEX_LENGTH, "0").slice(0, HASH_LENGTH);
4561
5593
  };
4562
- var baseName = (branch) => branch.replaceAll("/", "__");
5594
+ var baseName2 = (branch) => branch.replaceAll("/", "__");
4563
5595
  var truncate = (name, maxLength) => name.length > maxLength ? name.slice(0, maxLength) : name;
4564
5596
  var sanitizeBranchName = (branch, isTaken) => {
4565
- const base = truncate(baseName(branch), MAX_DIR_NAME_LENGTH);
5597
+ const base = truncate(baseName2(branch), MAX_DIR_NAME_LENGTH);
4566
5598
  if (!isTaken(base)) {
4567
5599
  return base;
4568
5600
  }
@@ -4578,7 +5610,8 @@ var sanitizeBranchName = (branch, isTaken) => {
4578
5610
  };
4579
5611
 
4580
5612
  // src/commands/jump.ts
4581
- 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) => {
4582
5615
  if (options.target === "-") {
4583
5616
  const previous = process.env.OLDPWD;
4584
5617
  if (previous === undefined || previous.length === 0) {
@@ -4592,57 +5625,35 @@ var jump = async (git, fs, term, options) => {
4592
5625
  const context = await loadRepoContext(git, fs, options.cwd);
4593
5626
  const existing = context.worktrees.find((wt) => wt.branch === options.target);
4594
5627
  if (existing !== undefined) {
4595
- return ok({
4596
- path: existing.path,
4597
- data: { branch: options.target, created: false }
4598
- });
5628
+ return existingWorktreeResult(options.target, existing.path);
4599
5629
  }
4600
5630
  if (!options.create) {
4601
- if (!term.isTTY()) {
4602
- return fail(EXIT_SAFE_REJECTION, `No worktree for branch "${options.target}". Re-run with --create to create it.`);
4603
- }
4604
5631
  return fail(EXIT_SAFE_REJECTION, `No worktree for branch "${options.target}". Re-run with --create to create it.`);
4605
5632
  }
4606
- const localBranches = await git.listBranches(context.rootPath);
4607
- const branchExistsLocally = localBranches.includes(options.target);
4608
- let track = options.track;
4609
- if (!branchExistsLocally && track === undefined) {
4610
- const remotes = await git.remotesWithBranch(context.rootPath, options.target);
4611
- if (remotes.includes("origin")) {
4612
- track = `origin/${options.target}`;
4613
- } else if (remotes.length === 1) {
4614
- track = `${remotes[0]}/${options.target}`;
4615
- } else if (remotes.length > 1) {
4616
- return fail(EXIT_USAGE_ERROR, `Branch "${options.target}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`);
4617
- }
5633
+ const checkout = await resolveBranchCheckout(git, context.rootPath, options.target, options.track);
5634
+ if (!checkout.ok) {
5635
+ return checkout.rejection;
4618
5636
  }
4619
5637
  const managedDirNames = await fs.listDirNames(context.managedRoot);
4620
5638
  const existingDirNames = new Set(managedDirNames.map((name) => name.toLowerCase()));
4621
5639
  const dirName = sanitizeBranchName(options.target, (candidate) => existingDirNames.has(candidate.toLowerCase()));
4622
- const targetPath = join3(context.managedRoot, dirName);
4623
- const lock = await acquireRepoLock(context.commonDir);
4624
- try {
4625
- const fresh = await loadRepoContext(git, fs, options.cwd);
4626
- const racedExisting = fresh.worktrees.find((wt) => wt.branch === options.target);
4627
- 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);
4628
5650
  return ok({
4629
- path: racedExisting.path,
4630
- data: { branch: options.target, created: false }
5651
+ path: targetPath,
5652
+ data: { branch: options.target, created: true }
4631
5653
  });
5654
+ } catch (error) {
5655
+ return fail(EXIT_SAFE_REJECTION, `Failed to create worktree: ${messageOf(error)}`);
4632
5656
  }
4633
- await fs.mkdir(context.managedRoot);
4634
- await git.addWorktree(context.rootPath, targetPath, options.target, {
4635
- createBranch: !branchExistsLocally,
4636
- ...track === undefined ? {} : { track }
4637
- });
4638
- } catch (error) {
4639
- return fail(EXIT_SAFE_REJECTION, `Failed to create worktree: ${error.message}`);
4640
- } finally {
4641
- await lock.release();
4642
- }
4643
- return ok({
4644
- path: targetPath,
4645
- data: { branch: options.target, created: true }
4646
5657
  });
4647
5658
  };
4648
5659
 
@@ -4650,13 +5661,13 @@ var jump = async (git, fs, term, options) => {
4650
5661
  var ls = async (git, fs, options) => {
4651
5662
  const context = await loadRepoContext(git, fs, options.cwd);
4652
5663
  const entries = await Promise.all(context.worktrees.map(async (wt) => {
4653
- const [dirty, aheadBehind] = await Promise.all([
4654
- 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),
4655
5666
  wt.branch === null ? Promise.resolve(null) : git.aheadBehind(context.rootPath, wt.branch)
4656
5667
  ]);
4657
5668
  return {
4658
5669
  ...wt,
4659
- dirty,
5670
+ dirty: dirtyState ?? false,
4660
5671
  ahead: aheadBehind?.ahead ?? null,
4661
5672
  behind: aheadBehind?.behind ?? null
4662
5673
  };
@@ -4696,26 +5707,26 @@ import { execFile as execFileCb } from "node:child_process";
4696
5707
  import { promisify } from "node:util";
4697
5708
 
4698
5709
  // src/domain/dirty.ts
4699
- var filterOutNestedWorktreePaths = (relativePaths, targetPath, otherWorktreePaths2) => {
5710
+ var filterOutNestedWorktreePaths = (relativePaths, targetPath, otherWorktreePaths) => {
4700
5711
  const target = stripTrailingSlash(targetPath);
4701
- const others = otherWorktreePaths2.map((path) => stripTrailingSlash(path)).filter((path) => path !== target);
5712
+ const others = otherWorktreePaths.map((path) => stripTrailingSlash(path)).filter((path) => path !== target);
4702
5713
  if (others.length === 0) {
4703
5714
  return [...relativePaths];
4704
5715
  }
4705
5716
  return relativePaths.filter((relativePath) => {
4706
5717
  const absolutePath = stripTrailingSlash(`${target}/${relativePath}`);
4707
- return !others.some((other) => isSameOrNested(absolutePath, other));
5718
+ return !others.some((other) => absolutePath === other || isWithin(other, absolutePath));
4708
5719
  });
4709
5720
  };
4710
- var isDirtyFromStatus = (statusOutput, targetPath, otherWorktreePaths2) => {
5721
+ var isDirtyFromStatus = (statusOutput, targetPath, otherWorktreePaths) => {
4711
5722
  const paths = parseStatusPaths(statusOutput);
4712
- return filterOutNestedWorktreePaths(paths, targetPath, otherWorktreePaths2).length > 0;
5723
+ return filterOutNestedWorktreePaths(paths, targetPath, otherWorktreePaths).length > 0;
4713
5724
  };
4714
5725
  var stripTrailingSlash = (path) => path.endsWith("/") ? path.slice(0, -1) : path;
4715
- var isSameOrNested = (childPath, otherPath) => childPath === otherPath || childPath.startsWith(`${otherPath}/`);
4716
- var isRenameOrCopyCode = (code) => code.includes("R") || code.includes("C");
5726
+ var STATUS_CODE_WIDTH = 3;
4717
5727
  var RECORDS_PER_RENAME_OR_COPY = 2;
4718
5728
  var RECORDS_PER_ORDINARY_ENTRY = 1;
5729
+ var isRenameOrCopyCode = (code) => code.includes("R") || code.includes("C");
4719
5730
  var parseStatusPaths = (statusOutput) => {
4720
5731
  const records = statusOutput.split("\x00").filter((record) => record.length > 0);
4721
5732
  const paths = [];
@@ -4732,14 +5743,53 @@ var parseStatusPaths = (statusOutput) => {
4732
5743
  }
4733
5744
  return paths;
4734
5745
  };
4735
- var STATUS_CODE_WIDTH = 3;
5746
+
5747
+ // src/infra/git-executable.ts
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) => {
5751
+ try {
5752
+ const stats = await stat2(path);
5753
+ if (!stats.isFile()) {
5754
+ return false;
5755
+ }
5756
+ await access3(path, constants3.X_OK);
5757
+ return true;
5758
+ } catch {
5759
+ return false;
5760
+ }
5761
+ };
5762
+ var resolveGitExecutable = async (env2) => {
5763
+ const candidates = gitExecutableCandidates({
5764
+ override: env2[GIT_EXECUTABLE_OVERRIDE_ENV],
5765
+ path: env2.PATH
5766
+ });
5767
+ const probes = candidates.map(async (candidate) => await isExecutableFile2(candidate) ? candidate : undefined);
5768
+ for (const probe of probes) {
5769
+ const found = await probe;
5770
+ if (found !== undefined) {
5771
+ return found;
5772
+ }
5773
+ }
5774
+ throw Object.assign(new Error(gitNotFoundMessage(candidates)), {
5775
+ code: GIT_NOT_FOUND_ERROR_CODE
5776
+ });
5777
+ };
5778
+ var createGitExecutableResolver = (env2) => {
5779
+ let cached = null;
5780
+ return () => {
5781
+ cached ??= resolveGitExecutable(env2);
5782
+ return cached;
5783
+ };
5784
+ };
4736
5785
 
4737
5786
  // src/infra/git.ts
4738
5787
  var execFile = promisify(execFileCb);
5788
+ var gitExecutable = createGitExecutableResolver(process.env);
4739
5789
  var MAX_BUFFER_BYTES = 64 * 1024 * 1024;
4740
5790
  var GIT_ANCESTOR_EXIT_CODE = 1;
4741
5791
  var run = async (cwd, args) => {
4742
- const { stdout: stdout2 } = await execFile("git", [...args], {
5792
+ const { stdout: stdout2 } = await execFile(await gitExecutable(), [...args], {
4743
5793
  cwd,
4744
5794
  maxBuffer: MAX_BUFFER_BYTES
4745
5795
  });
@@ -4768,9 +5818,9 @@ var createWorktreeMethods = () => ({
4768
5818
  const out = await run(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
4769
5819
  return out.trim();
4770
5820
  },
4771
- async isDirty(path, otherWorktreePaths2) {
5821
+ async isDirty(path, otherWorktreePaths) {
4772
5822
  const status = await run(path, ["status", "--porcelain", "-z", "--untracked-files=all"]);
4773
- return isDirtyFromStatus(status, path, otherWorktreePaths2);
5823
+ return isDirtyFromStatus(status, path, otherWorktreePaths);
4774
5824
  },
4775
5825
  async addWorktree(cwd, path, branch, options) {
4776
5826
  const args = ["worktree", "add"];
@@ -4953,7 +6003,7 @@ var createTermPort = () => ({
4953
6003
  var USAGE = `Usage: hop [command] [options]
4954
6004
 
4955
6005
  hop Pick a worktree/branch interactively and cd into it (TTY); lists worktrees otherwise
4956
- 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)
4957
6007
  hop root cd into the root clone
4958
6008
  hop - cd back to the previous worktree
4959
6009
  hop -- <branch> Escape a branch name that collides with a reserved command (ls/rm/clean/root/init/help)
@@ -4965,12 +6015,16 @@ var USAGE = `Usage: hop [command] [options]
4965
6015
  hop root - Switch the root clone back
4966
6016
  hop init zsh Print the zsh shell integration (eval "$(hop init zsh)")
4967
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
+
4968
6022
  Interactive picker keys:
4969
6023
  Enter cd into the selected candidate
4970
6024
  Tab, →, Ctrl+L, Ctrl+F Open the action panel, as a column beside the list
4971
6025
  (stacks below it instead on narrow terminals)
4972
6026
  Ctrl+X Delete the selected worktree (y/N confirmation)
4973
- 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)
4974
6028
  ↑/↓, Ctrl+P/N, Ctrl+K/J Move the selection (arrow, emacs, and vim keys all work)
4975
6029
  Esc Cancel (exit 0, no output)
4976
6030
  Ctrl+C Cancel like an interrupt (exit 130, same as SIGINT)
@@ -4978,7 +6032,7 @@ Interactive picker keys:
4978
6032
  action, c/d/r to run cd/delete/switchRoot directly, Esc/Tab/←/Ctrl+H to close
4979
6033
 
4980
6034
  Options:
4981
- --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)
4982
6036
  --json Output JSON instead of plain text
4983
6037
  --force Force removal even if the worktree is dirty (hop rm)
4984
6038
  --ext [deprecated, no-op for hop rm] hop rm no longer requires it to
@@ -4993,8 +6047,9 @@ Options:
4993
6047
  var git = createGitPort();
4994
6048
  var fs = createFsPort();
4995
6049
  var term = createTermPort();
4996
- var applyExitCode = (result) => {
4997
- process.exitCode = result.exitCode;
6050
+ var runLs = async (json) => {
6051
+ const result = await ls(git, fs, { cwd: process.cwd() });
6052
+ reportResult("ls", result, json);
4998
6053
  };
4999
6054
  var lsCommand = defineCommand({
5000
6055
  meta: { name: "ls", description: "List worktrees" },
@@ -5002,9 +6057,7 @@ var lsCommand = defineCommand({
5002
6057
  json: { type: "boolean", description: "Output JSON" }
5003
6058
  },
5004
6059
  async run({ args }) {
5005
- const result = await ls(git, fs, { cwd: process.cwd() });
5006
- render("ls", result, Boolean(args.json));
5007
- applyExitCode(result);
6060
+ await runLs(Boolean(args.json));
5008
6061
  }
5009
6062
  });
5010
6063
  var rmCommand = defineCommand({
@@ -5029,11 +6082,10 @@ var rmCommand = defineCommand({
5029
6082
  force: Boolean(args.force),
5030
6083
  ext: Boolean(args.ext)
5031
6084
  });
5032
- render("rm", result, Boolean(args.json));
5033
- applyExitCode(result);
6085
+ reportResult("rm", result, Boolean(args.json));
5034
6086
  }
5035
6087
  });
5036
- var rootCommand = createRootCommand(git, fs, applyExitCode);
6088
+ var rootCommand = createRootCommand(git, fs);
5037
6089
  var cleanCommand = defineCommand({
5038
6090
  meta: {
5039
6091
  name: "clean",
@@ -5054,8 +6106,7 @@ var cleanCommand = defineCommand({
5054
6106
  dryRun: Boolean(args.dryRun),
5055
6107
  yes: Boolean(args.yes)
5056
6108
  });
5057
- render("clean", result, Boolean(args.json));
5058
- applyExitCode(result);
6109
+ reportResult("clean", result, Boolean(args.json));
5059
6110
  }
5060
6111
  });
5061
6112
  var initCommand = defineCommand({
@@ -5079,14 +6130,13 @@ var initCommand = defineCommand({
5079
6130
  }
5080
6131
  });
5081
6132
  var runJump = async (target, options) => {
5082
- const result = await jump(git, fs, term, {
6133
+ const result = await jump(git, fs, {
5083
6134
  cwd: process.cwd(),
5084
6135
  target,
5085
6136
  create: options.create,
5086
6137
  ...options.track === undefined ? {} : { track: options.track }
5087
6138
  });
5088
- render("jump", result, options.json);
5089
- applyExitCode(result);
6139
+ reportResult("jump", result, options.json);
5090
6140
  };
5091
6141
  var RESERVED_COMMANDS = {
5092
6142
  ls: lsCommand,
@@ -5135,12 +6185,7 @@ var runInteractivePick = async (json) => {
5135
6185
  path: selected.worktree.path,
5136
6186
  data: { branch: selected.worktree.branch, created: false }
5137
6187
  });
5138
- if (json) {
5139
- render("pick", result, true);
5140
- } else {
5141
- process.stdout.write(`${selected.worktree.path}
5142
- `);
5143
- }
6188
+ render("pick", result, json);
5144
6189
  return;
5145
6190
  }
5146
6191
  await runJump(selected.branch, { create: true, json });
@@ -5152,9 +6197,7 @@ var runJumpFromArgs = async (rawArgs) => {
5152
6197
  await runInteractivePick(Boolean(args.json));
5153
6198
  return;
5154
6199
  }
5155
- const result = await ls(git, fs, { cwd: process.cwd() });
5156
- render("ls", result, Boolean(args.json));
5157
- applyExitCode(result);
6200
+ await runLs(Boolean(args.json));
5158
6201
  return;
5159
6202
  }
5160
6203
  await runJump(String(args.target), {
@@ -5164,19 +6207,47 @@ var runJumpFromArgs = async (rawArgs) => {
5164
6207
  });
5165
6208
  };
5166
6209
  var ARGV_USER_ARGS_START = 2;
5167
- var rawArgs = normalizeCliArgs(process.argv.slice(ARGV_USER_ARGS_START), process.argv0, isRunningAsCompiledBinary());
5168
- if (isHelpRequest(rawArgs)) {
5169
- process.stderr.write(USAGE);
5170
- process.exitCode = 0;
5171
- } else {
5172
- const dispatch = dispatchCliArgs(rawArgs, Object.keys(RESERVED_COMMANDS));
6210
+ var dispatchAndRun = async (dispatch) => {
5173
6211
  if (dispatch.kind === "reserved") {
5174
6212
  const command = RESERVED_COMMANDS[dispatch.name];
5175
6213
  const commandArgs = dispatch.name === "root" ? rewriteRootPreviousToken(dispatch.args) : [...dispatch.args];
5176
6214
  await runCommand(command, {
5177
6215
  rawArgs: commandArgs
5178
6216
  });
5179
- } else {
5180
- await runJumpFromArgs(dispatch.args);
6217
+ return;
5181
6218
  }
6219
+ await runJumpFromArgs(dispatch.args);
6220
+ };
6221
+ var main = async () => {
6222
+ const rawArgs = normalizeCliArgs(process.argv.slice(ARGV_USER_ARGS_START), process.argv0, isRunningAsCompiledBinary());
6223
+ if (isHelpRequest(rawArgs)) {
6224
+ process.stderr.write(USAGE);
6225
+ process.exitCode = 0;
6226
+ return;
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
+ }
6238
+ const dispatch = dispatchCliArgs(rawArgs, Object.keys(RESERVED_COMMANDS));
6239
+ try {
6240
+ await dispatchAndRun(dispatch);
6241
+ } catch (error) {
6242
+ const command = dispatch.kind === "reserved" ? dispatch.name : "jump";
6243
+ reportFatalError(command, wantsJson(rawArgs), error);
6244
+ }
6245
+ };
6246
+ try {
6247
+ await main();
6248
+ } catch (error) {
6249
+ const fatal = describeFatalError(error);
6250
+ process.stderr.write(`hop: ${fatal.message}
6251
+ `);
6252
+ process.exitCode = fatal.exitCode;
5182
6253
  }