@n-seiji/nuthatch 0.1.7 → 0.2.1

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
@@ -40,8 +40,8 @@ hop -- <branch> # 予約コマンドと被る branch 名をエスケープ
40
40
  hop --help # usage を表示 (-h / hop help でも同じ)
41
41
 
42
42
  hop --version # hop の version を表示
43
- hop --update # 最新リリースへ、インストールした方法のまま更新
44
- hop --update --check # --update が何をするかを報告するだけ — 何も変更しない
43
+ hop --update # standalone バイナリを最新リリースへ置換 (それ以外は更新方法を表示)
44
+ hop --update --check # 更新があるかを報告するだけ — 何も変更しない
45
45
  ```
46
46
 
47
47
  同じ branch が複数の worktree で checkout されている場合、branch だけを指定する
@@ -92,7 +92,7 @@ action を選ぶ間も一覧が見え続ける。横に並べられない幅 (
92
92
  | `Tab`, `→`, `Ctrl+L`, `Ctrl+F` | 選択中の候補の action panel を開く (ここで cd / 削除 / root 切替) |
93
93
  | `Ctrl+X` | 選択中の worktree を削除 (常に先に y/N を確認) |
94
94
  | `Ctrl+R` | root clone を選択中の branch へ切替 (`external` は先に y/N を確認) |
95
- | `↑`/`↓`, `Ctrl+P`/`Ctrl+N`, `Ctrl+K`/`Ctrl+J` | 選択を移動 (矢印・emacs・vim キーがすべて併用可) |
95
+ | `↑`/`↓`, `Ctrl+P`/`Ctrl+N`, `Ctrl+K` | 選択を移動 (矢印・emacs・vim キーがすべて併用可) |
96
96
  | `Esc` | 静かにキャンセル — exit 0、stdout は空のまま (shell wrapper は cd しない) |
97
97
  | `Ctrl+C` | 割り込みとしてキャンセル — exit 130、実際の SIGINT と同じ |
98
98
 
@@ -142,30 +142,40 @@ install script は `hop` を `~/.local/bin` に置き (`HOP_INSTALL_DIR` で
142
142
  には `HOP_VERSION=vX.Y.Z` を使う。Linux arm64 はビルド済みバイナリが
143
143
  まだないため、npm install を使う。
144
144
 
145
+ v0.2.0 より後のリリースのバイナリにはビルド来歴 (build provenance) の attestation
146
+ が付く。ダウンロードしたバイナリを手動で検証するには:
147
+
148
+ ```sh
149
+ gh attestation verify ./hop-darwin-arm64 --repo n-seiji/nuthatch \
150
+ --signer-workflow github.com/n-seiji/nuthatch/.github/workflows/release.yml
151
+ ```
152
+
153
+ `install.sh` は attestation を確認せず、SHA-256 だけを検証する。
154
+
145
155
  ### 更新
146
156
 
147
157
  ```sh
148
- hop --update # 最新リリースへ、インストールした方法のまま更新
149
- hop --update --check # 報告だけ: current / latest / method / 実行されるコマンド
158
+ hop --update # standalone バイナリを最新リリースへ置換
159
+ hop --update --check # 報告だけ: current / latest / インストール方法 / すべきこと
150
160
  hop --version # 今どの version か
151
161
  ```
152
162
 
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 リクエストのみ (タイムアウトとサイズ上限つき)。
163
+ `hop --update` が置換するのは `install.sh` で入れたバイナリだけで、置き場の
164
+ ディレクトリに書き込めて (バイナリと checksum のダウンロードの前に確認する)、
165
+ かつリリースに添付された `.sha256` と SHA-256 が一致した場合に限る — 不一致なら
166
+ 何も置換しない (確認するのは SHA-256 のみで、attestation は見ない)。それ以外のインストールでは、何も実行せず、リクエストも送らず、
167
+ 更新方法を 1 行表示して終了する (exit 1):
168
+
169
+ | インストール方法 | 更新方法 |
170
+ |---|---|
171
+ | `install.sh` (`~/.local/bin` のバイナリ) | `hop --update`、または `install.sh` を再実行 |
172
+ | mise | `mise upgrade` |
173
+ | npm / bun / pnpm / yarn の global | `npm i -g @n-seiji/nuthatch@latest` (または使っているパッケージマネージャの同等コマンド) |
174
+ | Homebrew・Nix・aqua・proto・ソースチェックアウト・その他のツール | そのツール自身の upgrade コマンド |
175
+
176
+ `hop --update --check` はどのインストール方法でも使え、同じ案内を報告する。
177
+ hop がネットワークに出るのは GitHub の固定アドレスへの HTTPS リクエストだけで、
178
+ タイムアウトとサイズ上限つき。
169
179
 
170
180
  ## トラブルシューティング
171
181
 
package/README.md CHANGED
@@ -42,8 +42,8 @@ hop -- <branch> # escape a branch name that collides with a reserved command
42
42
  hop --help # print usage (also -h / hop help)
43
43
 
44
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
45
+ hop --update # replace a standalone binary with the latest release (other installs: prints how to update)
46
+ hop --update --check # only report whether an update is available — changes nothing
47
47
  ```
48
48
 
49
49
  If Git reports the same branch checked out in multiple worktrees, the
@@ -100,7 +100,7 @@ below the list instead.
100
100
  | `Tab`, `→`, `Ctrl+L`, `Ctrl+F` | Open the action panel for the selected candidate (cd / delete / switch root here) |
101
101
  | `Ctrl+X` | Delete the selected worktree (always asks y/N first) |
102
102
  | `Ctrl+R` | Switch the root clone to the selected branch (asks y/N first for an `external` worktree) |
103
- | `↑`/`↓`, `Ctrl+P`/`Ctrl+N`, `Ctrl+K`/`Ctrl+J` | Move the selection (arrow, emacs, and vim keys all work side by side) |
103
+ | `↑`/`↓`, `Ctrl+P`/`Ctrl+N`, `Ctrl+K` | Move the selection (arrow, emacs, and vim keys all work side by side) |
104
104
  | `Esc` | Cancel quietly — exit 0, stdout stays empty (the shell wrapper just doesn't cd) |
105
105
  | `Ctrl+C` | Cancel like an interrupt — exit 130, same as a real SIGINT |
106
106
 
@@ -148,30 +148,40 @@ The install script places `hop` in `~/.local/bin` (override with
148
148
  specific version with `HOP_VERSION=vX.Y.Z`. Linux arm64 has no prebuilt
149
149
  binary yet — use the npm install instead.
150
150
 
151
+ Release binaries from releases after v0.2.0 carry a build-provenance
152
+ attestation. To verify a downloaded binary by hand:
153
+
154
+ ```sh
155
+ gh attestation verify ./hop-darwin-arm64 --repo n-seiji/nuthatch \
156
+ --signer-workflow github.com/n-seiji/nuthatch/.github/workflows/release.yml
157
+ ```
158
+
159
+ `install.sh` does not check it; it verifies the SHA-256 only.
160
+
151
161
  ### Updating
152
162
 
153
163
  ```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
164
+ hop --update # replace a standalone binary with the latest release
165
+ hop --update --check # just report: current, latest, how hop was installed, what to do
156
166
  hop --version # which version do I have?
157
167
  ```
158
168
 
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.
169
+ `hop --update` only replaces the binary that `install.sh` installs, and only if
170
+ its directory is writable (checked before the binary or its checksum is
171
+ downloaded) and its SHA-256 matches the `.sha256` attached to the release; on a
172
+ mismatch nothing is replaced (SHA-256 only; attestations are not checked). For any other install it runs nothing, makes no
173
+ request, and prints one line saying how to update it (exit 1):
174
+
175
+ | Installed with | Update with |
176
+ |---|---|
177
+ | `install.sh` (binary in `~/.local/bin`) | `hop --update`, or re-run `install.sh` |
178
+ | mise | `mise upgrade` |
179
+ | npm / bun / pnpm / yarn global | `npm i -g @n-seiji/nuthatch@latest` (or your package manager's equivalent) |
180
+ | Homebrew, Nix, aqua, proto, a source checkout, any other tool | that tool's own upgrade command |
181
+
182
+ `hop --update --check` works for every install and reports the same hint. The
183
+ only network traffic is HTTPS requests to fixed GitHub addresses, each with a
184
+ timeout and a size cap.
175
185
 
176
186
  ## Troubleshooting
177
187
 
@@ -1,22 +1,23 @@
1
1
  import {
2
2
  messageOf
3
- } from "./cli-qrxgkskr.js";
3
+ } from "./cli-ph0gp56k.js";
4
4
  import {
5
5
  upstreamStateOf
6
- } from "./cli-pqd2qe7v.js";
6
+ } from "./cli-btqjpf1q.js";
7
7
  import {
8
8
  withRepoLock
9
- } from "./cli-rcw5adqd.js";
9
+ } from "./cli-wcnyhk4w.js";
10
10
  import {
11
+ isOptionLikeRef,
11
12
  loadRepoContext,
12
13
  nestedWorktrees,
13
14
  worktreeDirtyState
14
- } from "./cli-7jg5qqs9.js";
15
+ } from "./cli-y8vsqre6.js";
15
16
  import {
16
17
  EXIT_USAGE_ERROR,
17
18
  ok,
18
19
  fail
19
- } from "./cli-2bhb9p0x.js";
20
+ } from "./cli-77yanq37.js";
20
21
 
21
22
  // src/domain/garbage.ts
22
23
  var classifyGarbage = (input) => {
@@ -41,9 +42,15 @@ var buildCleanCandidates = async (git, fs, context, ext) => {
41
42
  git.resolveDefaultBranchRef(context.rootPath),
42
43
  git.upstreamStates(context.rootPath)
43
44
  ]);
44
- const targets = context.worktrees.filter((wt) => wt.kind === "managed" || ext && wt.kind === "external");
45
+ const targets = context.worktrees.filter((wt) => (wt.kind === "managed" || ext && wt.kind === "external") && !(wt.branch !== null && isOptionLikeRef(wt.branch)));
45
46
  const results = await Promise.all(targets.map(async (wt) => {
46
- const reason = await classifyWorktree(wt, { git, fs, defaultRef, upstreams, context });
47
+ const reason = await classifyWorktree(wt, {
48
+ git,
49
+ fs,
50
+ defaultRef,
51
+ upstreams,
52
+ context
53
+ });
47
54
  if (reason === null) {
48
55
  return null;
49
56
  }
@@ -51,6 +58,17 @@ var buildCleanCandidates = async (git, fs, context, ext) => {
51
58
  }));
52
59
  return results.filter((candidate) => candidate !== null);
53
60
  };
61
+ var branchMergeFacts = async (git, rootPath, branch, { defaultRef, upstreams }) => {
62
+ const upstreamGone = upstreamStateOf(upstreams, branch).kind === "gone";
63
+ const mergedIntoDefault = defaultRef === null ? "unknown" : await git.isAncestor(rootPath, branch, defaultRef);
64
+ const allCommitsReachableFromDefault = defaultRef === null || mergedIntoDefault === true || !upstreamGone ? "unknown" : await git.hasEquivalentCommits(rootPath, branch, defaultRef);
65
+ return {
66
+ prunable: false,
67
+ upstreamGone,
68
+ mergedIntoDefault,
69
+ allCommitsReachableFromDefault
70
+ };
71
+ };
54
72
  var classifyWorktree = async (wt, { git, fs, defaultRef, upstreams, context }) => {
55
73
  if (wt.prunable) {
56
74
  return classifyGarbage({
@@ -65,15 +83,10 @@ var classifyWorktree = async (wt, { git, fs, defaultRef, upstreams, context }) =
65
83
  return null;
66
84
  }
67
85
  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
- };
86
+ const facts = await branchMergeFacts(git, rootPath, wt.branch, {
87
+ defaultRef,
88
+ upstreams
89
+ });
77
90
  if (classifyGarbage({ ...facts, clean: true }) === null) {
78
91
  return null;
79
92
  }
@@ -132,23 +145,19 @@ var removeCandidate = async ({
132
145
  return "failed";
133
146
  }
134
147
  };
135
- var canDeletePrunableBranch = async (git, rootPath, candidate, { defaultRef, upstreams }) => {
148
+ var canDeletePrunableBranch = async (git, rootPath, candidate, facts) => {
136
149
  if (candidate.reason !== "prunable") {
137
150
  return true;
138
151
  }
139
- if (candidate.branch.length === 0 || defaultRef === null) {
152
+ if (candidate.branch.length === 0) {
140
153
  return false;
141
154
  }
142
155
  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;
156
+ const branchFacts = await branchMergeFacts(git, rootPath, candidate.branch, facts);
157
+ return classifyGarbage({ ...branchFacts, clean: true }) !== null;
158
+ } catch {
159
+ return false;
160
+ }
152
161
  };
153
162
  var executeClean = ({
154
163
  git,
@@ -0,0 +1,30 @@
1
+ // src/infra/term.ts
2
+ import { createInterface } from "node:readline";
3
+ var colorEnabled = (stream) => stream.isTTY === true && (process.env["NO_COLOR"] ?? "") === "";
4
+ var createTermPort = () => ({
5
+ isTTY() {
6
+ return process.stderr.isTTY === true && process.stdin.isTTY === true;
7
+ },
8
+ logStderr(message) {
9
+ process.stderr.write(`${message}
10
+ `);
11
+ },
12
+ confirm(message) {
13
+ return new Promise((resolve) => {
14
+ const rl = createInterface({
15
+ input: process.stdin,
16
+ output: process.stderr,
17
+ terminal: false
18
+ });
19
+ rl.question(`${message} [y/N] `, (answer) => {
20
+ rl.close();
21
+ resolve(answer.trim().toLowerCase() === "y");
22
+ });
23
+ rl.once("close", () => {
24
+ resolve(false);
25
+ });
26
+ });
27
+ }
28
+ });
29
+
30
+ export { colorEnabled, createTermPort };
@@ -15,5 +15,7 @@ var fail = (exitCode, errorMessage, warnings) => ({
15
15
  errorMessage,
16
16
  ...warnings === undefined || warnings.length === 0 ? {} : { warnings }
17
17
  });
18
+ var pass = (value) => ({ ok: true, value });
19
+ var reject = (rejection) => ({ ok: false, rejection });
18
20
 
19
- export { EXIT_SUCCESS, EXIT_GENERAL_ERROR, EXIT_USAGE_ERROR, EXIT_SAFE_REJECTION, EXIT_CANCELLED, ok, fail };
21
+ export { EXIT_SUCCESS, EXIT_GENERAL_ERROR, EXIT_USAGE_ERROR, EXIT_SAFE_REJECTION, EXIT_CANCELLED, ok, fail, pass, reject };
@@ -1,24 +1,26 @@
1
1
  import {
2
2
  messageOf
3
- } from "./cli-qrxgkskr.js";
3
+ } from "./cli-ph0gp56k.js";
4
4
  import {
5
5
  withRepoLock
6
- } from "./cli-rcw5adqd.js";
6
+ } from "./cli-wcnyhk4w.js";
7
7
  import {
8
+ optionLikeRefRejection,
8
9
  loadRepoContext,
9
10
  nestedWorktrees,
10
11
  worktreeDirtyState
11
- } from "./cli-7jg5qqs9.js";
12
+ } from "./cli-y8vsqre6.js";
12
13
  import {
13
14
  EXIT_GENERAL_ERROR,
14
15
  EXIT_USAGE_ERROR,
15
16
  EXIT_SAFE_REJECTION,
16
17
  ok,
17
- fail
18
- } from "./cli-2bhb9p0x.js";
18
+ fail,
19
+ pass,
20
+ reject
21
+ } from "./cli-77yanq37.js";
19
22
 
20
23
  // 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
24
  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
25
  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
26
  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.`;
@@ -26,17 +28,13 @@ var resolveTarget = (worktrees, branch, expectedPath) => {
26
28
  const matches = worktrees.filter((wt) => wt.branch === branch);
27
29
  if (expectedPath !== undefined) {
28
30
  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 };
31
+ return target === undefined ? reject(fail(EXIT_SAFE_REJECTION, `The selected worktree for "${branch}" changed before removal. Refresh the picker and retry.`)) : pass(target);
32
32
  }
33
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
- };
34
+ return reject(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.`));
37
35
  }
38
36
  const [target] = matches;
39
- return target === undefined ? {} : { target };
37
+ return target === undefined ? reject(fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${branch}".`)) : pass(target);
40
38
  };
41
39
  var nestedWorktreeRejection = (branch, targetPath, worktrees) => {
42
40
  const nested = nestedWorktrees(worktrees, targetPath);
@@ -67,27 +65,21 @@ var targetSafetyRejection = async ({ git, fs, context, options }, target) => {
67
65
  var validateRemoval = async (check) => {
68
66
  const { context, options } = check;
69
67
  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
- };
68
+ if (!resolved.ok) {
69
+ return resolved;
79
70
  }
71
+ const target = resolved.value;
80
72
  if (target.kind === "root") {
81
- return { ok: false, rejection: fail(EXIT_USAGE_ERROR, "Cannot remove the root clone.") };
73
+ return reject(fail(EXIT_USAGE_ERROR, "Cannot remove the root clone."));
82
74
  }
83
75
  const rejection = await targetSafetyRejection(check, target);
84
- return rejection === null ? { ok: true, target } : { ok: false, rejection };
76
+ return rejection === null ? resolved : reject(rejection);
85
77
  };
86
- var withExtWarning = (ext, result) => ext ? {
87
- ...result,
88
- warnings: [...result.warnings ?? [], EXT_DEPRECATION_WARNING]
89
- } : result;
90
78
  var removeWorktree = async (git, fs, options) => {
79
+ const refRejection = optionLikeRefRejection("branch", options.branch);
80
+ if (refRejection !== null) {
81
+ return refRejection;
82
+ }
91
83
  const context = await loadRepoContext(git, fs, options.cwd);
92
84
  const validation = await validateRemoval({ git, fs, context, options });
93
85
  if (!validation.ok) {
@@ -100,7 +92,7 @@ var removeWorktree = async (git, fs, options) => {
100
92
  if (!freshValidation.ok) {
101
93
  return freshValidation.rejection;
102
94
  }
103
- const { target } = freshValidation;
95
+ const target = freshValidation.value;
104
96
  await git.removeWorktree(context.rootPath, target.path, options.force);
105
97
  return ok({
106
98
  data: { branch: options.branch, path: target.path },
@@ -111,6 +103,6 @@ var removeWorktree = async (git, fs, options) => {
111
103
  }
112
104
  });
113
105
  };
114
- var rm2 = async (git, fs, options) => withExtWarning(options.ext, await removeWorktree(git, fs, options));
106
+ var rm2 = (git, fs, options) => removeWorktree(git, fs, options);
115
107
 
116
108
  export { rm2 };
@@ -0,0 +1,20 @@
1
+ // src/domain/classify.ts
2
+ var isWithin = (parent, child) => {
3
+ if (parent === child) {
4
+ return false;
5
+ }
6
+ const parentWithSlash = parent.endsWith("/") ? parent : `${parent}/`;
7
+ return child.startsWith(parentWithSlash);
8
+ };
9
+ var isWithinOrSelf = (parent, child) => parent === child || isWithin(parent, child);
10
+ var classifyWorktreePath = (path, rootPath, managedRoot) => {
11
+ if (path === rootPath) {
12
+ return "root";
13
+ }
14
+ if (path === managedRoot || isWithin(managedRoot, path)) {
15
+ return "managed";
16
+ }
17
+ return "external";
18
+ };
19
+
20
+ export { isWithin, isWithinOrSelf, classifyWorktreePath };
@@ -1,21 +1,3 @@
1
- // src/domain/classify.ts
2
- var isWithin = (parent, child) => {
3
- if (parent === child) {
4
- return false;
5
- }
6
- const parentWithSlash = parent.endsWith("/") ? parent : `${parent}/`;
7
- return child.startsWith(parentWithSlash);
8
- };
9
- var classifyWorktreePath = (path, rootPath, managedRoot) => {
10
- if (path === rootPath) {
11
- return "root";
12
- }
13
- if (path === managedRoot || isWithin(managedRoot, path)) {
14
- return "managed";
15
- }
16
- return "external";
17
- };
18
-
19
1
  // src/domain/porcelain.ts
20
2
  var REFS_HEADS_PREFIX = "refs/heads/";
21
3
  var branchNameFromRef = (ref) => ref.startsWith(REFS_HEADS_PREFIX) ? ref.slice(REFS_HEADS_PREFIX.length) : ref;
@@ -101,4 +83,4 @@ var parsePorcelain = (output) => {
101
83
  return result;
102
84
  };
103
85
 
104
- export { isWithin, classifyWorktreePath, REFS_HEADS_PREFIX, parsePorcelain };
86
+ export { REFS_HEADS_PREFIX, parsePorcelain };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  REFS_HEADS_PREFIX
3
- } from "./cli-g72ra4e5.js";
3
+ } from "./cli-b0tk5wcx.js";
4
4
 
5
5
  // src/domain/upstream.ts
6
6
  var NONE = { kind: "none" };
@@ -109,4 +109,18 @@ var truncateToWidthKeepingTail = (value, width, ellipsis = DEFAULT_ELLIPSIS) =>
109
109
  return ellipsis + keptFromEnd.toReversed().join("");
110
110
  };
111
111
 
112
- export { graphemes, displayWidth, padToWidth, truncateToWidth, truncateToWidthKeepingTail };
112
+ // src/domain/ansi.ts
113
+ var ESC = "\x1B";
114
+ var SGR = {
115
+ bold: `${ESC}[1m`,
116
+ dim: `${ESC}[2m`,
117
+ inverse: `${ESC}[7m`,
118
+ red: `${ESC}[31m`,
119
+ green: `${ESC}[32m`,
120
+ yellow: `${ESC}[33m`,
121
+ magenta: `${ESC}[35m`,
122
+ cyan: `${ESC}[36m`
123
+ };
124
+ var SGR_RESET = `${ESC}[0m`;
125
+
126
+ export { graphemes, displayWidth, padToWidth, truncateToWidth, truncateToWidthKeepingTail, ESC, SGR, SGR_RESET };
@@ -1,19 +1,22 @@
1
1
  import {
2
2
  messageOf
3
- } from "./cli-qrxgkskr.js";
3
+ } from "./cli-ph0gp56k.js";
4
4
  import {
5
5
  withRepoLock
6
- } from "./cli-rcw5adqd.js";
6
+ } from "./cli-wcnyhk4w.js";
7
7
  import {
8
+ refArgumentRejection,
8
9
  loadRepoContext,
9
10
  isWorktreeDirty,
10
11
  resolveBranchCheckout
11
- } from "./cli-7jg5qqs9.js";
12
+ } from "./cli-y8vsqre6.js";
12
13
  import {
13
14
  EXIT_SAFE_REJECTION,
14
15
  ok,
15
- fail
16
- } from "./cli-2bhb9p0x.js";
16
+ fail,
17
+ pass,
18
+ reject
19
+ } from "./cli-77yanq37.js";
17
20
 
18
21
  // src/commands/root-holder-swap.ts
19
22
  var holderRejection = (branch, holderPath, reason) => fail(EXIT_SAFE_REJECTION, `Branch "${branch}" is already checked out at ${holderPath}, and it ${reason}. Not swapping — cd there instead of using hop root.`);
@@ -27,51 +30,47 @@ var resolveHolderSwap = async ({
27
30
  policy
28
31
  }) => {
29
32
  if (target === "-") {
30
- return { detachedHolder: null };
33
+ return pass(null);
31
34
  }
32
35
  const freshHolders = fresh.worktrees.filter((wt) => wt.branch === target && wt.kind !== "root");
33
36
  if (freshHolders.length > 1) {
34
- return { rejection: multipleHoldersRejection(target, freshHolders) };
37
+ return reject(multipleHoldersRejection(target, freshHolders));
35
38
  }
36
39
  const [freshHolder] = freshHolders;
37
40
  if (freshHolder === undefined) {
38
- return { detachedHolder: null };
41
+ return pass(null);
39
42
  }
40
43
  if (policy.expectedPath !== undefined && freshHolder.path !== policy.expectedPath) {
41
- return {
42
- rejection: holderRejection(target, freshHolder.path, `changed from the picker selection at ${policy.expectedPath}`)
43
- };
44
+ return reject(holderRejection(target, freshHolder.path, `changed from the picker selection at ${policy.expectedPath}`));
44
45
  }
45
46
  if (freshHolder.kind === "external" && !policy.allowExternal) {
46
- return {
47
- rejection: holderRejection(target, freshHolder.path, "is external and requires confirmation before it can be put into detached HEAD")
48
- };
47
+ return reject(holderRejection(target, freshHolder.path, "is external and requires confirmation before it can be put into detached HEAD"));
49
48
  }
50
49
  if (switchOptions.createBranch === true) {
51
- return {
52
- rejection: holderRejection(target, freshHolder.path, "wasn't visible when hop checked existing local branches (e.g. it has no commits yet, or was created after that check)")
53
- };
50
+ return reject(holderRejection(target, freshHolder.path, "wasn't visible when hop checked existing local branches (e.g. it has no commits yet, or was created after that check)"));
54
51
  }
55
52
  if (freshHolder.locked) {
56
- return {
57
- rejection: holderRejection(target, freshHolder.path, `is locked by git${freshHolder.lockReason === null ? "" : ` (${freshHolder.lockReason})`}`)
58
- };
53
+ return reject(holderRejection(target, freshHolder.path, `is locked by git${freshHolder.lockReason === null ? "" : ` (${freshHolder.lockReason})`}`));
59
54
  }
60
55
  if (freshHolder.prunable) {
61
- return { rejection: prunableHolderRejection(target, freshHolder.path) };
56
+ return reject(prunableHolderRejection(target, freshHolder.path));
62
57
  }
63
58
  const holderDirty = await isWorktreeDirty(git, fresh.worktrees, freshHolder.path);
64
59
  if (holderDirty) {
65
- return {
66
- rejection: holderRejection(target, freshHolder.path, "has uncommitted or untracked changes")
67
- };
60
+ return reject(holderRejection(target, freshHolder.path, "has uncommitted or untracked changes"));
68
61
  }
69
62
  await git.detachHead(freshHolder.path);
70
- return { detachedHolder: { path: freshHolder.path, branch: target } };
63
+ return pass({ path: freshHolder.path, branch: target });
71
64
  };
72
65
 
73
66
  // src/commands/root.ts
74
67
  var root2 = async (git, fs, options) => {
68
+ if (options.target !== undefined && options.target !== "-") {
69
+ const refRejection = refArgumentRejection(options.target, options.track);
70
+ if (refRejection !== null) {
71
+ return refRejection;
72
+ }
73
+ }
75
74
  const context = await loadRepoContext(git, fs, options.cwd);
76
75
  if (options.target === undefined) {
77
76
  return ok({
@@ -106,7 +105,7 @@ var root2 = async (git, fs, options) => {
106
105
  fs,
107
106
  context,
108
107
  target: options.target,
109
- switchOptions: checkout.options,
108
+ switchOptions: checkout.value,
110
109
  holderPolicy: {
111
110
  allowExternal: options.allowExternalHolderSwap ?? true,
112
111
  ...options.expectedHolderPath === undefined ? {} : { expectedPath: options.expectedHolderPath }
@@ -163,10 +162,10 @@ var switchAndReport = ({
163
162
  switchOptions,
164
163
  policy: holderPolicy
165
164
  });
166
- if ("rejection" in holderSwap) {
165
+ if (!holderSwap.ok) {
167
166
  return holderSwap.rejection;
168
167
  }
169
- ({ detachedHolder } = holderSwap);
168
+ detachedHolder = holderSwap.value;
170
169
  await git.switchBranch(fresh.rootPath, target, switchOptions);
171
170
  let resolvedBranch = target;
172
171
  if (target === "-") {