@n-seiji/nuthatch 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  ```sh
26
26
  hop # worktree/branch を対話的に選んで cd
27
- hop feat/foo # feat/foo の worktree へ cd — 未作成ならその場で作成
27
+ hop feat/foo # feat/foo の worktree へ cd (未作成なら --create でその場で作成)
28
28
  hop root # root clone へ cd
29
29
  hop - # 直前の worktree へ戻る
30
30
 
@@ -38,6 +38,10 @@ hop root - # root clone を元に戻す (root の branch のみ; swap
38
38
  hop -- <branch> # 予約コマンドと被る branch 名をエスケープ
39
39
  # (`hop` 自体と同名の branch (例: `hop -- hop`) にも必要)
40
40
  hop --help # usage を表示 (-h / hop help でも同じ)
41
+
42
+ hop --version # hop の version を表示
43
+ hop --update # 最新リリースへ、インストールした方法のまま更新
44
+ hop --update --check # --update が何をするかを報告するだけ — 何も変更しない
41
45
  ```
42
46
 
43
47
  同じ branch が複数の worktree で checkout されている場合、branch だけを指定する
@@ -79,14 +83,14 @@ picker は terminal の alternate screen buffer (fzf や vim と同じ仕組み)
79
83
  ### 対話的 picker のキー
80
84
 
81
85
  action panel は候補一覧の隣に列として表示される (overlay ではない) ので、
82
- action を選ぶ間も一覧が見え続ける。60 列未満の terminal では横に並べられ
83
- ないため、一覧の下にスタックする形にフォールバックする。
86
+ action を選ぶ間も一覧が見え続ける。横に並べられない幅 (おおよそ 94 列
87
+ 未満) の terminal では、一覧の下にスタックする形にフォールバックする。
84
88
 
85
89
  | キー | 動作 |
86
90
  |---|---|
87
91
  | `Enter` | 選択中の候補へ cd |
88
92
  | `Tab`, `→`, `Ctrl+L`, `Ctrl+F` | 選択中の候補の action panel を開く (ここで cd / 削除 / root 切替) |
89
- | `Ctrl+X` | 選択中の worktree を削除 (`external` は先に y/N を確認) |
93
+ | `Ctrl+X` | 選択中の worktree を削除 (常に先に y/N を確認) |
90
94
  | `Ctrl+R` | root clone を選択中の branch へ切替 (`external` は先に y/N を確認) |
91
95
  | `↑`/`↓`, `Ctrl+P`/`Ctrl+N`, `Ctrl+K`/`Ctrl+J` | 選択を移動 (矢印・emacs・vim キーがすべて併用可) |
92
96
  | `Esc` | 静かにキャンセル — exit 0、stdout は空のまま (shell wrapper は cd しない) |
@@ -99,8 +103,9 @@ action panel 内: 同じ上下移動キー (左右は閉じる操作に予約さ
99
103
  対になっている; `Tab` はどちらの状態からでもトグルする — Ghostty のように
100
104
  Cmd+K のようなキーの組み合わせを Tab に remap する terminal で便利)。
101
105
  `delete` はすでに作成済みの worktree (`managed` / `external`) すべてに
102
- 表示される (`external` の削除は panel からでも Ctrl+X からでも必ず y/N
103
- を確認する); `switch root here` は root worktree 自身には表示されず、
106
+ 表示される (panel からの削除は、`external` worktree と、git が作業ツリーを
107
+ 見失った prunable な worktree なら先に y/N を確認する。Ctrl+X は常に
108
+ 確認する); `switch root here` は root worktree 自身には表示されず、
104
109
  `external` worktree に対しては HEAD を detach する可能性があるため
105
110
  同じく先に y/N を確認する。削除すると候補一覧が再読み込みされ、picker を
106
111
  抜けずに削除を続けられる; cd と switch-root は終了して結果の path を
@@ -137,6 +142,31 @@ install script は `hop` を `~/.local/bin` に置き (`HOP_INSTALL_DIR` で
137
142
  には `HOP_VERSION=vX.Y.Z` を使う。Linux arm64 はビルド済みバイナリが
138
143
  まだないため、npm install を使う。
139
144
 
145
+ ### 更新
146
+
147
+ ```sh
148
+ hop --update # 最新リリースへ、インストールした方法のまま更新
149
+ hop --update --check # 報告だけ: current / latest / method / 実行されるコマンド
150
+ hop --version # 今どの version か
151
+ ```
152
+
153
+ `hop --update` は hop のインストール方法を判定する。`install.sh` で入れた
154
+ バイナリは、置き場のディレクトリに書き込めて (バイナリと checksum の
155
+ ダウンロードの前に確認する)、かつリリースに添付された `.sha256` と SHA-256 が
156
+ 一致した場合にだけその場で差し替える — 不一致なら何も置換しない。
157
+ mise / npm / bun 経由の install は、そのツール自身の upgrade コマンド
158
+ (`mise upgrade …`、
159
+ `npm install -g --prefix <hop が入っている prefix> @n-seiji/nuthatch@latest`、
160
+ `bun add -g @n-seiji/nuthatch@latest`) をホームディレクトリで実行して更新する
161
+ ので、グローバルの mise 設定で固定した version が優先され (hop は先に mise へ
162
+ 問い合わせ、upgrade するものが無ければ警告する)、プロジェクトの `mise.toml`
163
+ には左右されず、npm の更新は hop が今入っている prefix に入る。`npx` / `bunx`、
164
+ ソースチェックアウト、Homebrew・Nix・aqua・proto など他のバージョン管理ツール
165
+ が自分用にコピーを持っているバイナリ、hop が扱わない配置 (pnpm・yarn の
166
+ global、プロジェクトの依存) は、代わりにどうすればよいかを示して拒否する。
167
+ hop がネットワークに出るのはこれだけで、GitHub と npm の固定アドレスへの
168
+ HTTPS リクエストのみ (タイムアウトとサイズ上限つき)。
169
+
140
170
  ## トラブルシューティング
141
171
 
142
172
  `hop: git executable not found. Looked in: …` は git のバイナリを見つけられ
package/README.md CHANGED
@@ -26,7 +26,7 @@ doesn't exist yet. Built for humans (interactive picker) and AI coding agents
26
26
 
27
27
  ```sh
28
28
  hop # pick a worktree/branch interactively and cd into it
29
- hop feat/foo # cd into feat/foo's worktree — created on demand
29
+ hop feat/foo # cd into feat/foo's worktree (add --create to create it if missing)
30
30
  hop root # cd into the root clone
31
31
  hop - # cd back to the previous worktree
32
32
 
@@ -40,6 +40,10 @@ hop root - # switch the root clone back (only root's branch; a swapped-o
40
40
  hop -- <branch> # escape a branch name that collides with a reserved command
41
41
  # (also required for a branch named the same as your `hop` binary/alias, e.g. `hop -- hop`)
42
42
  hop --help # print usage (also -h / hop help)
43
+
44
+ hop --version # print hop's version
45
+ hop --update # update hop to the latest release, the way it was installed
46
+ hop --update --check # only report what --update would do — changes nothing
43
47
  ```
44
48
 
45
49
  If Git reports the same branch checked out in multiple worktrees, the
@@ -87,14 +91,14 @@ screen on exit, however it exits (selection, Esc/Ctrl-C, or an interrupt).
87
91
 
88
92
  The action panel renders as a column beside the candidate list (not an
89
93
  overlay), so the list stays visible while you pick an action. On terminals
90
- narrower than 60 columns it falls back to stacking below the list instead,
91
- since the two can't fit side by side.
94
+ too narrow to fit both (under about 94 columns) it falls back to stacking
95
+ below the list instead.
92
96
 
93
97
  | Key | Action |
94
98
  |---|---|
95
99
  | `Enter` | cd into the selected candidate |
96
100
  | `Tab`, `→`, `Ctrl+L`, `Ctrl+F` | Open the action panel for the selected candidate (cd / delete / switch root here) |
97
- | `Ctrl+X` | Delete the selected worktree (asks y/N first for an `external` worktree) |
101
+ | `Ctrl+X` | Delete the selected worktree (always asks y/N first) |
98
102
  | `Ctrl+R` | Switch the root clone to the selected branch (asks y/N first for an `external` worktree) |
99
103
  | `↑`/`↓`, `Ctrl+P`/`Ctrl+N`, `Ctrl+K`/`Ctrl+J` | Move the selection (arrow, emacs, and vim keys all work side by side) |
100
104
  | `Esc` | Cancel quietly — exit 0, stdout stays empty (the shell wrapper just doesn't cd) |
@@ -106,8 +110,9 @@ highlighted action, `c`/`d`/`r` to run cd/delete/switch-root directly, and
106
110
  `Esc`, `Tab`, `←`, or `Ctrl+H` to close it back to the list (`←`/`Ctrl+H`
107
111
  mirror the `→`/`Ctrl+L`/`Ctrl+F` that open it; `Tab` toggles either way —
108
112
  handy on terminals like Ghostty that remap a chord such as Cmd+K to Tab). `delete`
109
- appears for any already-created worktree (`managed` or `external` — deleting
110
- an `external` one always asks y/N, whether from the panel or Ctrl+X); `switch
113
+ appears for any already-created worktree (`managed` or `external` — from the
114
+ panel, deleting an `external` worktree, or a prunable one (git can no longer
115
+ find its working tree), asks y/N first; Ctrl+X always asks); `switch
111
116
  root here` doesn't appear on the root worktree itself, and also asks y/N
112
117
  first for an `external` worktree, since it can detach that worktree's HEAD.
113
118
  Deleting reloads the
@@ -123,9 +128,6 @@ eval "$(hop init zsh)"
123
128
 
124
129
  ## Install
125
130
 
126
- > Not released yet — no version has been published or tagged. Once the first
127
- > `v*` tag ships, the options below will work as described.
128
-
129
131
  Recommended: the GitHub Release binary (built with bun; via mise,
130
132
  `mise use github:n-seiji/nuthatch`). The npm version runs on Node, so its git
131
133
  calls are slower (hop ls ~400ms vs ~60ms) — prefer the binary for CI or
@@ -146,6 +148,31 @@ The install script places `hop` in `~/.local/bin` (override with
146
148
  specific version with `HOP_VERSION=vX.Y.Z`. Linux arm64 has no prebuilt
147
149
  binary yet — use the npm install instead.
148
150
 
151
+ ### Updating
152
+
153
+ ```sh
154
+ hop --update # update to the latest release, the way hop was installed
155
+ hop --update --check # just report: current, latest, method, and the command it would run
156
+ hop --version # which version do I have?
157
+ ```
158
+
159
+ `hop --update` detects how hop was installed. A binary from `install.sh` is
160
+ replaced in place, but only if its directory is writable (checked before the
161
+ binary or its checksum is downloaded) and only after its SHA-256 matches the
162
+ `.sha256` attached to the release — on a mismatch nothing is replaced. A mise /
163
+ npm / bun install is updated by running that tool's own upgrade command from
164
+ your home directory (`mise upgrade …`,
165
+ `npm install -g --prefix <the prefix hop lives in> @n-seiji/nuthatch@latest`,
166
+ `bun add -g @n-seiji/nuthatch@latest`), so a version pinned in your global mise
167
+ config still wins (hop asks mise first and warns when it has nothing to
168
+ upgrade), a project's own `mise.toml` cannot interfere, and an npm update lands
169
+ in the prefix hop is already in. `npx` / `bunx`, a source checkout, a binary
170
+ that Homebrew, Nix, aqua, proto or another version manager keeps its own copy
171
+ of, and layouts hop does not drive (a pnpm or yarn global, a project
172
+ dependency) are refused with a note on what to do instead. This is the only
173
+ thing hop does over the network: HTTPS requests to fixed GitHub and npm
174
+ addresses, each with a timeout and a size cap.
175
+
149
176
  ## Troubleshooting
150
177
 
151
178
  `hop: git executable not found. Looked in: …` means hop could not find a git
@@ -0,0 +1,208 @@
1
+ import {
2
+ messageOf
3
+ } from "./cli-qrxgkskr.js";
4
+ import {
5
+ upstreamStateOf
6
+ } from "./cli-pqd2qe7v.js";
7
+ import {
8
+ withRepoLock
9
+ } from "./cli-rcw5adqd.js";
10
+ import {
11
+ loadRepoContext,
12
+ nestedWorktrees,
13
+ worktreeDirtyState
14
+ } from "./cli-7jg5qqs9.js";
15
+ import {
16
+ EXIT_USAGE_ERROR,
17
+ ok,
18
+ fail
19
+ } from "./cli-2bhb9p0x.js";
20
+
21
+ // src/domain/garbage.ts
22
+ var classifyGarbage = (input) => {
23
+ if (input.prunable) {
24
+ return "prunable";
25
+ }
26
+ if (!input.clean) {
27
+ return null;
28
+ }
29
+ if (input.mergedIntoDefault === true) {
30
+ return "merged";
31
+ }
32
+ if (input.upstreamGone && input.allCommitsReachableFromDefault === true) {
33
+ return "gone";
34
+ }
35
+ return null;
36
+ };
37
+
38
+ // src/commands/clean-candidates.ts
39
+ var buildCleanCandidates = async (git, fs, context, ext) => {
40
+ const [defaultRef, upstreams] = await Promise.all([
41
+ git.resolveDefaultBranchRef(context.rootPath),
42
+ git.upstreamStates(context.rootPath)
43
+ ]);
44
+ const targets = context.worktrees.filter((wt) => wt.kind === "managed" || ext && wt.kind === "external");
45
+ const results = await Promise.all(targets.map(async (wt) => {
46
+ const reason = await classifyWorktree(wt, { git, fs, defaultRef, upstreams, context });
47
+ if (reason === null) {
48
+ return null;
49
+ }
50
+ return { branch: wt.branch ?? "", path: wt.path, reason };
51
+ }));
52
+ return results.filter((candidate) => candidate !== null);
53
+ };
54
+ var classifyWorktree = async (wt, { git, fs, defaultRef, upstreams, context }) => {
55
+ if (wt.prunable) {
56
+ return classifyGarbage({
57
+ prunable: true,
58
+ clean: false,
59
+ mergedIntoDefault: "unknown",
60
+ upstreamGone: false,
61
+ allCommitsReachableFromDefault: "unknown"
62
+ });
63
+ }
64
+ if (wt.branch === null) {
65
+ return null;
66
+ }
67
+ const { rootPath, worktrees } = context;
68
+ const upstreamGone = upstreamStateOf(upstreams, wt.branch).kind === "gone";
69
+ const mergedIntoDefault = defaultRef === null ? "unknown" : await git.isAncestor(rootPath, wt.branch, defaultRef);
70
+ const allCommitsReachableFromDefault = defaultRef === null || mergedIntoDefault === true || !upstreamGone ? "unknown" : await git.hasEquivalentCommits(rootPath, wt.branch, defaultRef);
71
+ const facts = {
72
+ prunable: false,
73
+ mergedIntoDefault,
74
+ upstreamGone,
75
+ allCommitsReachableFromDefault
76
+ };
77
+ if (classifyGarbage({ ...facts, clean: true }) === null) {
78
+ return null;
79
+ }
80
+ const dirtyState = await worktreeDirtyState(git, fs, worktrees, wt);
81
+ const input = { ...facts, clean: dirtyState === false };
82
+ return classifyGarbage(input);
83
+ };
84
+
85
+ // src/commands/clean.ts
86
+ var clean = async (git, fs, term, options) => {
87
+ const context = await loadRepoContext(git, fs, options.cwd);
88
+ const candidates = await buildCleanCandidates(git, fs, context, options.ext);
89
+ if (options.dryRun) {
90
+ return ok({ data: { candidates } });
91
+ }
92
+ if (candidates.length === 0) {
93
+ return ok({ data: { candidates, removed: [] } });
94
+ }
95
+ if (!options.yes) {
96
+ if (!term.isTTY()) {
97
+ return fail(EXIT_USAGE_ERROR, "hop clean would remove worktrees; re-run with --yes (non-interactive) or --dry-run to only list candidates.");
98
+ }
99
+ for (const candidate of candidates) {
100
+ term.logStderr(`${candidate.reason} ${candidate.branch} ${candidate.path}`);
101
+ }
102
+ const confirmed = await term.confirm(`Remove ${candidates.length} worktree(s) above?`);
103
+ if (!confirmed) {
104
+ return ok({ data: { candidates, removed: [] } });
105
+ }
106
+ }
107
+ return executeClean({
108
+ git,
109
+ fs,
110
+ context,
111
+ candidates,
112
+ cleanOptions: options
113
+ });
114
+ };
115
+ var removeCandidate = async ({
116
+ git,
117
+ rootPath,
118
+ candidate,
119
+ deleteBranch,
120
+ worktrees
121
+ }) => {
122
+ if (nestedWorktrees(worktrees, candidate.path).length > 0) {
123
+ return "nested";
124
+ }
125
+ try {
126
+ await git.removeWorktree(rootPath, candidate.path, false);
127
+ if (deleteBranch && candidate.branch.length > 0) {
128
+ await git.deleteBranch(rootPath, candidate.branch);
129
+ }
130
+ return "removed";
131
+ } catch {
132
+ return "failed";
133
+ }
134
+ };
135
+ var canDeletePrunableBranch = async (git, rootPath, candidate, { defaultRef, upstreams }) => {
136
+ if (candidate.reason !== "prunable") {
137
+ return true;
138
+ }
139
+ if (candidate.branch.length === 0 || defaultRef === null) {
140
+ return false;
141
+ }
142
+ try {
143
+ const mergedIntoDefault = await git.isAncestor(rootPath, candidate.branch, defaultRef);
144
+ if (mergedIntoDefault === true) {
145
+ return true;
146
+ }
147
+ if (upstreamStateOf(upstreams, candidate.branch).kind === "gone") {
148
+ return await git.hasEquivalentCommits(rootPath, candidate.branch, defaultRef) === true;
149
+ }
150
+ } catch {}
151
+ return false;
152
+ };
153
+ var executeClean = ({
154
+ git,
155
+ fs,
156
+ context,
157
+ candidates,
158
+ cleanOptions
159
+ }) => withRepoLock(context.commonDir, async () => {
160
+ try {
161
+ const fresh = await loadRepoContext(git, fs, context.rootPath);
162
+ const freshCandidates = await buildCleanCandidates(git, fs, fresh, cleanOptions.ext);
163
+ const freshByPath = new Map(freshCandidates.map((candidate) => [candidate.path, candidate]));
164
+ const stillValid = candidates.flatMap((candidate) => {
165
+ const freshCandidate = freshByPath.get(candidate.path);
166
+ return freshCandidate === undefined ? [] : [freshCandidate];
167
+ });
168
+ const [defaultRef, upstreams] = await Promise.all([
169
+ git.resolveDefaultBranchRef(fresh.rootPath),
170
+ git.upstreamStates(fresh.rootPath)
171
+ ]);
172
+ const removed = [];
173
+ const warnings = [];
174
+ for (const candidate of stillValid) {
175
+ const branchSafe = await canDeletePrunableBranch(git, fresh.rootPath, candidate, {
176
+ defaultRef,
177
+ upstreams
178
+ });
179
+ const deleteBranch = cleanOptions.withBranch && branchSafe;
180
+ if (cleanOptions.withBranch && candidate.reason === "prunable" && candidate.branch.length > 0 && !branchSafe) {
181
+ warnings.push(`Keeping branch "${candidate.branch}": prunable worktree is not confirmed merged or gone.`);
182
+ }
183
+ const outcome = await removeCandidate({
184
+ git,
185
+ rootPath: fresh.rootPath,
186
+ candidate,
187
+ deleteBranch,
188
+ worktrees: fresh.worktrees
189
+ });
190
+ if (outcome === "removed") {
191
+ removed.push(candidate.branch);
192
+ } else if (outcome === "nested") {
193
+ warnings.push(`Skipped ${candidate.path}: still contains a registered worktree.`);
194
+ }
195
+ }
196
+ return ok({
197
+ data: { candidates: [...candidates], removed },
198
+ ...warnings.length === 0 ? {} : { warnings }
199
+ });
200
+ } catch (error) {
201
+ throw new Error(`Failed to clean worktrees: ${messageOf(error)}`, {
202
+ cause: error
203
+ });
204
+ }
205
+ });
206
+ export {
207
+ clean
208
+ };
@@ -0,0 +1,116 @@
1
+ import {
2
+ messageOf
3
+ } from "./cli-qrxgkskr.js";
4
+ import {
5
+ withRepoLock
6
+ } from "./cli-rcw5adqd.js";
7
+ import {
8
+ loadRepoContext,
9
+ nestedWorktrees,
10
+ worktreeDirtyState
11
+ } from "./cli-7jg5qqs9.js";
12
+ import {
13
+ EXIT_GENERAL_ERROR,
14
+ EXIT_USAGE_ERROR,
15
+ EXIT_SAFE_REJECTION,
16
+ ok,
17
+ fail
18
+ } from "./cli-2bhb9p0x.js";
19
+
20
+ // src/commands/rm.ts
21
+ var EXT_DEPRECATION_WARNING = "--ext is deprecated and no longer required: hop rm can now remove external worktrees without it.";
22
+ 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.`);
23
+ 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.`);
24
+ 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.`;
25
+ var resolveTarget = (worktrees, branch, expectedPath) => {
26
+ const matches = worktrees.filter((wt) => wt.branch === branch);
27
+ if (expectedPath !== undefined) {
28
+ const target = matches.find((wt) => wt.path === expectedPath);
29
+ return target === undefined ? {
30
+ rejection: fail(EXIT_SAFE_REJECTION, `The selected worktree for "${branch}" changed before removal. Refresh the picker and retry.`)
31
+ } : { target };
32
+ }
33
+ if (matches.length > 1) {
34
+ return {
35
+ rejection: fail(EXIT_SAFE_REJECTION, `Branch "${branch}" is checked out at ${matches.length} worktrees (${matches.map((wt) => wt.path).join(", ")}). Refusing to guess which one to remove.`)
36
+ };
37
+ }
38
+ const [target] = matches;
39
+ return target === undefined ? {} : { target };
40
+ };
41
+ var nestedWorktreeRejection = (branch, targetPath, worktrees) => {
42
+ const nested = nestedWorktrees(worktrees, targetPath);
43
+ if (nested.length === 0) {
44
+ return null;
45
+ }
46
+ const listing = nested.map((wt) => ` ${wt.path}${wt.branch === null ? "" : ` (${wt.branch})`}`).join(`
47
+ `);
48
+ 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:
49
+ ${listing}`);
50
+ };
51
+ var targetSafetyRejection = async ({ git, fs, context, options }, target) => {
52
+ const nestedRejection = nestedWorktreeRejection(options.branch, target.path, context.worktrees);
53
+ if (nestedRejection !== null) {
54
+ return nestedRejection;
55
+ }
56
+ if (target.locked) {
57
+ return lockedRejection(options.branch, target.lockReason);
58
+ }
59
+ if (target.prunable && options.allowPrunable === false) {
60
+ return turnedPrunableRejection(options.branch);
61
+ }
62
+ if (!options.force && await worktreeDirtyState(git, fs, context.worktrees, target) === true) {
63
+ return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
64
+ }
65
+ return null;
66
+ };
67
+ var validateRemoval = async (check) => {
68
+ const { context, options } = check;
69
+ const resolved = resolveTarget(context.worktrees, options.branch, options.expectedPath);
70
+ if (resolved.rejection !== undefined) {
71
+ return { ok: false, rejection: resolved.rejection };
72
+ }
73
+ const { target } = resolved;
74
+ if (target === undefined) {
75
+ return {
76
+ ok: false,
77
+ rejection: fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${options.branch}".`)
78
+ };
79
+ }
80
+ if (target.kind === "root") {
81
+ return { ok: false, rejection: fail(EXIT_USAGE_ERROR, "Cannot remove the root clone.") };
82
+ }
83
+ const rejection = await targetSafetyRejection(check, target);
84
+ return rejection === null ? { ok: true, target } : { ok: false, rejection };
85
+ };
86
+ var withExtWarning = (ext, result) => ext ? {
87
+ ...result,
88
+ warnings: [...result.warnings ?? [], EXT_DEPRECATION_WARNING]
89
+ } : result;
90
+ var removeWorktree = async (git, fs, options) => {
91
+ const context = await loadRepoContext(git, fs, options.cwd);
92
+ const validation = await validateRemoval({ git, fs, context, options });
93
+ if (!validation.ok) {
94
+ return validation.rejection;
95
+ }
96
+ return withRepoLock(context.commonDir, async () => {
97
+ try {
98
+ const fresh = await loadRepoContext(git, fs, options.cwd);
99
+ const freshValidation = await validateRemoval({ git, fs, context: fresh, options });
100
+ if (!freshValidation.ok) {
101
+ return freshValidation.rejection;
102
+ }
103
+ const { target } = freshValidation;
104
+ await git.removeWorktree(context.rootPath, target.path, options.force);
105
+ return ok({
106
+ data: { branch: options.branch, path: target.path },
107
+ ...target.prunable ? { warnings: [stalePrunableWarning(target)] } : {}
108
+ });
109
+ } catch (error) {
110
+ return fail(EXIT_SAFE_REJECTION, `Failed to remove worktree: ${messageOf(error)}`);
111
+ }
112
+ });
113
+ };
114
+ var rm2 = async (git, fs, options) => withExtWarning(options.ext, await removeWorktree(git, fs, options));
115
+
116
+ export { rm2 };
@@ -0,0 +1,19 @@
1
+ // src/domain/result.ts
2
+ var EXIT_SUCCESS = 0;
3
+ var EXIT_GENERAL_ERROR = 1;
4
+ var EXIT_USAGE_ERROR = 2;
5
+ var EXIT_SAFE_REJECTION = 3;
6
+ var EXIT_CANCELLED = 130;
7
+ var ok = (fields = {}) => ({
8
+ ok: true,
9
+ exitCode: EXIT_SUCCESS,
10
+ ...fields
11
+ });
12
+ var fail = (exitCode, errorMessage, warnings) => ({
13
+ ok: false,
14
+ exitCode,
15
+ errorMessage,
16
+ ...warnings === undefined || warnings.length === 0 ? {} : { warnings }
17
+ });
18
+
19
+ export { EXIT_SUCCESS, EXIT_GENERAL_ERROR, EXIT_USAGE_ERROR, EXIT_SAFE_REJECTION, EXIT_CANCELLED, ok, fail };
@@ -0,0 +1,114 @@
1
+ import {
2
+ describeFatalError
3
+ } from "./cli-qrxgkskr.js";
4
+ import {
5
+ reportResult
6
+ } from "./cli-dbzx9nzg.js";
7
+ import {
8
+ fail
9
+ } from "./cli-2bhb9p0x.js";
10
+
11
+ // src/cli-dispatch.ts
12
+ var normalizeCliArgs = (rawArgs, argv0, isCompiledBinary) => isCompiledBinary && rawArgs.length === 1 && rawArgs[0] === argv0 ? [] : rawArgs;
13
+ var isRunningAsCompiledBinary = () => typeof Bun !== "undefined" && Bun.main.startsWith("/$bunfs/");
14
+ var isUpdateRequest = (rawArgs) => rawArgs[0] === "--update";
15
+ var HELP_FLAGS = new Set(["--help", "-h", "help"]);
16
+ var UPDATE_HELP_FLAGS = new Set(["--help", "-h"]);
17
+ var isHelpRequest = (rawArgs) => rawArgs.length > 0 && (HELP_FLAGS.has(rawArgs[0] ?? "") || isUpdateRequest(rawArgs) && rawArgs.slice(1).some((token) => UPDATE_HELP_FLAGS.has(token)));
18
+ var isVersionRequest = (rawArgs) => rawArgs[0] === "--version";
19
+ var dispatchCliArgs = (rawArgs, reservedNames) => {
20
+ const [first, ...rest] = rawArgs;
21
+ if (first === "--") {
22
+ return { kind: "jump", args: rest };
23
+ }
24
+ if (first !== undefined && reservedNames.includes(first)) {
25
+ return { kind: "reserved", name: first, args: rest };
26
+ }
27
+ return { kind: "jump", args: rawArgs };
28
+ };
29
+
30
+ // src/cli-fatal.ts
31
+ var wantsJson = (args) => args.some((arg) => arg === "--json" || arg.startsWith("--json="));
32
+ var reportFatalError = (command, json, error) => {
33
+ const fatal = describeFatalError(error);
34
+ const result = fail(fatal.exitCode, `hop: ${fatal.message}`);
35
+ reportResult(command, result, json);
36
+ };
37
+ // package.json
38
+ var package_default = {
39
+ name: "@n-seiji/nuthatch",
40
+ version: "0.1.7",
41
+ description: "git worktree manager (hop)",
42
+ homepage: "https://github.com/n-seiji/nuthatch#readme",
43
+ bugs: {
44
+ url: "https://github.com/n-seiji/nuthatch/issues"
45
+ },
46
+ license: "GPL-3.0-only",
47
+ repository: {
48
+ type: "git",
49
+ url: "git+https://github.com/n-seiji/nuthatch.git"
50
+ },
51
+ bin: {
52
+ hop: "dist/cli.js"
53
+ },
54
+ files: [
55
+ "dist",
56
+ "shell"
57
+ ],
58
+ type: "module",
59
+ publishConfig: {
60
+ access: "public"
61
+ },
62
+ scripts: {
63
+ build: "bun build ./src/cli.ts --outdir dist --target node --format esm --splitting",
64
+ typecheck: "tsc --noEmit",
65
+ lint: "oxlint .",
66
+ "lint:fix": "oxlint . --fix",
67
+ format: "oxfmt --write .",
68
+ "format:check": "oxfmt --check .",
69
+ test: "bun test"
70
+ },
71
+ dependencies: {
72
+ valibot: "^1.4.2"
73
+ },
74
+ devDependencies: {
75
+ "@types/bun": "latest",
76
+ oxfmt: "^0.66.0",
77
+ oxlint: "^1.81.0",
78
+ typescript: "^5.6.0"
79
+ },
80
+ engines: {
81
+ node: ">=22"
82
+ }
83
+ };
84
+
85
+ // src/version.ts
86
+ var VERSION = package_default.version;
87
+
88
+ // src/infra/executable.ts
89
+ import { constants } from "node:fs";
90
+ import { access, stat } from "node:fs/promises";
91
+ var isExecutableFile = async (path) => {
92
+ try {
93
+ const stats = await stat(path);
94
+ if (!stats.isFile()) {
95
+ return false;
96
+ }
97
+ await access(path, constants.X_OK);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ };
103
+ var firstExecutable = async (candidates) => {
104
+ const probes = candidates.map(async (candidate) => await isExecutableFile(candidate) ? candidate : null);
105
+ for (const probe of probes) {
106
+ const found = await probe;
107
+ if (found !== null) {
108
+ return found;
109
+ }
110
+ }
111
+ return null;
112
+ };
113
+
114
+ export { normalizeCliArgs, isRunningAsCompiledBinary, isUpdateRequest, isHelpRequest, isVersionRequest, dispatchCliArgs, wantsJson, reportFatalError, firstExecutable, VERSION };