@n-seiji/nuthatch 0.1.0 → 0.1.2

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 (3) hide show
  1. package/README.md +67 -0
  2. package/dist/cli.js +1155 -488
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -809,7 +809,18 @@ var buildPickCandidates = (worktrees, dirtyByPath, localBranches, remoteBranches
809
809
  source: "remote"
810
810
  }))
811
811
  ];
812
- }, candidateBranchLabel = (candidate) => candidate.kind === "worktree" ? candidate.worktree.branch ?? "(detached)" : candidate.branch;
812
+ }, candidateBranchLabel = (candidate) => candidate.kind === "worktree" ? candidate.worktree.branch ?? "(detached)" : candidate.branch, candidateBranchName = (candidate) => candidate.kind === "worktree" ? candidate.worktree.branch : candidate.branch;
813
+
814
+ // src/domain/result.ts
815
+ var EXIT_SUCCESS = 0, EXIT_GENERAL_ERROR = 1, EXIT_USAGE_ERROR = 2, EXIT_SAFE_REJECTION = 3, EXIT_CANCELLED = 130, ok = (fields = {}) => ({
816
+ ok: true,
817
+ exitCode: EXIT_SUCCESS,
818
+ ...fields
819
+ }), fail = (exitCode, errorMessage) => ({
820
+ ok: false,
821
+ exitCode,
822
+ errorMessage
823
+ });
813
824
 
814
825
  // node_modules/react/cjs/react.development.js
815
826
  var require_react_development = __commonJS((exports, module) => {
@@ -41982,6 +41993,182 @@ var init_build2 = __esm(async () => {
41982
41993
  init_kitty_keyboard();
41983
41994
  });
41984
41995
 
41996
+ // src/ui/alt-screen.ts
41997
+ var ASCII_ESCAPE_CODE = 27, ESCAPE2, ENTER_ALT_SCREEN, LEAVE_ALT_SCREEN, HIDE_CURSOR, SHOW_CURSOR, enterAltScreen = (target) => {
41998
+ if (!target.isTTY) {
41999
+ return;
42000
+ }
42001
+ target.write(ENTER_ALT_SCREEN + HIDE_CURSOR);
42002
+ }, leaveAltScreen = (target) => {
42003
+ if (!target.isTTY) {
42004
+ return;
42005
+ }
42006
+ target.write(SHOW_CURSOR + LEAVE_ALT_SCREEN);
42007
+ };
42008
+ var init_alt_screen = __esm(() => {
42009
+ ESCAPE2 = String.fromCodePoint(ASCII_ESCAPE_CODE);
42010
+ ENTER_ALT_SCREEN = `${ESCAPE2}[?1049h`;
42011
+ LEAVE_ALT_SCREEN = `${ESCAPE2}[?1049l`;
42012
+ HIDE_CURSOR = `${ESCAPE2}[?25l`;
42013
+ SHOW_CURSOR = `${ESCAPE2}[?25h`;
42014
+ });
42015
+
42016
+ // src/ui/alt-screen-session.ts
42017
+ var createAltScreenTarget = () => ({
42018
+ isTTY: process.stderr.isTTY === true,
42019
+ write: (data) => {
42020
+ process.stderr.write(data);
42021
+ }
42022
+ }), runInAltScreenSession = (renderElement) => new Promise((resolve) => {
42023
+ const altScreen = createAltScreenTarget();
42024
+ let hasLeftAltScreen = false;
42025
+ const leaveOnce = () => {
42026
+ if (hasLeftAltScreen) {
42027
+ return;
42028
+ }
42029
+ hasLeftAltScreen = true;
42030
+ leaveAltScreen(altScreen);
42031
+ };
42032
+ process.once("exit", leaveOnce);
42033
+ const instanceRef = {
42034
+ current: null
42035
+ };
42036
+ const handleSigint = () => {
42037
+ leaveOnce();
42038
+ instanceRef.current?.unmount();
42039
+ process.exitCode = EXIT_CANCELLED;
42040
+ process.exit(EXIT_CANCELLED);
42041
+ };
42042
+ process.once("SIGINT", handleSigint);
42043
+ const finish = (result) => {
42044
+ leaveOnce();
42045
+ process.removeListener("SIGINT", handleSigint);
42046
+ instanceRef.current?.unmount();
42047
+ resolve(result);
42048
+ };
42049
+ enterAltScreen(altScreen);
42050
+ try {
42051
+ instanceRef.current = renderElement(finish);
42052
+ } catch (error) {
42053
+ leaveOnce();
42054
+ process.removeListener("SIGINT", handleSigint);
42055
+ throw error;
42056
+ }
42057
+ });
42058
+ var init_alt_screen_session = __esm(() => {
42059
+ init_alt_screen();
42060
+ });
42061
+
42062
+ // src/ui/picker-layout.ts
42063
+ var MAX_PATH_LENGTH = 40, MAX_BRANCH_COLUMN_WIDTH = 24, LEGEND_TEXT = "●=dirty ○=clean +=未作成", NARROW_TERMINAL_WIDTH_THRESHOLD = 60, isNarrowTerminal = (columns) => columns < NARROW_TERMINAL_WIDTH_THRESHOLD, WORKTREE_KIND_LABELS, CREATABLE_SOURCE_LABELS, statusMarker = (candidate) => {
42064
+ if (candidate.kind === "creatable") {
42065
+ return "+";
42066
+ }
42067
+ if (candidate.dirty === null) {
42068
+ return " ";
42069
+ }
42070
+ return candidate.dirty ? "●" : "○";
42071
+ }, candidateKindLabel = (candidate) => candidate.kind === "worktree" ? WORKTREE_KIND_LABELS[candidate.worktree.kind] : CREATABLE_SOURCE_LABELS[candidate.source], isWorktreeCandidate = (candidate) => candidate.kind === "worktree", isCreatableCandidate = (candidate) => candidate.kind === "creatable", WORKTREE_KIND_ORDER, CREATABLE_SOURCE_ORDER, compareByBranchLabel = (a2, b2) => candidateBranchLabel(a2).localeCompare(candidateBranchLabel(b2)), detachedRank = (candidate) => candidate.worktree.branch === null ? 1 : 0, compareWorktreeCandidates = (a2, b2) => {
42072
+ const kindDiff = WORKTREE_KIND_ORDER[a2.worktree.kind] - WORKTREE_KIND_ORDER[b2.worktree.kind];
42073
+ if (kindDiff !== 0) {
42074
+ return kindDiff;
42075
+ }
42076
+ const detachedDiff = detachedRank(a2) - detachedRank(b2);
42077
+ return detachedDiff === 0 ? compareByBranchLabel(a2, b2) : detachedDiff;
42078
+ }, compareCreatableCandidates = (a2, b2) => {
42079
+ const sourceDiff = CREATABLE_SOURCE_ORDER[a2.source] - CREATABLE_SOURCE_ORDER[b2.source];
42080
+ return sourceDiff === 0 ? compareByBranchLabel(a2, b2) : sourceDiff;
42081
+ }, sortCandidatesForDisplay = (candidates) => [
42082
+ ...candidates.filter((candidate) => isWorktreeCandidate(candidate)).toSorted((a2, b2) => compareWorktreeCandidates(a2, b2)),
42083
+ ...candidates.filter((candidate) => isCreatableCandidate(candidate)).toSorted((a2, b2) => compareCreatableCandidates(a2, b2))
42084
+ ], KIND_COLUMN_WIDTH, shortenPath = (path, homeDir, maxLength = MAX_PATH_LENGTH) => {
42085
+ const withTilde = homeDir.length > 0 && (path === homeDir || path.startsWith(`${homeDir}/`)) ? `~${path.slice(homeDir.length)}` : path;
42086
+ if (withTilde.length <= maxLength) {
42087
+ return withTilde;
42088
+ }
42089
+ const ellipsis = "…";
42090
+ const keepLength = maxLength - ellipsis.length;
42091
+ return `${ellipsis}${withTilde.slice(withTilde.length - keepLength)}`;
42092
+ }, candidatePathLabel = (candidate, homeDir) => candidate.kind === "worktree" ? shortenPath(candidate.worktree.path, homeDir) : "", branchColumnWidth = (candidates) => candidates.reduce((max, candidate) => Math.min(MAX_BRANCH_COLUMN_WIDTH, Math.max(max, candidateBranchLabel(candidate).length)), 0), padBranchLabel = (label, width) => label.length >= width ? label : label.padEnd(width, " "), displayRowKey = (row) => row.kind === "header" ? `header:${row.label}` : `candidate:${row.index}`, toCandidateRow = (candidate, options) => ({
42093
+ kind: "candidate",
42094
+ index: options.index,
42095
+ section: options.section,
42096
+ statusMarker: statusMarker(candidate),
42097
+ branchLabel: padBranchLabel(candidateBranchLabel(candidate), options.branchWidth),
42098
+ kindLabel: candidateKindLabel(candidate).padEnd(KIND_COLUMN_WIDTH, " "),
42099
+ pathLabel: candidatePathLabel(candidate, options.homeDir)
42100
+ }), buildDisplayRows = (candidates, homeDir) => {
42101
+ const branchWidth = branchColumnWidth(candidates);
42102
+ const indexed = candidates.map((candidate, index) => ({ candidate, index }));
42103
+ const worktreeEntries = indexed.filter((entry) => isWorktreeCandidate(entry.candidate));
42104
+ const branchEntries = indexed.filter((entry) => isCreatableCandidate(entry.candidate));
42105
+ const rows = [];
42106
+ if (worktreeEntries.length > 0) {
42107
+ rows.push({ kind: "header", label: "WORKTREES" });
42108
+ for (const entry of worktreeEntries) {
42109
+ rows.push(toCandidateRow(entry.candidate, {
42110
+ index: entry.index,
42111
+ section: "worktree",
42112
+ branchWidth,
42113
+ homeDir
42114
+ }));
42115
+ }
42116
+ }
42117
+ if (branchEntries.length > 0) {
42118
+ rows.push({ kind: "header", label: "BRANCHES — Enter で worktree 作成" });
42119
+ for (const entry of branchEntries) {
42120
+ rows.push(toCandidateRow(entry.candidate, {
42121
+ index: entry.index,
42122
+ section: "branch",
42123
+ branchWidth,
42124
+ homeDir
42125
+ }));
42126
+ }
42127
+ }
42128
+ return rows;
42129
+ };
42130
+ var init_picker_layout = __esm(() => {
42131
+ WORKTREE_KIND_LABELS = {
42132
+ root: "root",
42133
+ managed: "managed",
42134
+ external: "ext"
42135
+ };
42136
+ CREATABLE_SOURCE_LABELS = {
42137
+ local: "local",
42138
+ remote: "remote"
42139
+ };
42140
+ WORKTREE_KIND_ORDER = {
42141
+ root: 0,
42142
+ managed: 1,
42143
+ external: 2
42144
+ };
42145
+ CREATABLE_SOURCE_ORDER = {
42146
+ local: 0,
42147
+ remote: 1
42148
+ };
42149
+ KIND_COLUMN_WIDTH = Math.max(...Object.values(WORKTREE_KIND_LABELS).map((label) => label.length), ...Object.values(CREATABLE_SOURCE_LABELS).map((label) => label.length));
42150
+ });
42151
+
42152
+ // src/domain/actions.ts
42153
+ var availableActions = (candidate) => {
42154
+ const actions = ["cd"];
42155
+ if (candidate.kind !== "worktree") {
42156
+ actions.push("switchRoot");
42157
+ return actions;
42158
+ }
42159
+ if (candidate.worktree.branch === null) {
42160
+ return actions;
42161
+ }
42162
+ if (candidate.worktree.kind === "managed") {
42163
+ actions.push("delete");
42164
+ }
42165
+ if (candidate.worktree.kind !== "root") {
42166
+ actions.push("switchRoot");
42167
+ }
42168
+ return actions;
42169
+ };
42170
+ var init_actions = () => {};
42171
+
41985
42172
  // node_modules/react/cjs/react-jsx-dev-runtime.development.js
41986
42173
  var require_react_jsx_dev_runtime_development = __commonJS((exports) => {
41987
42174
  var React11 = __toESM(require_react(), 1);
@@ -42205,111 +42392,497 @@ var require_jsx_dev_runtime = __commonJS((exports, module) => {
42205
42392
  }
42206
42393
  });
42207
42394
 
42208
- // src/ui/picker.tsx
42209
- var exports_picker = {};
42210
- __export(exports_picker, {
42211
- runPicker: () => runPicker,
42212
- candidateRowKey: () => candidateRowKey
42395
+ // src/ui/side-panel.tsx
42396
+ var jsx_dev_runtime, ACTION_LABELS, ACTION_LETTERS, SIDE_PANEL_WIDTH = 34, ActionPanel = ({ candidate, panelIndex, error, busy }) => {
42397
+ const actions = availableActions(candidate);
42398
+ return /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Box_default, {
42399
+ flexDirection: "column",
42400
+ borderStyle: "round",
42401
+ paddingX: 1,
42402
+ width: SIDE_PANEL_WIDTH,
42403
+ children: [
42404
+ /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42405
+ children: [
42406
+ "Actions for ",
42407
+ /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42408
+ color: "cyan",
42409
+ children: candidateBranchLabel(candidate)
42410
+ }, undefined, false, undefined, this)
42411
+ ]
42412
+ }, undefined, true, undefined, this),
42413
+ actions.map((action, actionIndex) => /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42414
+ inverse: actionIndex === panelIndex,
42415
+ children: `${actionIndex === panelIndex ? "> " : " "}[${ACTION_LETTERS[action]}] ${ACTION_LABELS[action]}`
42416
+ }, action, false, undefined, this)),
42417
+ busy && /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42418
+ dimColor: true,
42419
+ children: "Working…"
42420
+ }, undefined, false, undefined, this),
42421
+ error !== null && /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42422
+ color: "red",
42423
+ children: error
42424
+ }, undefined, false, undefined, this)
42425
+ ]
42426
+ }, undefined, true, undefined, this);
42427
+ }, ConfirmDeletePanel = ({ candidate }) => /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Box_default, {
42428
+ flexDirection: "column",
42429
+ borderStyle: "round",
42430
+ paddingX: 1,
42431
+ width: SIDE_PANEL_WIDTH,
42432
+ children: [
42433
+ /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42434
+ children: [
42435
+ "Delete worktree for ",
42436
+ /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42437
+ color: "cyan",
42438
+ children: candidateBranchName(candidate)
42439
+ }, undefined, false, undefined, this),
42440
+ "?"
42441
+ ]
42442
+ }, undefined, true, undefined, this),
42443
+ /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42444
+ dimColor: true,
42445
+ children: "(y/N)"
42446
+ }, undefined, false, undefined, this)
42447
+ ]
42448
+ }, undefined, true, undefined, this);
42449
+ var init_side_panel = __esm(async () => {
42450
+ await init_build2();
42451
+ init_actions();
42452
+ jsx_dev_runtime = __toESM(require_jsx_dev_runtime(), 1);
42453
+ ACTION_LABELS = {
42454
+ cd: "cd into this worktree",
42455
+ delete: "delete worktree",
42456
+ switchRoot: "switch root here"
42457
+ };
42458
+ ACTION_LETTERS = {
42459
+ cd: "c",
42460
+ delete: "d",
42461
+ switchRoot: "r"
42462
+ };
42213
42463
  });
42214
- var import_react34, jsx_dev_runtime, MAX_VISIBLE_ROWS = 15, kindTag = (candidate) => candidate.kind === "worktree" ? candidate.worktree.kind : `new (${candidate.source})`, dirtyTag = (candidate) => {
42215
- if (candidate.kind !== "worktree") {
42216
- return "";
42464
+
42465
+ // src/ui/picker-keys.ts
42466
+ var isCtrlJByte = (input) => input === `
42467
+ `, resolvePickerKeyAction = (input, key) => {
42468
+ if (key.escape) {
42469
+ return { type: "cancel", reason: "esc" };
42217
42470
  }
42218
- if (candidate.dirty === null) {
42219
- return "";
42471
+ if (key.ctrl && input === "c") {
42472
+ return { type: "cancel", reason: "ctrlC" };
42473
+ }
42474
+ if (key.return) {
42475
+ return { type: "select" };
42476
+ }
42477
+ if (key.tab || key.rightArrow || key.ctrl && (input === "l" || input === "f")) {
42478
+ return { type: "openPanel" };
42479
+ }
42480
+ if (key.ctrl && input === "x") {
42481
+ return { type: "deleteShortcut" };
42482
+ }
42483
+ if (key.ctrl && input === "r") {
42484
+ return { type: "rootSwitchShortcut" };
42220
42485
  }
42221
- return candidate.dirty ? " dirty" : "";
42222
- }, pathTag = (candidate) => candidate.kind === "worktree" ? candidate.worktree.path : "(not created yet)", matchesQuery = (candidate, query) => query.length === 0 || candidateBranchLabel(candidate).toLowerCase().includes(query.toLowerCase()), candidateRowKey = (candidate, index) => `${index}:${candidateBranchLabel(candidate)}`, Picker = ({ candidates, onSelect, onCancel }) => {
42486
+ if (key.upArrow || key.ctrl && (input === "p" || input === "k")) {
42487
+ return { type: "up" };
42488
+ }
42489
+ if (key.downArrow || key.ctrl && (input === "n" || input === "j") || isCtrlJByte(input)) {
42490
+ return { type: "down" };
42491
+ }
42492
+ if (key.ctrl && input === "u") {
42493
+ return { type: "clear" };
42494
+ }
42495
+ if (key.backspace || key.delete) {
42496
+ return { type: "backspace" };
42497
+ }
42498
+ if (input.length > 0 && !key.ctrl && !key.meta) {
42499
+ return { type: "char", char: input };
42500
+ }
42501
+ return { type: "ignore" };
42502
+ }, PANEL_LETTER_SHORTCUTS, resolvePanelKeyAction = (input, key) => {
42503
+ if (key.escape || key.tab || key.leftArrow || key.backspace || key.ctrl && input === "h") {
42504
+ return { type: "close" };
42505
+ }
42506
+ if (key.return) {
42507
+ return { type: "confirm" };
42508
+ }
42509
+ if (key.upArrow || key.ctrl && (input === "p" || input === "k")) {
42510
+ return { type: "up" };
42511
+ }
42512
+ if (key.downArrow || key.ctrl && (input === "n" || input === "j") || isCtrlJByte(input)) {
42513
+ return { type: "down" };
42514
+ }
42515
+ if (!key.ctrl && !key.meta && isPanelLetterShortcut(input)) {
42516
+ return { type: "letter", char: input };
42517
+ }
42518
+ return { type: "ignore" };
42519
+ }, isPanelLetterShortcut = (input) => PANEL_LETTER_SHORTCUTS.includes(input), resolveConfirmKeyAction = (input, key) => {
42520
+ if (!key.ctrl && !key.meta && (input === "y" || input === "Y")) {
42521
+ return { type: "yes" };
42522
+ }
42523
+ return { type: "no" };
42524
+ };
42525
+ var init_picker_keys = __esm(() => {
42526
+ PANEL_LETTER_SHORTCUTS = ["c", "d", "r"];
42527
+ });
42528
+
42529
+ // src/ui/picker-input.ts
42530
+ var handleConfirmDeleteInput = (input, key, confirmMode, ctx) => {
42531
+ const confirmAction = resolveConfirmKeyAction(input, key);
42532
+ if (confirmAction.type === "yes") {
42533
+ ctx.runAction(confirmMode.candidate, "delete");
42534
+ } else {
42535
+ ctx.setMode({ kind: "list" });
42536
+ }
42537
+ }, handlePanelInput = (input, key, panelMode, ctx) => {
42538
+ const actions = availableActions(panelMode.candidate);
42539
+ const panelAction = resolvePanelKeyAction(input, key);
42540
+ switch (panelAction.type) {
42541
+ case "close": {
42542
+ ctx.setMode({ kind: "list" });
42543
+ break;
42544
+ }
42545
+ case "up": {
42546
+ ctx.setPanelIndex((current) => Math.max(0, current - 1));
42547
+ break;
42548
+ }
42549
+ case "down": {
42550
+ ctx.setPanelIndex((current) => Math.min(actions.length - 1, current + 1));
42551
+ break;
42552
+ }
42553
+ case "confirm": {
42554
+ const chosen = actions[Math.min(ctx.panelIndex, actions.length - 1)];
42555
+ if (chosen !== undefined) {
42556
+ ctx.runAction(panelMode.candidate, chosen);
42557
+ }
42558
+ break;
42559
+ }
42560
+ case "letter": {
42561
+ const chosen = actions.find((action) => ACTION_LETTERS[action] === panelAction.char);
42562
+ if (chosen !== undefined) {
42563
+ ctx.runAction(panelMode.candidate, chosen);
42564
+ }
42565
+ break;
42566
+ }
42567
+ case "ignore": {
42568
+ break;
42569
+ }
42570
+ }
42571
+ }, handleListInput = (input, key, ctx) => {
42572
+ const action = resolvePickerKeyAction(input, key);
42573
+ switch (action.type) {
42574
+ case "cancel": {
42575
+ ctx.onCancel(action.reason);
42576
+ break;
42577
+ }
42578
+ case "select": {
42579
+ if (ctx.selectedCandidate !== undefined) {
42580
+ ctx.runAction(ctx.selectedCandidate, "cd");
42581
+ }
42582
+ break;
42583
+ }
42584
+ case "up": {
42585
+ ctx.setIndex((current) => Math.max(0, current - 1));
42586
+ break;
42587
+ }
42588
+ case "down": {
42589
+ ctx.setIndex((current) => Math.min(ctx.filteredLength - 1, current + 1));
42590
+ break;
42591
+ }
42592
+ case "clear": {
42593
+ ctx.setQuery(() => "");
42594
+ ctx.setIndex(() => 0);
42595
+ break;
42596
+ }
42597
+ case "backspace": {
42598
+ ctx.setQuery((current) => current.slice(0, -1));
42599
+ ctx.setIndex(() => 0);
42600
+ break;
42601
+ }
42602
+ case "char": {
42603
+ ctx.setQuery((current) => current + action.char);
42604
+ ctx.setIndex(() => 0);
42605
+ break;
42606
+ }
42607
+ case "openPanel": {
42608
+ if (ctx.selectedCandidate !== undefined) {
42609
+ ctx.setPanelIndex(0);
42610
+ ctx.setMode({
42611
+ kind: "panel",
42612
+ candidate: ctx.selectedCandidate,
42613
+ error: null
42614
+ });
42615
+ }
42616
+ break;
42617
+ }
42618
+ case "deleteShortcut": {
42619
+ if (ctx.selectedCandidate !== undefined && availableActions(ctx.selectedCandidate).includes("delete")) {
42620
+ ctx.setMode({
42621
+ kind: "confirmDelete",
42622
+ candidate: ctx.selectedCandidate,
42623
+ error: null
42624
+ });
42625
+ }
42626
+ break;
42627
+ }
42628
+ case "rootSwitchShortcut": {
42629
+ if (ctx.selectedCandidate !== undefined && availableActions(ctx.selectedCandidate).includes("switchRoot")) {
42630
+ ctx.runAction(ctx.selectedCandidate, "switchRoot");
42631
+ }
42632
+ break;
42633
+ }
42634
+ case "ignore": {
42635
+ break;
42636
+ }
42637
+ }
42638
+ };
42639
+ var init_picker_input = __esm(async () => {
42640
+ init_actions();
42641
+ await init_side_panel();
42642
+ init_picker_keys();
42643
+ });
42644
+
42645
+ // src/ui/picker-controller.ts
42646
+ var import_react34, matchesQuery = (candidate, query) => query.length === 0 || candidateBranchLabel(candidate).toLowerCase().includes(query.toLowerCase()), panelErrorTransition = (candidate, error) => ({
42647
+ mode: { kind: "panel", candidate, error },
42648
+ panelIndex: 0
42649
+ }), usePickerController = (initialCandidates, callbacks, onExit, onCancel) => {
42650
+ const [candidates, setCandidates] = import_react34.useState(initialCandidates);
42223
42651
  const [query, setQuery] = import_react34.useState("");
42224
42652
  const [index, setIndex] = import_react34.useState(0);
42225
- const filtered = import_react34.useMemo(() => candidates.filter((candidate) => matchesQuery(candidate, query)), [candidates, query]);
42653
+ const [mode, setMode] = import_react34.useState({ kind: "list" });
42654
+ const [panelIndex, setPanelIndex] = import_react34.useState(0);
42655
+ const [busy, setBusy] = import_react34.useState(false);
42656
+ const filtered = import_react34.useMemo(() => sortCandidatesForDisplay(candidates.filter((candidate) => matchesQuery(candidate, query))), [candidates, query]);
42226
42657
  const clampedIndex = Math.min(index, Math.max(filtered.length - 1, 0));
42227
- use_input_default((input, key) => {
42228
- if (key.escape || key.ctrl && input === "c") {
42229
- onCancel();
42658
+ const selectedCandidate = filtered[clampedIndex];
42659
+ const applyMutation = async (candidate, action) => {
42660
+ if (action === "switchRoot") {
42661
+ const result2 = await callbacks.switchRootHere(candidate);
42662
+ if (!result2.ok || result2.path === undefined) {
42663
+ const transition = panelErrorTransition(candidate, result2.message ?? "Failed to switch root.");
42664
+ setPanelIndex(transition.panelIndex);
42665
+ setMode(transition.mode);
42666
+ return;
42667
+ }
42668
+ onExit({ type: "path", path: result2.path });
42230
42669
  return;
42231
42670
  }
42232
- if (key.return) {
42233
- const selected = filtered[clampedIndex];
42234
- if (selected !== undefined) {
42235
- onSelect(selected);
42236
- }
42671
+ const result = await callbacks.deleteWorktree(candidate);
42672
+ if (!result.ok) {
42673
+ const transition = panelErrorTransition(candidate, result.message ?? "Failed to delete worktree.");
42674
+ setPanelIndex(transition.panelIndex);
42675
+ setMode(transition.mode);
42676
+ return;
42677
+ }
42678
+ const fresh = await callbacks.reloadCandidates();
42679
+ setCandidates(fresh);
42680
+ setMode({ kind: "list" });
42681
+ setIndex(0);
42682
+ };
42683
+ const runAction = (candidate, action) => {
42684
+ if (action === "cd") {
42685
+ onExit({ type: "cd", candidate });
42237
42686
  return;
42238
42687
  }
42239
- if (key.upArrow) {
42240
- setIndex((current) => Math.max(0, current - 1));
42688
+ if (busy) {
42241
42689
  return;
42242
42690
  }
42243
- if (key.downArrow) {
42244
- setIndex((current) => Math.min(filtered.length - 1, current + 1));
42691
+ setBusy(true);
42692
+ (async () => {
42693
+ await applyMutation(candidate, action);
42694
+ setBusy(false);
42695
+ })();
42696
+ };
42697
+ const handleInput = (input, key) => {
42698
+ if (busy) {
42245
42699
  return;
42246
42700
  }
42247
- if (key.backspace || key.delete) {
42248
- setQuery((current) => current.slice(0, -1));
42249
- setIndex(0);
42701
+ if (mode.kind === "confirmDelete") {
42702
+ handleConfirmDeleteInput(input, key, mode, { runAction, setMode });
42250
42703
  return;
42251
42704
  }
42252
- if (input.length > 0 && !key.ctrl && !key.meta) {
42253
- setQuery((current) => current + input);
42254
- setIndex(0);
42705
+ if (mode.kind === "panel") {
42706
+ handlePanelInput(input, key, mode, {
42707
+ panelIndex,
42708
+ runAction,
42709
+ setPanelIndex,
42710
+ setMode
42711
+ });
42712
+ return;
42255
42713
  }
42256
- });
42714
+ handleListInput(input, key, {
42715
+ selectedCandidate,
42716
+ filteredLength: filtered.length,
42717
+ runAction,
42718
+ onCancel,
42719
+ setIndex,
42720
+ setQuery,
42721
+ setPanelIndex: (value) => setPanelIndex(value),
42722
+ setMode
42723
+ });
42724
+ };
42725
+ return { query, filtered, clampedIndex, mode, panelIndex, busy, handleInput };
42726
+ };
42727
+ var init_picker_controller = __esm(async () => {
42728
+ import_react34 = __toESM(require_react(), 1);
42729
+ init_picker_layout();
42730
+ await init_picker_input();
42731
+ });
42732
+
42733
+ // src/ui/use-terminal-width.ts
42734
+ var import_react35, DEFAULT_TERMINAL_WIDTH = 80, useTerminalWidth = (stream) => {
42735
+ const [width, setWidth] = import_react35.useState(() => stream.columns ?? DEFAULT_TERMINAL_WIDTH);
42736
+ import_react35.useEffect(() => {
42737
+ const handleResize = () => {
42738
+ setWidth(stream.columns ?? DEFAULT_TERMINAL_WIDTH);
42739
+ };
42740
+ stream.on("resize", handleResize);
42741
+ return () => {
42742
+ stream.off("resize", handleResize);
42743
+ };
42744
+ }, [stream]);
42745
+ return width;
42746
+ };
42747
+ var init_use_terminal_width = __esm(() => {
42748
+ import_react35 = __toESM(require_react(), 1);
42749
+ });
42750
+
42751
+ // src/ui/picker.tsx
42752
+ var exports_picker = {};
42753
+ __export(exports_picker, {
42754
+ runPicker: () => runPicker
42755
+ });
42756
+ import { homedir } from "node:os";
42757
+ var jsx_dev_runtime2, MAX_VISIBLE_ROWS = 15, LIST_FOOTER_HINT = "Tab/→/Ctrl+L actions · Ctrl+X delete · Ctrl+R switch root · ↑↓/Ctrl+P,N,K,J move · Enter cd · Esc cancel", PANEL_FOOTER_HINT = "↑↓/Ctrl+P,N,K,J move · Enter run · c/d/r shortcuts · Esc/Tab/←/Ctrl+H close", PickerList = ({
42758
+ query,
42759
+ rows,
42760
+ clampedIndex,
42761
+ hiddenCount,
42762
+ footerHint,
42763
+ marginRight
42764
+ }) => /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Box_default, {
42765
+ flexDirection: "column",
42766
+ marginRight,
42767
+ children: [
42768
+ /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42769
+ children: [
42770
+ "hop: ",
42771
+ /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42772
+ color: "cyan",
42773
+ children: query
42774
+ }, undefined, false, undefined, this),
42775
+ /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42776
+ dimColor: true,
42777
+ children: query.length === 0 ? " (type to filter)" : ""
42778
+ }, undefined, false, undefined, this)
42779
+ ]
42780
+ }, undefined, true, undefined, this),
42781
+ rows.length === 0 && /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42782
+ dimColor: true,
42783
+ children: "No matches."
42784
+ }, undefined, false, undefined, this),
42785
+ rows.map((row) => {
42786
+ if (row.kind === "header") {
42787
+ return /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42788
+ bold: true,
42789
+ dimColor: true,
42790
+ children: row.label
42791
+ }, displayRowKey(row), false, undefined, this);
42792
+ }
42793
+ const selected = row.index === clampedIndex;
42794
+ return /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42795
+ inverse: selected,
42796
+ dimColor: !selected && row.section === "branch",
42797
+ children: `${selected ? "❯ " : " "}${row.statusMarker} ${row.branchLabel} ${row.kindLabel} ${row.pathLabel}`
42798
+ }, displayRowKey(row), false, undefined, this);
42799
+ }),
42800
+ hiddenCount > 0 && /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42801
+ dimColor: true,
42802
+ children: [
42803
+ "... and ",
42804
+ hiddenCount,
42805
+ " more (keep typing to narrow down)"
42806
+ ]
42807
+ }, undefined, true, undefined, this),
42808
+ /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42809
+ dimColor: true,
42810
+ children: [
42811
+ "(",
42812
+ LEGEND_TEXT,
42813
+ ")"
42814
+ ]
42815
+ }, undefined, true, undefined, this),
42816
+ /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Text, {
42817
+ dimColor: true,
42818
+ children: footerHint
42819
+ }, undefined, false, undefined, this)
42820
+ ]
42821
+ }, undefined, true, undefined, this), renderSidePanel = (mode, panelIndex, busy) => {
42822
+ if (mode.kind === "panel") {
42823
+ return /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(ActionPanel, {
42824
+ candidate: mode.candidate,
42825
+ panelIndex,
42826
+ error: mode.error,
42827
+ busy
42828
+ }, undefined, false, undefined, this);
42829
+ }
42830
+ if (mode.kind === "confirmDelete") {
42831
+ return /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(ConfirmDeletePanel, {
42832
+ candidate: mode.candidate
42833
+ }, undefined, false, undefined, this);
42834
+ }
42835
+ return null;
42836
+ }, Picker = ({ candidates, callbacks, onExit, onCancel }) => {
42837
+ const { query, filtered, clampedIndex, mode, panelIndex, busy, handleInput } = usePickerController(candidates, callbacks, onExit, onCancel);
42838
+ use_input_default(handleInput);
42839
+ const width = useTerminalWidth(process.stderr);
42840
+ const narrow = isNarrowTerminal(width);
42257
42841
  const visible = filtered.slice(0, MAX_VISIBLE_ROWS);
42258
- return /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Box_default, {
42259
- flexDirection: "column",
42842
+ const hiddenCount = filtered.length - visible.length;
42843
+ const rows = buildDisplayRows(visible, homedir());
42844
+ const sidePanel = renderSidePanel(mode, panelIndex, busy);
42845
+ const list = /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(PickerList, {
42846
+ query,
42847
+ rows,
42848
+ clampedIndex,
42849
+ hiddenCount,
42850
+ footerHint: sidePanel === null ? LIST_FOOTER_HINT : PANEL_FOOTER_HINT,
42851
+ marginRight: sidePanel === null || narrow ? 0 : 1
42852
+ }, undefined, false, undefined, this);
42853
+ if (sidePanel === null) {
42854
+ return list;
42855
+ }
42856
+ if (narrow) {
42857
+ return /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Box_default, {
42858
+ flexDirection: "column",
42859
+ children: [
42860
+ list,
42861
+ sidePanel
42862
+ ]
42863
+ }, undefined, true, undefined, this);
42864
+ }
42865
+ return /* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Box_default, {
42866
+ flexDirection: "row",
42260
42867
  children: [
42261
- /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42262
- children: [
42263
- "hop: ",
42264
- /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42265
- color: "cyan",
42266
- children: query
42267
- }, undefined, false, undefined, this),
42268
- /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42269
- dimColor: true,
42270
- children: query.length === 0 ? " (type to filter)" : ""
42271
- }, undefined, false, undefined, this)
42272
- ]
42273
- }, undefined, true, undefined, this),
42274
- visible.length === 0 && /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42275
- dimColor: true,
42276
- children: "No matches."
42277
- }, undefined, false, undefined, this),
42278
- visible.map((candidate, rowIndex) => {
42279
- const selected = rowIndex === clampedIndex;
42280
- const label = candidateBranchLabel(candidate);
42281
- return /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42282
- inverse: selected,
42283
- children: `${selected ? "> " : " "}[${kindTag(candidate)}${dirtyTag(candidate)}] ${label} ${pathTag(candidate)}`
42284
- }, candidateRowKey(candidate, rowIndex), false, undefined, this);
42285
- }),
42286
- filtered.length > MAX_VISIBLE_ROWS && /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Text, {
42287
- dimColor: true,
42288
- children: [
42289
- "... and ",
42290
- filtered.length - MAX_VISIBLE_ROWS,
42291
- " more (keep typing to narrow down)"
42292
- ]
42293
- }, undefined, true, undefined, this)
42868
+ list,
42869
+ sidePanel
42294
42870
  ]
42295
42871
  }, undefined, true, undefined, this);
42296
- }, runPicker = (candidates) => new Promise((resolve) => {
42297
- const instance = render_default(/* @__PURE__ */ jsx_dev_runtime.jsxDEV(Picker, {
42298
- candidates,
42299
- onSelect: (candidate) => {
42300
- instance.unmount();
42301
- resolve(candidate);
42302
- },
42303
- onCancel: () => {
42304
- instance.unmount();
42305
- resolve(null);
42306
- }
42307
- }, undefined, false, undefined, this), { stdout: process.stderr });
42308
- });
42872
+ }, runPicker = (candidates, callbacks) => runInAltScreenSession((finish) => render_default(/* @__PURE__ */ jsx_dev_runtime2.jsxDEV(Picker, {
42873
+ candidates,
42874
+ callbacks,
42875
+ onExit: (outcome) => finish(outcome),
42876
+ onCancel: (reason) => finish({ type: "cancelled", reason })
42877
+ }, undefined, false, undefined, this), { stdout: process.stderr }));
42309
42878
  var init_picker = __esm(async () => {
42310
42879
  await init_build2();
42311
- import_react34 = __toESM(require_react(), 1);
42312
- jsx_dev_runtime = __toESM(require_jsx_dev_runtime(), 1);
42880
+ init_alt_screen_session();
42881
+ await init_picker_controller();
42882
+ init_picker_layout();
42883
+ await init_side_panel();
42884
+ init_use_terminal_width();
42885
+ jsx_dev_runtime2 = __toESM(require_jsx_dev_runtime(), 1);
42313
42886
  });
42314
42887
 
42315
42888
  // src/ui/simple-picker.ts
@@ -42317,7 +42890,7 @@ var exports_simple_picker = {};
42317
42890
  __export(exports_simple_picker, {
42318
42891
  runSimplePicker: () => runSimplePicker
42319
42892
  });
42320
- import { createInterface as createInterface2 } from "node:readline";
42893
+ import { createInterface } from "node:readline";
42321
42894
  var runSimplePicker = (candidates) => new Promise((resolve) => {
42322
42895
  if (candidates.length === 0) {
42323
42896
  process.stderr.write(`No candidates.
@@ -42332,7 +42905,7 @@ var runSimplePicker = (candidates) => new Promise((resolve) => {
42332
42905
  `);
42333
42906
  });
42334
42907
  process.stderr.write("Select a number (empty to cancel): ");
42335
- const rl = createInterface2({
42908
+ const rl = createInterface({
42336
42909
  input: process.stdin,
42337
42910
  output: process.stderr,
42338
42911
  terminal: false
@@ -43628,139 +44201,22 @@ async function runCommand(cmd, opts) {
43628
44201
  return { result };
43629
44202
  }
43630
44203
 
43631
- // src/domain/garbage.ts
43632
- var classifyGarbage = (input) => {
43633
- if (input.prunable) {
43634
- return "prunable";
43635
- }
43636
- if (!input.clean) {
43637
- return null;
43638
- }
43639
- if (input.mergedIntoDefault === true) {
43640
- return "merged";
43641
- }
43642
- if (input.upstreamGone && input.allCommitsReachableFromDefault === true) {
43643
- return "gone";
43644
- }
43645
- return null;
43646
- };
43647
-
43648
- // src/domain/result.ts
43649
- var EXIT_SUCCESS = 0;
43650
- var EXIT_GENERAL_ERROR = 1;
43651
- var EXIT_USAGE_ERROR = 2;
43652
- var EXIT_SAFE_REJECTION = 3;
43653
- var EXIT_CANCELLED = 130;
43654
- var ok = (fields = {}) => ({
43655
- ok: true,
43656
- exitCode: EXIT_SUCCESS,
43657
- ...fields
43658
- });
43659
- var fail = (exitCode, errorMessage) => ({
43660
- ok: false,
43661
- exitCode,
43662
- errorMessage
43663
- });
43664
-
43665
- // src/infra/lock.ts
43666
- import { randomUUID } from "node:crypto";
43667
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
43668
- import { join } from "node:path";
43669
-
43670
- // src/domain/lock-policy.ts
43671
- var canReclaimLock = (input) => {
43672
- if (input.processAlive !== false) {
43673
- return false;
43674
- }
43675
- return input.nowMs - input.startedAtMs > input.ttlMs;
43676
- };
43677
-
43678
- // src/infra/lock.ts
43679
- var LOCK_DIR_NAME = "nuthatch-lock";
43680
- var LOCK_INFO_FILE = "info.json";
43681
- var DEFAULT_TTL_MS = 30000;
43682
- var HEARTBEAT_INTERVAL_MS = 5000;
43683
-
43684
- class LockHeldError extends Error {
43685
- info;
43686
- constructor(info) {
43687
- super(`Repository is locked by another nuthatch process (pid ${info.pid})`);
43688
- this.name = "LockHeldError";
43689
- this.info = info;
43690
- }
43691
- }
43692
- var isProcessAlive = (pid) => {
43693
- try {
43694
- process.kill(pid, 0);
43695
- return true;
43696
- } catch (error) {
43697
- const { code } = error;
43698
- if (code === "ESRCH") {
43699
- return false;
43700
- }
43701
- if (code === "EPERM") {
43702
- return true;
43703
- }
43704
- return "unknown";
43705
- }
43706
- };
43707
- var readLockInfo = async (lockDir) => {
43708
- try {
43709
- const raw = await readFile(join(lockDir, LOCK_INFO_FILE), "utf8");
43710
- return JSON.parse(raw);
43711
- } catch {
43712
- return null;
44204
+ // src/cli-dispatch.ts
44205
+ var normalizeCliArgs = (rawArgs, argv0, stdoutIsTTY) => stdoutIsTTY && rawArgs.length === 1 && rawArgs[0] === argv0 ? [] : rawArgs;
44206
+ var HELP_FLAGS = new Set(["--help", "-h", "help"]);
44207
+ var isHelpRequest = (rawArgs) => rawArgs.length > 0 && HELP_FLAGS.has(rawArgs[0] ?? "");
44208
+ var dispatchCliArgs = (rawArgs, reservedNames) => {
44209
+ const [first, ...rest] = rawArgs;
44210
+ if (first === "--") {
44211
+ return { kind: "jump", args: rest };
43713
44212
  }
43714
- };
43715
- var writeLockInfo = async (lockDir, info) => {
43716
- await writeFile(join(lockDir, LOCK_INFO_FILE), JSON.stringify(info), "utf8");
43717
- };
43718
- var acquireRepoLock = async (commonDir, ttlMs = DEFAULT_TTL_MS) => {
43719
- const lockDir = join(commonDir, LOCK_DIR_NAME);
43720
- for (;; ) {
43721
- try {
43722
- await mkdir(lockDir);
43723
- break;
43724
- } catch (error) {
43725
- if (error.code !== "EEXIST") {
43726
- throw error;
43727
- }
43728
- const existing = await readLockInfo(lockDir);
43729
- if (existing === null) {
43730
- throw new LockHeldError({ pid: -1, startedAtMs: 0, token: "unknown" });
43731
- }
43732
- const reclaimable = canReclaimLock({
43733
- processAlive: isProcessAlive(existing.pid),
43734
- startedAtMs: existing.startedAtMs,
43735
- nowMs: Date.now(),
43736
- ttlMs
43737
- });
43738
- if (!reclaimable) {
43739
- throw new LockHeldError(existing);
43740
- }
43741
- await rm(lockDir, { recursive: true, force: true });
43742
- }
44213
+ if (first !== undefined && reservedNames.includes(first)) {
44214
+ return { kind: "reserved", name: first, args: rest };
43743
44215
  }
43744
- const info = {
43745
- pid: process.pid,
43746
- startedAtMs: Date.now(),
43747
- token: randomUUID()
43748
- };
43749
- await writeLockInfo(lockDir, info);
43750
- const heartbeat = setInterval(() => {
43751
- writeLockInfo(lockDir, { ...info, startedAtMs: Date.now() });
43752
- }, HEARTBEAT_INTERVAL_MS);
43753
- heartbeat.unref();
43754
- return {
43755
- async release() {
43756
- clearInterval(heartbeat);
43757
- await rm(lockDir, { recursive: true, force: true });
43758
- }
43759
- };
44216
+ return { kind: "jump", args: rawArgs };
43760
44217
  };
43761
-
43762
44218
  // src/infra/repo.ts
43763
- import { basename, dirname, join as join2 } from "node:path";
44219
+ import { basename, dirname, join } from "node:path";
43764
44220
 
43765
44221
  // src/domain/classify.ts
43766
44222
  var isWithin = (parent, child) => {
@@ -43877,7 +44333,7 @@ var loadRepoContext = async (git, fs, cwd) => {
43877
44333
  throw new Error("git worktree list returned no entries");
43878
44334
  }
43879
44335
  const rootPath = await realpathOrRaw(fs, firstEntry.path);
43880
- const managedRoot = join2(dirname(rootPath), "_worktree", basename(rootPath));
44336
+ const managedRoot = join(dirname(rootPath), "_worktree", basename(rootPath));
43881
44337
  const worktrees = await Promise.all(parsed.map(async (entry) => {
43882
44338
  const resolvedPath = await realpathOrRaw(fs, entry.path);
43883
44339
  return {
@@ -43896,9 +44352,378 @@ var realpathOrRaw = async (fs, path) => {
43896
44352
  }
43897
44353
  };
43898
44354
 
43899
- // src/commands/clean.ts
43900
- var clean = async (git, fs, term, options) => {
44355
+ // src/commands/pick.ts
44356
+ var pick = async (git, fs, options) => {
43901
44357
  const context = await loadRepoContext(git, fs, options.cwd);
44358
+ const [localBranches, remoteBranches, dirtyEntries] = await Promise.all([
44359
+ git.listBranches(context.rootPath),
44360
+ git.listRemoteBranches(context.rootPath),
44361
+ Promise.all(context.worktrees.map(async (worktree) => [
44362
+ worktree.path,
44363
+ worktree.bare ? null : await git.isDirty(worktree.path)
44364
+ ]))
44365
+ ]);
44366
+ const dirtyByPath = new Map(dirtyEntries);
44367
+ const candidates = buildPickCandidates(context.worktrees, dirtyByPath, localBranches, remoteBranches);
44368
+ return ok({ data: { candidates } });
44369
+ };
44370
+ // src/infra/lock.ts
44371
+ import { randomUUID } from "node:crypto";
44372
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
44373
+ import { join as join2 } from "node:path";
44374
+
44375
+ // src/domain/lock-policy.ts
44376
+ var canReclaimLock = (input) => {
44377
+ if (input.processAlive !== false) {
44378
+ return false;
44379
+ }
44380
+ return input.nowMs - input.startedAtMs > input.ttlMs;
44381
+ };
44382
+
44383
+ // src/infra/lock.ts
44384
+ var LOCK_DIR_NAME = "nuthatch-lock";
44385
+ var LOCK_INFO_FILE = "info.json";
44386
+ var DEFAULT_TTL_MS = 30000;
44387
+ var HEARTBEAT_INTERVAL_MS = 5000;
44388
+
44389
+ class LockHeldError extends Error {
44390
+ info;
44391
+ constructor(info) {
44392
+ super(`Repository is locked by another nuthatch process (pid ${info.pid})`);
44393
+ this.name = "LockHeldError";
44394
+ this.info = info;
44395
+ }
44396
+ }
44397
+ var isProcessAlive = (pid) => {
44398
+ try {
44399
+ process.kill(pid, 0);
44400
+ return true;
44401
+ } catch (error) {
44402
+ const { code } = error;
44403
+ if (code === "ESRCH") {
44404
+ return false;
44405
+ }
44406
+ if (code === "EPERM") {
44407
+ return true;
44408
+ }
44409
+ return "unknown";
44410
+ }
44411
+ };
44412
+ var readLockInfo = async (lockDir) => {
44413
+ try {
44414
+ const raw = await readFile(join2(lockDir, LOCK_INFO_FILE), "utf8");
44415
+ return JSON.parse(raw);
44416
+ } catch {
44417
+ return null;
44418
+ }
44419
+ };
44420
+ var writeLockInfo = async (lockDir, info) => {
44421
+ await writeFile(join2(lockDir, LOCK_INFO_FILE), JSON.stringify(info), "utf8");
44422
+ };
44423
+ var acquireRepoLock = async (commonDir, ttlMs = DEFAULT_TTL_MS) => {
44424
+ const lockDir = join2(commonDir, LOCK_DIR_NAME);
44425
+ for (;; ) {
44426
+ try {
44427
+ await mkdir(lockDir);
44428
+ break;
44429
+ } catch (error) {
44430
+ if (error.code !== "EEXIST") {
44431
+ throw error;
44432
+ }
44433
+ const existing = await readLockInfo(lockDir);
44434
+ if (existing === null) {
44435
+ throw new LockHeldError({ pid: -1, startedAtMs: 0, token: "unknown" });
44436
+ }
44437
+ const reclaimable = canReclaimLock({
44438
+ processAlive: isProcessAlive(existing.pid),
44439
+ startedAtMs: existing.startedAtMs,
44440
+ nowMs: Date.now(),
44441
+ ttlMs
44442
+ });
44443
+ if (!reclaimable) {
44444
+ throw new LockHeldError(existing);
44445
+ }
44446
+ await rm(lockDir, { recursive: true, force: true });
44447
+ }
44448
+ }
44449
+ const info = {
44450
+ pid: process.pid,
44451
+ startedAtMs: Date.now(),
44452
+ token: randomUUID()
44453
+ };
44454
+ await writeLockInfo(lockDir, info);
44455
+ const heartbeat = setInterval(() => {
44456
+ writeLockInfo(lockDir, { ...info, startedAtMs: Date.now() });
44457
+ }, HEARTBEAT_INTERVAL_MS);
44458
+ heartbeat.unref();
44459
+ return {
44460
+ async release() {
44461
+ clearInterval(heartbeat);
44462
+ await rm(lockDir, { recursive: true, force: true });
44463
+ }
44464
+ };
44465
+ };
44466
+
44467
+ // src/commands/rm.ts
44468
+ var rm2 = async (git, fs, options) => {
44469
+ const context = await loadRepoContext(git, fs, options.cwd);
44470
+ const target = context.worktrees.find((wt) => wt.branch === options.branch);
44471
+ if (target === undefined) {
44472
+ return fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`);
44473
+ }
44474
+ if (target.kind === "root") {
44475
+ return fail(EXIT_USAGE_ERROR, "Cannot remove the root clone.");
44476
+ }
44477
+ if (target.kind === "external" && !(options.ext && options.force)) {
44478
+ return fail(EXIT_SAFE_REJECTION, `"${options.branch}" is an external worktree not managed by nuthatch. Removal requires both --ext and --force.`);
44479
+ }
44480
+ if (!options.force) {
44481
+ const dirty = await git.isDirty(target.path);
44482
+ if (dirty) {
44483
+ return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
44484
+ }
44485
+ }
44486
+ const lock = await acquireRepoLock(context.commonDir);
44487
+ try {
44488
+ const fresh = await loadRepoContext(git, fs, options.cwd);
44489
+ const freshTarget = fresh.worktrees.find((wt) => wt.branch === options.branch);
44490
+ if (freshTarget === undefined) {
44491
+ return fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`);
44492
+ }
44493
+ if (!options.force) {
44494
+ const stillDirty = await git.isDirty(freshTarget.path);
44495
+ if (stillDirty) {
44496
+ return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
44497
+ }
44498
+ }
44499
+ await git.removeWorktree(context.rootPath, freshTarget.path, options.force);
44500
+ return ok({ data: { branch: options.branch, path: freshTarget.path } });
44501
+ } catch (error) {
44502
+ return fail(EXIT_SAFE_REJECTION, `Failed to remove worktree: ${error.message}`);
44503
+ } finally {
44504
+ await lock.release();
44505
+ }
44506
+ };
44507
+ // src/commands/root.ts
44508
+ var root = async (git, fs, options) => {
44509
+ const context = await loadRepoContext(git, fs, options.cwd);
44510
+ const rootWorktree = context.worktrees.find((wt) => wt.kind === "root");
44511
+ if (options.target === undefined) {
44512
+ return ok({
44513
+ path: context.rootPath,
44514
+ data: { branch: rootWorktree?.branch ?? null, switched: false }
44515
+ });
44516
+ }
44517
+ const dirty = await git.isDirty(context.rootPath);
44518
+ if (dirty) {
44519
+ return fail(EXIT_SAFE_REJECTION, "Root clone has uncommitted or untracked changes. Commit, stash, or discard them before switching.");
44520
+ }
44521
+ const previousBranch = rootWorktree?.branch ?? null;
44522
+ if (options.target === "-") {
44523
+ return switchAndReport({
44524
+ git,
44525
+ fs,
44526
+ context,
44527
+ target: "-",
44528
+ switchOptions: {},
44529
+ previousBranch
44530
+ });
44531
+ }
44532
+ const holder = context.worktrees.find((wt) => wt.branch === options.target && wt.kind !== "root");
44533
+ if (holder !== undefined) {
44534
+ return fail(EXIT_SAFE_REJECTION, `Branch "${options.target}" is already checked out at ${holder.path}. Not swapping — cd there instead of using hop root.`);
44535
+ }
44536
+ const localBranches = await git.listBranches(context.rootPath);
44537
+ const branchExistsLocally = localBranches.includes(options.target);
44538
+ const { track: initialTrack } = options;
44539
+ let track = initialTrack;
44540
+ if (!branchExistsLocally && track === undefined) {
44541
+ const remotes = await git.remotesWithBranch(context.rootPath, options.target);
44542
+ const [firstRemote] = remotes;
44543
+ if (remotes.includes("origin")) {
44544
+ track = `origin/${options.target}`;
44545
+ } else if (remotes.length === 1 && firstRemote !== undefined) {
44546
+ track = `${firstRemote}/${options.target}`;
44547
+ } else if (remotes.length > 1) {
44548
+ return fail(EXIT_USAGE_ERROR, `Branch "${options.target}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`);
44549
+ }
44550
+ }
44551
+ return switchAndReport({
44552
+ git,
44553
+ fs,
44554
+ context,
44555
+ target: options.target,
44556
+ switchOptions: {
44557
+ createBranch: !branchExistsLocally,
44558
+ ...track === undefined ? {} : { track }
44559
+ },
44560
+ previousBranch
44561
+ });
44562
+ };
44563
+ var switchAndReport = async ({
44564
+ git,
44565
+ fs,
44566
+ context,
44567
+ target,
44568
+ switchOptions,
44569
+ previousBranch
44570
+ }) => {
44571
+ const lock = await acquireRepoLock(context.commonDir);
44572
+ try {
44573
+ const fresh = await loadRepoContext(git, fs, context.rootPath);
44574
+ const stillDirty = await git.isDirty(fresh.rootPath);
44575
+ if (stillDirty) {
44576
+ return fail(EXIT_SAFE_REJECTION, "Root clone has uncommitted or untracked changes. Commit, stash, or discard them before switching.");
44577
+ }
44578
+ if (target !== "-") {
44579
+ const freshHolder = fresh.worktrees.find((wt) => wt.branch === target && wt.kind !== "root");
44580
+ if (freshHolder !== undefined) {
44581
+ return fail(EXIT_SAFE_REJECTION, `Branch "${target}" is already checked out at ${freshHolder.path}. Not swapping — cd there instead of using hop root.`);
44582
+ }
44583
+ }
44584
+ await git.switchBranch(fresh.rootPath, target, switchOptions);
44585
+ return ok({
44586
+ path: fresh.rootPath,
44587
+ data: { branch: target === "-" ? null : target, switched: true }
44588
+ });
44589
+ } catch (error) {
44590
+ if (previousBranch !== null) {
44591
+ try {
44592
+ await git.switchBranch(context.rootPath, previousBranch, {});
44593
+ } catch {}
44594
+ }
44595
+ return fail(EXIT_SAFE_REJECTION, `Failed to switch root: ${error.message}`);
44596
+ } finally {
44597
+ await lock.release();
44598
+ }
44599
+ };
44600
+ // src/render.ts
44601
+ var PLAIN_JSON_INDENT = 2;
44602
+ var render = (command, result, json) => {
44603
+ if (json) {
44604
+ const envelope = {
44605
+ schemaVersion: 1,
44606
+ command,
44607
+ data: result.ok ? result.data : undefined,
44608
+ warnings: result.warnings ?? []
44609
+ };
44610
+ process.stdout.write(`${JSON.stringify(envelope)}
44611
+ `);
44612
+ if (!result.ok && result.errorMessage !== undefined) {
44613
+ process.stderr.write(`${result.errorMessage}
44614
+ `);
44615
+ }
44616
+ return;
44617
+ }
44618
+ if (result.path !== undefined) {
44619
+ process.stdout.write(`${result.path}
44620
+ `);
44621
+ } else if (result.ok && result.data !== undefined) {
44622
+ process.stdout.write(`${JSON.stringify(result.data, null, PLAIN_JSON_INDENT)}
44623
+ `);
44624
+ }
44625
+ for (const warning of result.warnings ?? []) {
44626
+ process.stderr.write(`warning: ${warning}
44627
+ `);
44628
+ }
44629
+ if (!result.ok && result.errorMessage !== undefined) {
44630
+ process.stderr.write(`${result.errorMessage}
44631
+ `);
44632
+ }
44633
+ };
44634
+
44635
+ // src/cli-pick.ts
44636
+ var loadPickCandidates = async (git, fs3, json) => {
44637
+ const pickResult = await pick(git, fs3, { cwd: process.cwd() });
44638
+ if (!pickResult.ok) {
44639
+ render("pick", pickResult, json);
44640
+ process.exitCode = pickResult.exitCode;
44641
+ return null;
44642
+ }
44643
+ return pickResult.data?.candidates ?? [];
44644
+ };
44645
+ var deleteWorktree = async (git, fs3, candidate) => {
44646
+ const branch = candidateBranchName(candidate);
44647
+ if (branch === null) {
44648
+ return { ok: false, message: "This candidate has no branch to remove." };
44649
+ }
44650
+ const result = await rm2(git, fs3, {
44651
+ cwd: process.cwd(),
44652
+ branch,
44653
+ force: false,
44654
+ ext: false
44655
+ });
44656
+ if (!result.ok) {
44657
+ return {
44658
+ ok: false,
44659
+ message: result.errorMessage ?? "Failed to remove worktree."
44660
+ };
44661
+ }
44662
+ return { ok: true };
44663
+ };
44664
+ var createPickerCallbacks = (git, fs3, json, onSwitchedBranch) => ({
44665
+ deleteWorktree: (candidate) => deleteWorktree(git, fs3, candidate),
44666
+ switchRootHere: async (candidate) => {
44667
+ const branch = candidateBranchName(candidate);
44668
+ if (branch === null) {
44669
+ return {
44670
+ ok: false,
44671
+ message: "This candidate has no branch to switch to."
44672
+ };
44673
+ }
44674
+ const result = await root(git, fs3, { cwd: process.cwd(), target: branch });
44675
+ if (!result.ok) {
44676
+ return {
44677
+ ok: false,
44678
+ message: result.errorMessage ?? "Failed to switch root."
44679
+ };
44680
+ }
44681
+ onSwitchedBranch(branch);
44682
+ return {
44683
+ ok: true,
44684
+ ...result.path === undefined ? {} : { path: result.path }
44685
+ };
44686
+ },
44687
+ reloadCandidates: async () => await loadPickCandidates(git, fs3, json) ?? []
44688
+ });
44689
+ var runInteractivePicker = async (candidates, callbacks) => {
44690
+ try {
44691
+ const { runPicker: runPicker2 } = await init_picker().then(() => exports_picker);
44692
+ return await runPicker2(candidates, callbacks);
44693
+ } catch {
44694
+ const { runSimplePicker: runSimplePicker2 } = await Promise.resolve().then(() => (init_simple_picker(), exports_simple_picker));
44695
+ const selected = await runSimplePicker2(candidates);
44696
+ return selected === null ? { type: "cancelled", reason: "esc" } : { type: "cd", candidate: selected };
44697
+ }
44698
+ };
44699
+ var renderSwitchRootOutcome = (path, branch, json) => {
44700
+ if (json) {
44701
+ render("root", ok({ path, data: { branch, switched: true } }), true);
44702
+ return;
44703
+ }
44704
+ process.stdout.write(`${path}
44705
+ `);
44706
+ };
44707
+
44708
+ // src/domain/garbage.ts
44709
+ var classifyGarbage = (input) => {
44710
+ if (input.prunable) {
44711
+ return "prunable";
44712
+ }
44713
+ if (!input.clean) {
44714
+ return null;
44715
+ }
44716
+ if (input.mergedIntoDefault === true) {
44717
+ return "merged";
44718
+ }
44719
+ if (input.upstreamGone && input.allCommitsReachableFromDefault === true) {
44720
+ return "gone";
44721
+ }
44722
+ return null;
44723
+ };
44724
+ // src/commands/clean.ts
44725
+ var clean = async (git, fs3, term, options) => {
44726
+ const context = await loadRepoContext(git, fs3, options.cwd);
43902
44727
  const candidates = await buildCleanCandidates(git, context, options.ext);
43903
44728
  if (options.dryRun) {
43904
44729
  return ok({ data: { candidates } });
@@ -43920,7 +44745,7 @@ var clean = async (git, fs, term, options) => {
43920
44745
  }
43921
44746
  const execution = await executeClean({
43922
44747
  git,
43923
- fs,
44748
+ fs: fs3,
43924
44749
  context,
43925
44750
  candidates,
43926
44751
  cleanOptions: options
@@ -44001,14 +44826,14 @@ var canDeletePrunableBranch = async (git, rootPath, candidate, defaultRef) => {
44001
44826
  };
44002
44827
  var executeClean = async ({
44003
44828
  git,
44004
- fs,
44829
+ fs: fs3,
44005
44830
  context,
44006
44831
  candidates,
44007
44832
  cleanOptions
44008
44833
  }) => {
44009
44834
  const lock = await acquireRepoLock(context.commonDir);
44010
44835
  try {
44011
- const fresh = await loadRepoContext(git, fs, context.rootPath);
44836
+ const fresh = await loadRepoContext(git, fs3, context.rootPath);
44012
44837
  const freshCandidates = await buildCleanCandidates(git, fresh, cleanOptions.ext);
44013
44838
  const freshByPath = new Map(freshCandidates.map((candidate) => [candidate.path, candidate]));
44014
44839
  const stillValid = candidates.flatMap((candidate) => {
@@ -44039,19 +44864,6 @@ var executeClean = async ({
44039
44864
  }
44040
44865
  };
44041
44866
 
44042
- // src/cli-dispatch.ts
44043
- var normalizeCliArgs = (rawArgs, argv0, stdoutIsTTY) => stdoutIsTTY && rawArgs.length === 1 && rawArgs[0] === argv0 ? [] : rawArgs;
44044
- var dispatchCliArgs = (rawArgs, reservedNames) => {
44045
- const [first, ...rest] = rawArgs;
44046
- if (first === "--") {
44047
- return { kind: "jump", args: rest };
44048
- }
44049
- if (first !== undefined && reservedNames.includes(first)) {
44050
- return { kind: "reserved", name: first, args: rest };
44051
- }
44052
- return { kind: "jump", args: rawArgs };
44053
- };
44054
-
44055
44867
  // src/commands/init.ts
44056
44868
  var ZSH_TEMPLATE = `# nuthatch shell integration for zsh.
44057
44869
  # Usage: eval "$(hop init zsh)"
@@ -44129,23 +44941,23 @@ var shortHash = (input) => {
44129
44941
  var baseName = (branch) => branch.replaceAll("/", "__");
44130
44942
  var truncate = (name, maxLength) => name.length > maxLength ? name.slice(0, maxLength) : name;
44131
44943
  var sanitizeBranchName = (branch, isTaken) => {
44132
- const base = truncate(baseName(branch), MAX_DIR_NAME_LENGTH);
44133
- if (!isTaken(base)) {
44134
- return base;
44944
+ const base2 = truncate(baseName(branch), MAX_DIR_NAME_LENGTH);
44945
+ if (!isTaken(base2)) {
44946
+ return base2;
44135
44947
  }
44136
44948
  const hash = shortHash(branch);
44137
- let candidate = `${truncate(base, MAX_DIR_NAME_LENGTH - hash.length - 1)}-${hash}`;
44949
+ let candidate = `${truncate(base2, MAX_DIR_NAME_LENGTH - hash.length - 1)}-${hash}`;
44138
44950
  let suffix = 1;
44139
44951
  while (isTaken(candidate)) {
44140
44952
  const withSuffix = `${hash}-${suffix}`;
44141
- candidate = `${truncate(base, MAX_DIR_NAME_LENGTH - withSuffix.length - 1)}-${withSuffix}`;
44953
+ candidate = `${truncate(base2, MAX_DIR_NAME_LENGTH - withSuffix.length - 1)}-${withSuffix}`;
44142
44954
  suffix++;
44143
44955
  }
44144
44956
  return candidate;
44145
44957
  };
44146
44958
 
44147
44959
  // src/commands/jump.ts
44148
- var jump = async (git, fs, term, options) => {
44960
+ var jump = async (git, fs3, term, options) => {
44149
44961
  if (options.target === "-") {
44150
44962
  const previous = process.env.OLDPWD;
44151
44963
  if (previous === undefined || previous.length === 0) {
@@ -44156,7 +44968,7 @@ var jump = async (git, fs, term, options) => {
44156
44968
  data: { branch: options.target, created: false }
44157
44969
  });
44158
44970
  }
44159
- const context = await loadRepoContext(git, fs, options.cwd);
44971
+ const context = await loadRepoContext(git, fs3, options.cwd);
44160
44972
  const existing = context.worktrees.find((wt) => wt.branch === options.target);
44161
44973
  if (existing !== undefined) {
44162
44974
  return ok({
@@ -44183,13 +44995,13 @@ var jump = async (git, fs, term, options) => {
44183
44995
  return fail(EXIT_USAGE_ERROR, `Branch "${options.target}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`);
44184
44996
  }
44185
44997
  }
44186
- const managedDirNames = await fs.listDirNames(context.managedRoot);
44998
+ const managedDirNames = await fs3.listDirNames(context.managedRoot);
44187
44999
  const existingDirNames = new Set(managedDirNames.map((name) => name.toLowerCase()));
44188
45000
  const dirName = sanitizeBranchName(options.target, (candidate) => existingDirNames.has(candidate.toLowerCase()));
44189
45001
  const targetPath = join3(context.managedRoot, dirName);
44190
45002
  const lock = await acquireRepoLock(context.commonDir);
44191
45003
  try {
44192
- const fresh = await loadRepoContext(git, fs, options.cwd);
45004
+ const fresh = await loadRepoContext(git, fs3, options.cwd);
44193
45005
  const racedExisting = fresh.worktrees.find((wt) => wt.branch === options.target);
44194
45006
  if (racedExisting !== undefined) {
44195
45007
  return ok({
@@ -44197,7 +45009,7 @@ var jump = async (git, fs, term, options) => {
44197
45009
  data: { branch: options.target, created: false }
44198
45010
  });
44199
45011
  }
44200
- await fs.mkdir(context.managedRoot);
45012
+ await fs3.mkdir(context.managedRoot);
44201
45013
  await git.addWorktree(context.rootPath, targetPath, options.target, {
44202
45014
  createBranch: !branchExistsLocally,
44203
45015
  ...track === undefined ? {} : { track }
@@ -44212,10 +45024,9 @@ var jump = async (git, fs, term, options) => {
44212
45024
  data: { branch: options.target, created: true }
44213
45025
  });
44214
45026
  };
44215
-
44216
45027
  // src/commands/ls.ts
44217
- var ls = async (git, fs, options) => {
44218
- const context = await loadRepoContext(git, fs, options.cwd);
45028
+ var ls = async (git, fs3, options) => {
45029
+ const context = await loadRepoContext(git, fs3, options.cwd);
44219
45030
  const entries = await Promise.all(context.worktrees.map(async (wt) => {
44220
45031
  const [dirty, aheadBehind] = await Promise.all([
44221
45032
  wt.bare ? Promise.resolve(false) : git.isDirty(wt.path),
@@ -44230,157 +45041,6 @@ var ls = async (git, fs, options) => {
44230
45041
  }));
44231
45042
  return ok({ data: entries });
44232
45043
  };
44233
- // src/commands/pick.ts
44234
- var pick = async (git, fs, options) => {
44235
- const context = await loadRepoContext(git, fs, options.cwd);
44236
- const [localBranches, remoteBranches, dirtyEntries] = await Promise.all([
44237
- git.listBranches(context.rootPath),
44238
- git.listRemoteBranches(context.rootPath),
44239
- Promise.all(context.worktrees.map(async (worktree) => [
44240
- worktree.path,
44241
- worktree.bare ? null : await git.isDirty(worktree.path)
44242
- ]))
44243
- ]);
44244
- const dirtyByPath = new Map(dirtyEntries);
44245
- const candidates = buildPickCandidates(context.worktrees, dirtyByPath, localBranches, remoteBranches);
44246
- return ok({ data: { candidates } });
44247
- };
44248
-
44249
- // src/commands/rm.ts
44250
- var rm2 = async (git, fs, options) => {
44251
- const context = await loadRepoContext(git, fs, options.cwd);
44252
- const target = context.worktrees.find((wt) => wt.branch === options.branch);
44253
- if (target === undefined) {
44254
- return fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`);
44255
- }
44256
- if (target.kind === "root") {
44257
- return fail(EXIT_USAGE_ERROR, "Cannot remove the root clone.");
44258
- }
44259
- if (target.kind === "external" && !(options.ext && options.force)) {
44260
- return fail(EXIT_SAFE_REJECTION, `"${options.branch}" is an external worktree not managed by nuthatch. Removal requires both --ext and --force.`);
44261
- }
44262
- if (!options.force) {
44263
- const dirty = await git.isDirty(target.path);
44264
- if (dirty) {
44265
- return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
44266
- }
44267
- }
44268
- const lock = await acquireRepoLock(context.commonDir);
44269
- try {
44270
- const fresh = await loadRepoContext(git, fs, options.cwd);
44271
- const freshTarget = fresh.worktrees.find((wt) => wt.branch === options.branch);
44272
- if (freshTarget === undefined) {
44273
- return fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`);
44274
- }
44275
- if (!options.force) {
44276
- const stillDirty = await git.isDirty(freshTarget.path);
44277
- if (stillDirty) {
44278
- return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
44279
- }
44280
- }
44281
- await git.removeWorktree(context.rootPath, freshTarget.path, options.force);
44282
- return ok({ data: { branch: options.branch, path: freshTarget.path } });
44283
- } catch (error) {
44284
- return fail(EXIT_SAFE_REJECTION, `Failed to remove worktree: ${error.message}`);
44285
- } finally {
44286
- await lock.release();
44287
- }
44288
- };
44289
-
44290
- // src/commands/root.ts
44291
- var root = async (git, fs, options) => {
44292
- const context = await loadRepoContext(git, fs, options.cwd);
44293
- const rootWorktree = context.worktrees.find((wt) => wt.kind === "root");
44294
- if (options.target === undefined) {
44295
- return ok({
44296
- path: context.rootPath,
44297
- data: { branch: rootWorktree?.branch ?? null, switched: false }
44298
- });
44299
- }
44300
- const dirty = await git.isDirty(context.rootPath);
44301
- if (dirty) {
44302
- return fail(EXIT_SAFE_REJECTION, "Root clone has uncommitted or untracked changes. Commit, stash, or discard them before switching.");
44303
- }
44304
- const previousBranch = rootWorktree?.branch ?? null;
44305
- if (options.target === "-") {
44306
- return switchAndReport({
44307
- git,
44308
- fs,
44309
- context,
44310
- target: "-",
44311
- switchOptions: {},
44312
- previousBranch
44313
- });
44314
- }
44315
- const holder = context.worktrees.find((wt) => wt.branch === options.target && wt.kind !== "root");
44316
- if (holder !== undefined) {
44317
- return fail(EXIT_SAFE_REJECTION, `Branch "${options.target}" is already checked out at ${holder.path}. Not swapping — cd there instead of using hop root.`);
44318
- }
44319
- const localBranches = await git.listBranches(context.rootPath);
44320
- const branchExistsLocally = localBranches.includes(options.target);
44321
- const { track: initialTrack } = options;
44322
- let track = initialTrack;
44323
- if (!branchExistsLocally && track === undefined) {
44324
- const remotes = await git.remotesWithBranch(context.rootPath, options.target);
44325
- const [firstRemote] = remotes;
44326
- if (remotes.includes("origin")) {
44327
- track = `origin/${options.target}`;
44328
- } else if (remotes.length === 1 && firstRemote !== undefined) {
44329
- track = `${firstRemote}/${options.target}`;
44330
- } else if (remotes.length > 1) {
44331
- return fail(EXIT_USAGE_ERROR, `Branch "${options.target}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`);
44332
- }
44333
- }
44334
- return switchAndReport({
44335
- git,
44336
- fs,
44337
- context,
44338
- target: options.target,
44339
- switchOptions: {
44340
- createBranch: !branchExistsLocally,
44341
- ...track === undefined ? {} : { track }
44342
- },
44343
- previousBranch
44344
- });
44345
- };
44346
- var switchAndReport = async ({
44347
- git,
44348
- fs,
44349
- context,
44350
- target,
44351
- switchOptions,
44352
- previousBranch
44353
- }) => {
44354
- const lock = await acquireRepoLock(context.commonDir);
44355
- try {
44356
- const fresh = await loadRepoContext(git, fs, context.rootPath);
44357
- const stillDirty = await git.isDirty(fresh.rootPath);
44358
- if (stillDirty) {
44359
- return fail(EXIT_SAFE_REJECTION, "Root clone has uncommitted or untracked changes. Commit, stash, or discard them before switching.");
44360
- }
44361
- if (target !== "-") {
44362
- const freshHolder = fresh.worktrees.find((wt) => wt.branch === target && wt.kind !== "root");
44363
- if (freshHolder !== undefined) {
44364
- return fail(EXIT_SAFE_REJECTION, `Branch "${target}" is already checked out at ${freshHolder.path}. Not swapping — cd there instead of using hop root.`);
44365
- }
44366
- }
44367
- await git.switchBranch(fresh.rootPath, target, switchOptions);
44368
- return ok({
44369
- path: fresh.rootPath,
44370
- data: { branch: target === "-" ? null : target, switched: true }
44371
- });
44372
- } catch (error) {
44373
- if (previousBranch !== null) {
44374
- try {
44375
- await git.switchBranch(context.rootPath, previousBranch, {});
44376
- } catch {}
44377
- }
44378
- return fail(EXIT_SAFE_REJECTION, `Failed to switch root: ${error.message}`);
44379
- } finally {
44380
- await lock.release();
44381
- }
44382
- };
44383
-
44384
45044
  // src/infra/fs.ts
44385
45045
  import { mkdir as mkdirFs, readdir, realpath as realpathFs } from "node:fs/promises";
44386
45046
  var createFsPort = () => ({
@@ -44414,9 +45074,9 @@ import { promisify } from "node:util";
44414
45074
  var execFile = promisify(execFileCb);
44415
45075
  var MAX_BUFFER_BYTES = 64 * 1024 * 1024;
44416
45076
  var GIT_ANCESTOR_EXIT_CODE = 1;
44417
- var run = async (cwd, args) => {
45077
+ var run = async (cwd2, args) => {
44418
45078
  const { stdout: stdout2 } = await execFile("git", [...args], {
44419
- cwd,
45079
+ cwd: cwd2,
44420
45080
  maxBuffer: MAX_BUFFER_BYTES
44421
45081
  });
44422
45082
  return stdout2;
@@ -44437,18 +45097,18 @@ var parseRemoteRefs = (out) => {
44437
45097
  return parsed;
44438
45098
  };
44439
45099
  var createWorktreeMethods = () => ({
44440
- listWorktreesPorcelain(cwd) {
44441
- return run(cwd, ["worktree", "list", "--porcelain", "-z"]);
45100
+ listWorktreesPorcelain(cwd2) {
45101
+ return run(cwd2, ["worktree", "list", "--porcelain", "-z"]);
44442
45102
  },
44443
- async commonDir(cwd) {
44444
- const out = await run(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
45103
+ async commonDir(cwd2) {
45104
+ const out = await run(cwd2, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
44445
45105
  return out.trim();
44446
45106
  },
44447
45107
  async isDirty(path) {
44448
45108
  const status = await run(path, ["status", "--porcelain", "--untracked-files=all"]);
44449
45109
  return status.trim().length > 0;
44450
45110
  },
44451
- async addWorktree(cwd, path, branch, options) {
45111
+ async addWorktree(cwd2, path, branch, options) {
44452
45112
  const args = ["worktree", "add"];
44453
45113
  if (options.createBranch) {
44454
45114
  if (options.track !== undefined) {
@@ -44462,19 +45122,19 @@ var createWorktreeMethods = () => ({
44462
45122
  } else if (!options.createBranch) {
44463
45123
  args.push(branch);
44464
45124
  }
44465
- await run(cwd, args);
45125
+ await run(cwd2, args);
44466
45126
  },
44467
- async removeWorktree(cwd, path, force) {
45127
+ async removeWorktree(cwd2, path, force) {
44468
45128
  const args = ["worktree", "remove"];
44469
45129
  if (force) {
44470
45130
  args.push("--force");
44471
45131
  }
44472
45132
  args.push(path);
44473
- await run(cwd, args);
45133
+ await run(cwd2, args);
44474
45134
  },
44475
- async aheadBehind(cwd, branch) {
45135
+ async aheadBehind(cwd2, branch) {
44476
45136
  try {
44477
- const out = await run(cwd, [
45137
+ const out = await run(cwd2, [
44478
45138
  "rev-list",
44479
45139
  "--left-right",
44480
45140
  "--count",
@@ -44491,13 +45151,13 @@ var createWorktreeMethods = () => ({
44491
45151
  }
44492
45152
  });
44493
45153
  var createBranchMethods = () => ({
44494
- async listBranches(cwd) {
44495
- const out = await run(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]);
45154
+ async listBranches(cwd2) {
45155
+ const out = await run(cwd2, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]);
44496
45156
  return out.split(`
44497
45157
  `).map((line) => line.trim()).filter((line) => line.length > 0);
44498
45158
  },
44499
- async remotesWithBranch(cwd, branch) {
44500
- const out = await run(cwd, ["for-each-ref", "--format=%(refname)", "refs/remotes/"]);
45159
+ async remotesWithBranch(cwd2, branch) {
45160
+ const out = await run(cwd2, ["for-each-ref", "--format=%(refname)", "refs/remotes/"]);
44501
45161
  const remotes = new Set;
44502
45162
  for (const { remote, branchName } of parseRemoteRefs(out)) {
44503
45163
  if (branchName === branch) {
@@ -44506,8 +45166,8 @@ var createBranchMethods = () => ({
44506
45166
  }
44507
45167
  return [...remotes];
44508
45168
  },
44509
- async listRemoteBranches(cwd) {
44510
- const out = await run(cwd, ["for-each-ref", "--format=%(refname)", "refs/remotes/"]);
45169
+ async listRemoteBranches(cwd2) {
45170
+ const out = await run(cwd2, ["for-each-ref", "--format=%(refname)", "refs/remotes/"]);
44511
45171
  const branches = new Set;
44512
45172
  for (const { branchName } of parseRemoteRefs(out)) {
44513
45173
  if (branchName !== "HEAD") {
@@ -44516,7 +45176,7 @@ var createBranchMethods = () => ({
44516
45176
  }
44517
45177
  return [...branches];
44518
45178
  },
44519
- async switchBranch(cwd, ref, options = {}) {
45179
+ async switchBranch(cwd2, ref, options = {}) {
44520
45180
  const args = ["switch"];
44521
45181
  if (options.createBranch) {
44522
45182
  if (options.track !== undefined) {
@@ -44529,35 +45189,35 @@ var createBranchMethods = () => ({
44529
45189
  } else {
44530
45190
  args.push(ref);
44531
45191
  }
44532
- await run(cwd, args);
45192
+ await run(cwd2, args);
44533
45193
  },
44534
- async deleteBranch(cwd, branch) {
44535
- await run(cwd, ["branch", "-D", branch]);
45194
+ async deleteBranch(cwd2, branch) {
45195
+ await run(cwd2, ["branch", "-D", branch]);
44536
45196
  }
44537
45197
  });
44538
45198
  var createGarbagePolicyMethods = () => ({
44539
- async resolveDefaultBranchRef(cwd) {
45199
+ async resolveDefaultBranchRef(cwd2) {
44540
45200
  try {
44541
- const out = await run(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
45201
+ const out = await run(cwd2, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
44542
45202
  const ref = out.trim();
44543
45203
  if (ref.length > 0) {
44544
45204
  return ref;
44545
45205
  }
44546
45206
  } catch {}
44547
45207
  try {
44548
- await run(cwd, ["rev-parse", "--verify", "--quiet", "main"]);
45208
+ await run(cwd2, ["rev-parse", "--verify", "--quiet", "main"]);
44549
45209
  return "main";
44550
45210
  } catch {}
44551
45211
  try {
44552
- await run(cwd, ["rev-parse", "--verify", "--quiet", "master"]);
45212
+ await run(cwd2, ["rev-parse", "--verify", "--quiet", "master"]);
44553
45213
  return "master";
44554
45214
  } catch {
44555
45215
  return null;
44556
45216
  }
44557
45217
  },
44558
- async isAncestor(cwd, branch, ref) {
45218
+ async isAncestor(cwd2, branch, ref) {
44559
45219
  try {
44560
- await run(cwd, ["merge-base", "--is-ancestor", branch, ref]);
45220
+ await run(cwd2, ["merge-base", "--is-ancestor", branch, ref]);
44561
45221
  return true;
44562
45222
  } catch (error) {
44563
45223
  if (failedWithExitCode(error, GIT_ANCESTOR_EXIT_CODE)) {
@@ -44566,18 +45226,18 @@ var createGarbagePolicyMethods = () => ({
44566
45226
  return "unknown";
44567
45227
  }
44568
45228
  },
44569
- async hasEquivalentCommits(cwd, branch, ref) {
45229
+ async hasEquivalentCommits(cwd2, branch, ref) {
44570
45230
  try {
44571
- const out = await run(cwd, ["cherry", ref, branch]);
45231
+ const out = await run(cwd2, ["cherry", ref, branch]);
44572
45232
  return !out.split(`
44573
45233
  `).some((line) => line.startsWith("+"));
44574
45234
  } catch {
44575
45235
  return "unknown";
44576
45236
  }
44577
45237
  },
44578
- async isUpstreamGone(cwd, branch) {
45238
+ async isUpstreamGone(cwd2, branch) {
44579
45239
  try {
44580
- const out = await run(cwd, [
45240
+ const out = await run(cwd2, [
44581
45241
  "for-each-ref",
44582
45242
  "--format=%(upstream:track)",
44583
45243
  `refs/heads/${branch}`
@@ -44595,7 +45255,7 @@ var createGitPort = () => ({
44595
45255
  });
44596
45256
 
44597
45257
  // src/infra/term.ts
44598
- import { createInterface } from "node:readline";
45258
+ import { createInterface as createInterface2 } from "node:readline";
44599
45259
  var createTermPort = () => ({
44600
45260
  isTTY() {
44601
45261
  return process.stderr.isTTY === true && process.stdin.isTTY === true;
@@ -44606,7 +45266,7 @@ var createTermPort = () => ({
44606
45266
  },
44607
45267
  confirm(message) {
44608
45268
  return new Promise((resolve) => {
44609
- const rl = createInterface({
45269
+ const rl = createInterface2({
44610
45270
  input: process.stdin,
44611
45271
  output: process.stderr,
44612
45272
  terminal: false
@@ -44622,40 +45282,44 @@ var createTermPort = () => ({
44622
45282
  }
44623
45283
  });
44624
45284
 
44625
- // src/render.ts
44626
- var PLAIN_JSON_INDENT = 2;
44627
- var render = (command, result, json) => {
44628
- if (json) {
44629
- const envelope = {
44630
- schemaVersion: 1,
44631
- command,
44632
- data: result.ok ? result.data : undefined,
44633
- warnings: result.warnings ?? []
44634
- };
44635
- process.stdout.write(`${JSON.stringify(envelope)}
44636
- `);
44637
- if (!result.ok && result.errorMessage !== undefined) {
44638
- process.stderr.write(`${result.errorMessage}
44639
- `);
44640
- }
44641
- return;
44642
- }
44643
- if (result.path !== undefined) {
44644
- process.stdout.write(`${result.path}
44645
- `);
44646
- } else if (result.ok && result.data !== undefined) {
44647
- process.stdout.write(`${JSON.stringify(result.data, null, PLAIN_JSON_INDENT)}
44648
- `);
44649
- }
44650
- for (const warning of result.warnings ?? []) {
44651
- process.stderr.write(`warning: ${warning}
44652
- `);
44653
- }
44654
- if (!result.ok && result.errorMessage !== undefined) {
44655
- process.stderr.write(`${result.errorMessage}
44656
- `);
44657
- }
44658
- };
45285
+ // src/usage.ts
45286
+ var USAGE = `Usage: hop [command] [options]
45287
+
45288
+ hop Pick a worktree/branch interactively and cd into it (TTY); lists worktrees otherwise
45289
+ hop <branch> Create-or-jump: cd into <branch>'s worktree, creating it on demand
45290
+ hop root cd into the root clone
45291
+ hop - cd back to the previous worktree
45292
+ hop -- <branch> Escape a branch name that collides with a reserved command (ls/rm/clean/root/init/help)
45293
+
45294
+ hop ls [--json] List worktrees (dirty, ahead/behind, kind)
45295
+ hop rm <branch> Remove a worktree, keeping the branch
45296
+ hop clean Auto-detect and remove garbage worktrees
45297
+ hop root <branch> Temporarily switch the root clone (for verification)
45298
+ hop root - Switch the root clone back
45299
+ hop init zsh Print the zsh shell integration (eval "$(hop init zsh)")
45300
+
45301
+ Interactive picker keys:
45302
+ Enter cd into the selected candidate
45303
+ Tab, →, Ctrl+L, Ctrl+F Open the action panel, as a column beside the list
45304
+ (stacks below it instead on narrow terminals)
45305
+ Ctrl+X Delete the selected worktree (y/N confirmation)
45306
+ Ctrl+R Switch the root clone to the selected branch, immediately
45307
+ ↑/↓, Ctrl+P/N, Ctrl+K/J Move the selection (arrow, emacs, and vim keys all work)
45308
+ Esc Cancel (exit 0, no output)
45309
+ Ctrl+C Cancel like an interrupt (exit 130, same as SIGINT)
45310
+ In the action panel: same up/down movement keys, Enter to run the highlighted
45311
+ action, c/d/r to run cd/delete/switchRoot directly, Esc/Tab/←/Ctrl+H to close
45312
+
45313
+ Options:
45314
+ --create Create the worktree when jumping to a branch without one (required outside a TTY)
45315
+ --json Output JSON instead of plain text
45316
+ --force Force removal even if the worktree is dirty (hop rm)
45317
+ --ext Allow operating on external worktrees (hop rm / hop clean)
45318
+ --yes Skip confirmation and execute (hop clean)
45319
+ --dry-run Only report candidates as JSON, without deleting (hop clean)
45320
+ --with-branch Also delete the branch when cleaning (hop clean)
45321
+ -h, --help Show this help and exit
45322
+ `;
44659
45323
 
44660
45324
  // src/cli.ts
44661
45325
  var git = createGitPort();
@@ -44806,26 +45470,24 @@ var jumpArgsSchema = {
44806
45470
  json: { type: "boolean", description: "Output JSON" }
44807
45471
  };
44808
45472
  var runInteractivePick = async (json) => {
44809
- const pickResult = await pick(git, fs3, { cwd: process.cwd() });
44810
- if (!pickResult.ok) {
44811
- render("pick", pickResult, json);
44812
- applyExitCode(pickResult);
45473
+ const candidates = await loadPickCandidates(git, fs3, json);
45474
+ if (candidates === null) {
44813
45475
  return;
44814
45476
  }
44815
- const candidates = pickResult.data?.candidates ?? [];
44816
- const selected = await (async () => {
44817
- try {
44818
- const { runPicker: runPicker2 } = await init_picker().then(() => exports_picker);
44819
- return await runPicker2(candidates);
44820
- } catch {
44821
- const { runSimplePicker: runSimplePicker2 } = await Promise.resolve().then(() => (init_simple_picker(), exports_simple_picker));
44822
- return await runSimplePicker2(candidates);
44823
- }
44824
- })();
44825
- if (selected === null) {
44826
- process.exitCode = EXIT_CANCELLED;
45477
+ let lastSwitchedBranch = null;
45478
+ const callbacks = createPickerCallbacks(git, fs3, json, (branch) => {
45479
+ lastSwitchedBranch = branch;
45480
+ });
45481
+ const outcome = await runInteractivePicker(candidates, callbacks);
45482
+ if (outcome.type === "cancelled") {
45483
+ process.exitCode = outcome.reason === "esc" ? EXIT_SUCCESS : EXIT_CANCELLED;
45484
+ return;
45485
+ }
45486
+ if (outcome.type === "path") {
45487
+ renderSwitchRootOutcome(outcome.path, lastSwitchedBranch, json);
44827
45488
  return;
44828
45489
  }
45490
+ const selected = outcome.candidate;
44829
45491
  if (selected.kind === "worktree") {
44830
45492
  const result = ok({
44831
45493
  path: selected.worktree.path,
@@ -44861,12 +45523,17 @@ var runJumpFromArgs = async (rawArgs) => {
44861
45523
  };
44862
45524
  var ARGV_USER_ARGS_START = 2;
44863
45525
  var rawArgs = normalizeCliArgs(process.argv.slice(ARGV_USER_ARGS_START), process.argv0, process.stdout.isTTY === true);
44864
- var dispatch = dispatchCliArgs(rawArgs, Object.keys(RESERVED_COMMANDS));
44865
- if (dispatch.kind === "reserved") {
44866
- const command = RESERVED_COMMANDS[dispatch.name];
44867
- await runCommand(command, {
44868
- rawArgs: [...dispatch.args]
44869
- });
45526
+ if (isHelpRequest(rawArgs)) {
45527
+ process.stderr.write(USAGE);
45528
+ process.exitCode = 0;
44870
45529
  } else {
44871
- await runJumpFromArgs(dispatch.args);
45530
+ const dispatch = dispatchCliArgs(rawArgs, Object.keys(RESERVED_COMMANDS));
45531
+ if (dispatch.kind === "reserved") {
45532
+ const command = RESERVED_COMMANDS[dispatch.name];
45533
+ await runCommand(command, {
45534
+ rawArgs: [...dispatch.args]
45535
+ });
45536
+ } else {
45537
+ await runJumpFromArgs(dispatch.args);
45538
+ }
44872
45539
  }