@n-seiji/nuthatch 0.1.5 → 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 +31 -21
- package/README.md +31 -21
- package/dist/clean-rn2476my.js +217 -0
- package/dist/cli-26d2drt6.js +30 -0
- package/dist/cli-77yanq37.js +21 -0
- package/dist/cli-9n04gsq1.js +108 -0
- package/dist/cli-a56b2ar0.js +20 -0
- package/dist/cli-b0tk5wcx.js +86 -0
- package/dist/cli-btqjpf1q.js +35 -0
- package/dist/cli-dbzx9nzg.js +40 -0
- package/dist/cli-fhta3tr8.js +126 -0
- package/dist/cli-hz0r7zw8.js +194 -0
- package/dist/cli-ph0gp56k.js +54 -0
- package/dist/cli-pick-yjd4qpd9.js +1444 -0
- package/dist/cli-s5qcjcwk.js +25 -0
- package/dist/cli-sre95k9x.js +87 -0
- package/dist/cli-update-x0r4b4xk.js +639 -0
- package/dist/cli-wcnyhk4w.js +124 -0
- package/dist/cli-y8vsqre6.js +97 -0
- package/dist/cli.js +245 -5868
- package/{shell/init.zsh → dist/init-167d1bbs.js} +22 -4
- package/dist/jump-1zkraat1.js +108 -0
- package/dist/ls-3hyjf60d.js +34 -0
- package/dist/render-ls-x4hpnsvy.js +149 -0
- package/dist/rm-eg3j8ae1.js +7 -0
- package/dist/root-49vcb8x2.js +7 -0
- package/package.json +4 -6
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 #
|
|
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
|
|
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 /
|
|
158
|
+
hop --update # standalone バイナリを最新リリースへ置換
|
|
159
|
+
hop --update --check # 報告だけ: current / latest / インストール方法 / すべきこと
|
|
150
160
|
hop --version # 今どの version か
|
|
151
161
|
```
|
|
152
162
|
|
|
153
|
-
`hop --update`
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
hop
|
|
168
|
-
|
|
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 #
|
|
46
|
-
hop --update --check # only report
|
|
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
|
|
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 #
|
|
155
|
-
hop --update --check # just report: current, latest,
|
|
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`
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
messageOf
|
|
3
|
+
} from "./cli-ph0gp56k.js";
|
|
4
|
+
import {
|
|
5
|
+
upstreamStateOf
|
|
6
|
+
} from "./cli-btqjpf1q.js";
|
|
7
|
+
import {
|
|
8
|
+
withRepoLock
|
|
9
|
+
} from "./cli-wcnyhk4w.js";
|
|
10
|
+
import {
|
|
11
|
+
isOptionLikeRef,
|
|
12
|
+
loadRepoContext,
|
|
13
|
+
nestedWorktrees,
|
|
14
|
+
worktreeDirtyState
|
|
15
|
+
} from "./cli-y8vsqre6.js";
|
|
16
|
+
import {
|
|
17
|
+
EXIT_USAGE_ERROR,
|
|
18
|
+
ok,
|
|
19
|
+
fail
|
|
20
|
+
} from "./cli-77yanq37.js";
|
|
21
|
+
|
|
22
|
+
// src/domain/garbage.ts
|
|
23
|
+
var classifyGarbage = (input) => {
|
|
24
|
+
if (input.prunable) {
|
|
25
|
+
return "prunable";
|
|
26
|
+
}
|
|
27
|
+
if (!input.clean) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (input.mergedIntoDefault === true) {
|
|
31
|
+
return "merged";
|
|
32
|
+
}
|
|
33
|
+
if (input.upstreamGone && input.allCommitsReachableFromDefault === true) {
|
|
34
|
+
return "gone";
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/commands/clean-candidates.ts
|
|
40
|
+
var buildCleanCandidates = async (git, fs, context, ext) => {
|
|
41
|
+
const [defaultRef, upstreams] = await Promise.all([
|
|
42
|
+
git.resolveDefaultBranchRef(context.rootPath),
|
|
43
|
+
git.upstreamStates(context.rootPath)
|
|
44
|
+
]);
|
|
45
|
+
const targets = context.worktrees.filter((wt) => (wt.kind === "managed" || ext && wt.kind === "external") && !(wt.branch !== null && isOptionLikeRef(wt.branch)));
|
|
46
|
+
const results = await Promise.all(targets.map(async (wt) => {
|
|
47
|
+
const reason = await classifyWorktree(wt, {
|
|
48
|
+
git,
|
|
49
|
+
fs,
|
|
50
|
+
defaultRef,
|
|
51
|
+
upstreams,
|
|
52
|
+
context
|
|
53
|
+
});
|
|
54
|
+
if (reason === null) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return { branch: wt.branch ?? "", path: wt.path, reason };
|
|
58
|
+
}));
|
|
59
|
+
return results.filter((candidate) => candidate !== null);
|
|
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
|
+
};
|
|
72
|
+
var classifyWorktree = async (wt, { git, fs, defaultRef, upstreams, context }) => {
|
|
73
|
+
if (wt.prunable) {
|
|
74
|
+
return classifyGarbage({
|
|
75
|
+
prunable: true,
|
|
76
|
+
clean: false,
|
|
77
|
+
mergedIntoDefault: "unknown",
|
|
78
|
+
upstreamGone: false,
|
|
79
|
+
allCommitsReachableFromDefault: "unknown"
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (wt.branch === null) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const { rootPath, worktrees } = context;
|
|
86
|
+
const facts = await branchMergeFacts(git, rootPath, wt.branch, {
|
|
87
|
+
defaultRef,
|
|
88
|
+
upstreams
|
|
89
|
+
});
|
|
90
|
+
if (classifyGarbage({ ...facts, clean: true }) === null) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const dirtyState = await worktreeDirtyState(git, fs, worktrees, wt);
|
|
94
|
+
const input = { ...facts, clean: dirtyState === false };
|
|
95
|
+
return classifyGarbage(input);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/commands/clean.ts
|
|
99
|
+
var clean = async (git, fs, term, options) => {
|
|
100
|
+
const context = await loadRepoContext(git, fs, options.cwd);
|
|
101
|
+
const candidates = await buildCleanCandidates(git, fs, context, options.ext);
|
|
102
|
+
if (options.dryRun) {
|
|
103
|
+
return ok({ data: { candidates } });
|
|
104
|
+
}
|
|
105
|
+
if (candidates.length === 0) {
|
|
106
|
+
return ok({ data: { candidates, removed: [] } });
|
|
107
|
+
}
|
|
108
|
+
if (!options.yes) {
|
|
109
|
+
if (!term.isTTY()) {
|
|
110
|
+
return fail(EXIT_USAGE_ERROR, "hop clean would remove worktrees; re-run with --yes (non-interactive) or --dry-run to only list candidates.");
|
|
111
|
+
}
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
term.logStderr(`${candidate.reason} ${candidate.branch} ${candidate.path}`);
|
|
114
|
+
}
|
|
115
|
+
const confirmed = await term.confirm(`Remove ${candidates.length} worktree(s) above?`);
|
|
116
|
+
if (!confirmed) {
|
|
117
|
+
return ok({ data: { candidates, removed: [] } });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return executeClean({
|
|
121
|
+
git,
|
|
122
|
+
fs,
|
|
123
|
+
context,
|
|
124
|
+
candidates,
|
|
125
|
+
cleanOptions: options
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
var removeCandidate = async ({
|
|
129
|
+
git,
|
|
130
|
+
rootPath,
|
|
131
|
+
candidate,
|
|
132
|
+
deleteBranch,
|
|
133
|
+
worktrees
|
|
134
|
+
}) => {
|
|
135
|
+
if (nestedWorktrees(worktrees, candidate.path).length > 0) {
|
|
136
|
+
return "nested";
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await git.removeWorktree(rootPath, candidate.path, false);
|
|
140
|
+
if (deleteBranch && candidate.branch.length > 0) {
|
|
141
|
+
await git.deleteBranch(rootPath, candidate.branch);
|
|
142
|
+
}
|
|
143
|
+
return "removed";
|
|
144
|
+
} catch {
|
|
145
|
+
return "failed";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
var canDeletePrunableBranch = async (git, rootPath, candidate, facts) => {
|
|
149
|
+
if (candidate.reason !== "prunable") {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (candidate.branch.length === 0) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const branchFacts = await branchMergeFacts(git, rootPath, candidate.branch, facts);
|
|
157
|
+
return classifyGarbage({ ...branchFacts, clean: true }) !== null;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var executeClean = ({
|
|
163
|
+
git,
|
|
164
|
+
fs,
|
|
165
|
+
context,
|
|
166
|
+
candidates,
|
|
167
|
+
cleanOptions
|
|
168
|
+
}) => withRepoLock(context.commonDir, async () => {
|
|
169
|
+
try {
|
|
170
|
+
const fresh = await loadRepoContext(git, fs, context.rootPath);
|
|
171
|
+
const freshCandidates = await buildCleanCandidates(git, fs, fresh, cleanOptions.ext);
|
|
172
|
+
const freshByPath = new Map(freshCandidates.map((candidate) => [candidate.path, candidate]));
|
|
173
|
+
const stillValid = candidates.flatMap((candidate) => {
|
|
174
|
+
const freshCandidate = freshByPath.get(candidate.path);
|
|
175
|
+
return freshCandidate === undefined ? [] : [freshCandidate];
|
|
176
|
+
});
|
|
177
|
+
const [defaultRef, upstreams] = await Promise.all([
|
|
178
|
+
git.resolveDefaultBranchRef(fresh.rootPath),
|
|
179
|
+
git.upstreamStates(fresh.rootPath)
|
|
180
|
+
]);
|
|
181
|
+
const removed = [];
|
|
182
|
+
const warnings = [];
|
|
183
|
+
for (const candidate of stillValid) {
|
|
184
|
+
const branchSafe = await canDeletePrunableBranch(git, fresh.rootPath, candidate, {
|
|
185
|
+
defaultRef,
|
|
186
|
+
upstreams
|
|
187
|
+
});
|
|
188
|
+
const deleteBranch = cleanOptions.withBranch && branchSafe;
|
|
189
|
+
if (cleanOptions.withBranch && candidate.reason === "prunable" && candidate.branch.length > 0 && !branchSafe) {
|
|
190
|
+
warnings.push(`Keeping branch "${candidate.branch}": prunable worktree is not confirmed merged or gone.`);
|
|
191
|
+
}
|
|
192
|
+
const outcome = await removeCandidate({
|
|
193
|
+
git,
|
|
194
|
+
rootPath: fresh.rootPath,
|
|
195
|
+
candidate,
|
|
196
|
+
deleteBranch,
|
|
197
|
+
worktrees: fresh.worktrees
|
|
198
|
+
});
|
|
199
|
+
if (outcome === "removed") {
|
|
200
|
+
removed.push(candidate.branch);
|
|
201
|
+
} else if (outcome === "nested") {
|
|
202
|
+
warnings.push(`Skipped ${candidate.path}: still contains a registered worktree.`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return ok({
|
|
206
|
+
data: { candidates: [...candidates], removed },
|
|
207
|
+
...warnings.length === 0 ? {} : { warnings }
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new Error(`Failed to clean worktrees: ${messageOf(error)}`, {
|
|
211
|
+
cause: error
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
export {
|
|
216
|
+
clean
|
|
217
|
+
};
|
|
@@ -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 };
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
var pass = (value) => ({ ok: true, value });
|
|
19
|
+
var reject = (rejection) => ({ ok: false, rejection });
|
|
20
|
+
|
|
21
|
+
export { EXIT_SUCCESS, EXIT_GENERAL_ERROR, EXIT_USAGE_ERROR, EXIT_SAFE_REJECTION, EXIT_CANCELLED, ok, fail, pass, reject };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
messageOf
|
|
3
|
+
} from "./cli-ph0gp56k.js";
|
|
4
|
+
import {
|
|
5
|
+
withRepoLock
|
|
6
|
+
} from "./cli-wcnyhk4w.js";
|
|
7
|
+
import {
|
|
8
|
+
optionLikeRefRejection,
|
|
9
|
+
loadRepoContext,
|
|
10
|
+
nestedWorktrees,
|
|
11
|
+
worktreeDirtyState
|
|
12
|
+
} from "./cli-y8vsqre6.js";
|
|
13
|
+
import {
|
|
14
|
+
EXIT_GENERAL_ERROR,
|
|
15
|
+
EXIT_USAGE_ERROR,
|
|
16
|
+
EXIT_SAFE_REJECTION,
|
|
17
|
+
ok,
|
|
18
|
+
fail,
|
|
19
|
+
pass,
|
|
20
|
+
reject
|
|
21
|
+
} from "./cli-77yanq37.js";
|
|
22
|
+
|
|
23
|
+
// src/commands/rm.ts
|
|
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.`);
|
|
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.`);
|
|
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.`;
|
|
27
|
+
var resolveTarget = (worktrees, branch, expectedPath) => {
|
|
28
|
+
const matches = worktrees.filter((wt) => wt.branch === branch);
|
|
29
|
+
if (expectedPath !== undefined) {
|
|
30
|
+
const target = matches.find((wt) => wt.path === expectedPath);
|
|
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
|
+
}
|
|
33
|
+
if (matches.length > 1) {
|
|
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.`));
|
|
35
|
+
}
|
|
36
|
+
const [target] = matches;
|
|
37
|
+
return target === undefined ? reject(fail(EXIT_GENERAL_ERROR, `No worktree found for branch "${branch}".`)) : pass(target);
|
|
38
|
+
};
|
|
39
|
+
var nestedWorktreeRejection = (branch, targetPath, worktrees) => {
|
|
40
|
+
const nested = nestedWorktrees(worktrees, targetPath);
|
|
41
|
+
if (nested.length === 0) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const listing = nested.map((wt) => ` ${wt.path}${wt.branch === null ? "" : ` (${wt.branch})`}`).join(`
|
|
45
|
+
`);
|
|
46
|
+
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:
|
|
47
|
+
${listing}`);
|
|
48
|
+
};
|
|
49
|
+
var targetSafetyRejection = async ({ git, fs, context, options }, target) => {
|
|
50
|
+
const nestedRejection = nestedWorktreeRejection(options.branch, target.path, context.worktrees);
|
|
51
|
+
if (nestedRejection !== null) {
|
|
52
|
+
return nestedRejection;
|
|
53
|
+
}
|
|
54
|
+
if (target.locked) {
|
|
55
|
+
return lockedRejection(options.branch, target.lockReason);
|
|
56
|
+
}
|
|
57
|
+
if (target.prunable && options.allowPrunable === false) {
|
|
58
|
+
return turnedPrunableRejection(options.branch);
|
|
59
|
+
}
|
|
60
|
+
if (!options.force && await worktreeDirtyState(git, fs, context.worktrees, target) === true) {
|
|
61
|
+
return fail(EXIT_SAFE_REJECTION, `Worktree for "${options.branch}" has uncommitted or untracked changes. Use --force to remove anyway.`);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
var validateRemoval = async (check) => {
|
|
66
|
+
const { context, options } = check;
|
|
67
|
+
const resolved = resolveTarget(context.worktrees, options.branch, options.expectedPath);
|
|
68
|
+
if (!resolved.ok) {
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
const target = resolved.value;
|
|
72
|
+
if (target.kind === "root") {
|
|
73
|
+
return reject(fail(EXIT_USAGE_ERROR, "Cannot remove the root clone."));
|
|
74
|
+
}
|
|
75
|
+
const rejection = await targetSafetyRejection(check, target);
|
|
76
|
+
return rejection === null ? resolved : reject(rejection);
|
|
77
|
+
};
|
|
78
|
+
var removeWorktree = async (git, fs, options) => {
|
|
79
|
+
const refRejection = optionLikeRefRejection("branch", options.branch);
|
|
80
|
+
if (refRejection !== null) {
|
|
81
|
+
return refRejection;
|
|
82
|
+
}
|
|
83
|
+
const context = await loadRepoContext(git, fs, options.cwd);
|
|
84
|
+
const validation = await validateRemoval({ git, fs, context, options });
|
|
85
|
+
if (!validation.ok) {
|
|
86
|
+
return validation.rejection;
|
|
87
|
+
}
|
|
88
|
+
return withRepoLock(context.commonDir, async () => {
|
|
89
|
+
try {
|
|
90
|
+
const fresh = await loadRepoContext(git, fs, options.cwd);
|
|
91
|
+
const freshValidation = await validateRemoval({ git, fs, context: fresh, options });
|
|
92
|
+
if (!freshValidation.ok) {
|
|
93
|
+
return freshValidation.rejection;
|
|
94
|
+
}
|
|
95
|
+
const target = freshValidation.value;
|
|
96
|
+
await git.removeWorktree(context.rootPath, target.path, options.force);
|
|
97
|
+
return ok({
|
|
98
|
+
data: { branch: options.branch, path: target.path },
|
|
99
|
+
...target.prunable ? { warnings: [stalePrunableWarning(target)] } : {}
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return fail(EXIT_SAFE_REJECTION, `Failed to remove worktree: ${messageOf(error)}`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
var rm2 = (git, fs, options) => removeWorktree(git, fs, options);
|
|
107
|
+
|
|
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 };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// src/domain/porcelain.ts
|
|
2
|
+
var REFS_HEADS_PREFIX = "refs/heads/";
|
|
3
|
+
var branchNameFromRef = (ref) => ref.startsWith(REFS_HEADS_PREFIX) ? ref.slice(REFS_HEADS_PREFIX.length) : ref;
|
|
4
|
+
var parseRecord = (lines) => {
|
|
5
|
+
let path = null;
|
|
6
|
+
let head = null;
|
|
7
|
+
let branch = null;
|
|
8
|
+
let detached = false;
|
|
9
|
+
let bare = false;
|
|
10
|
+
let locked = false;
|
|
11
|
+
let lockReason = null;
|
|
12
|
+
let prunable = false;
|
|
13
|
+
let prunableReason = null;
|
|
14
|
+
for (const line of lines.filter((candidate) => candidate.length > 0)) {
|
|
15
|
+
const spaceIndex = line.indexOf(" ");
|
|
16
|
+
const key = spaceIndex === -1 ? line : line.slice(0, spaceIndex);
|
|
17
|
+
const value = spaceIndex === -1 ? "" : line.slice(spaceIndex + 1);
|
|
18
|
+
switch (key) {
|
|
19
|
+
case "worktree": {
|
|
20
|
+
path = value;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
case "HEAD": {
|
|
24
|
+
head = value;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case "branch": {
|
|
28
|
+
branch = branchNameFromRef(value);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case "detached": {
|
|
32
|
+
detached = true;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case "bare": {
|
|
36
|
+
bare = true;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case "locked": {
|
|
40
|
+
locked = true;
|
|
41
|
+
lockReason = value.length > 0 ? value : null;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case "prunable": {
|
|
45
|
+
prunable = true;
|
|
46
|
+
prunableReason = value.length > 0 ? value : null;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
default: {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (path === null) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
path,
|
|
59
|
+
head,
|
|
60
|
+
branch,
|
|
61
|
+
detached,
|
|
62
|
+
bare,
|
|
63
|
+
locked,
|
|
64
|
+
lockReason,
|
|
65
|
+
prunable,
|
|
66
|
+
prunableReason
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
var parsePorcelain = (output) => {
|
|
70
|
+
if (output.length === 0) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const trimmed = output.endsWith("\x00") ? output.slice(0, -1) : output;
|
|
74
|
+
const records = trimmed.split("\x00\x00");
|
|
75
|
+
const result = [];
|
|
76
|
+
for (const record of records.filter((candidate) => candidate.length > 0)) {
|
|
77
|
+
const lines = record.split("\x00");
|
|
78
|
+
const parsed = parseRecord(lines);
|
|
79
|
+
if (parsed !== null) {
|
|
80
|
+
result.push(parsed);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export { REFS_HEADS_PREFIX, parsePorcelain };
|