@solaqua/gji 0.4.0 → 0.5.0
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.md +124 -11
- package/dist/back.d.ts +13 -0
- package/dist/back.js +74 -0
- package/dist/clean.d.ts +1 -0
- package/dist/clean.js +103 -13
- package/dist/cli.js +104 -4
- package/dist/completion.d.ts +6 -0
- package/dist/completion.js +11 -0
- package/dist/git.d.ts +3 -0
- package/dist/git.js +38 -0
- package/dist/gji +5 -0
- package/dist/gji-bundle.mjs +16705 -0
- package/dist/go.js +2 -0
- package/dist/history-command.d.ts +7 -0
- package/dist/history-command.js +15 -0
- package/dist/history.d.ts +9 -0
- package/dist/history.js +46 -0
- package/dist/init.d.ts +1 -1
- package/dist/init.js +9 -22
- package/dist/ls.d.ts +3 -0
- package/dist/ls.js +46 -2
- package/dist/new.js +3 -0
- package/dist/pr.js +46 -1
- package/dist/shell-completion.d.ts +1 -0
- package/dist/shell-completion.js +284 -0
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +21 -0
- package/dist/sync.js +2 -13
- package/dist/worktree-info.d.ts +33 -0
- package/dist/worktree-info.js +105 -0
- package/man/man1/gji-back.1 +13 -0
- package/man/man1/gji-clean.1 +4 -1
- package/man/man1/gji-completion.1 +9 -0
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-go.1 +1 -1
- package/man/man1/gji-history.1 +13 -0
- package/man/man1/gji-init.1 +1 -1
- package/man/man1/gji-ls.1 +4 -1
- package/man/man1/gji-new.1 +1 -1
- package/man/man1/gji-pr.1 +1 -1
- package/man/man1/gji-remove.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-trigger-hook.1 +1 -1
- package/man/man1/gji.1 +13 -1
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# gji — Git worktrees without the hassle
|
|
2
2
|
|
|
3
|
-
> Jump between tasks instantly. No stash. No
|
|
3
|
+
> Jump between tasks instantly. No stash. No branch juggling. No mess.
|
|
4
4
|
|
|
5
5
|
`gji` wraps Git worktrees into a fast, ergonomic CLI. Each branch gets its own directory, its own `node_modules`, and its own terminal — so switching context is a single command instead of a ritual.
|
|
6
6
|
|
|
7
|
+
That matters even more in AI-assisted workflows, where one repository often has several active tasks in parallel: your main feature, a PR review, a scratch experiment, or an agent-driven refactor. `gji` keeps each one isolated and easy to enter.
|
|
8
|
+
|
|
7
9
|
```sh
|
|
8
10
|
gji new feature/payment-refactor # new branch + worktree, cd in
|
|
9
11
|
gji pr 1234 # review PR in isolation, cd in
|
|
@@ -11,6 +13,23 @@ gji go main # jump back, shell changes directory
|
|
|
11
13
|
gji remove feature/payment-refactor
|
|
12
14
|
```
|
|
13
15
|
|
|
16
|
+
## Before / After
|
|
17
|
+
|
|
18
|
+
<table>
|
|
19
|
+
<tr>
|
|
20
|
+
<td width="50%" valign="top">
|
|
21
|
+
<strong>Before</strong><br />
|
|
22
|
+
<img src=".github/assets/readme-before.gif" alt="Traditional branch review flow with git stash, branch switching, reinstalling dependencies, and a merge conflict on stash pop." />
|
|
23
|
+
</td>
|
|
24
|
+
<td width="50%" valign="top">
|
|
25
|
+
<strong>After</strong><br />
|
|
26
|
+
<img src=".github/assets/readme-after.gif" alt="gji creating an isolated pull request worktree from the terminal in a few commands." />
|
|
27
|
+
</td>
|
|
28
|
+
</tr>
|
|
29
|
+
</table>
|
|
30
|
+
|
|
31
|
+
Maintainer note: `pnpm generate:readme-demos` currently expects macOS, `zsh`, Google Chrome, `asciinema`, and `ffmpeg`.
|
|
32
|
+
|
|
14
33
|
---
|
|
15
34
|
|
|
16
35
|
**If `gji` has saved you from a `git stash` spiral, a ⭐ on [GitHub](https://github.com/sjquant/gji) means a lot — it helps other developers find this tool.**
|
|
@@ -23,13 +42,26 @@ You are deep in a feature branch. A colleague asks for a quick review. You:
|
|
|
23
42
|
|
|
24
43
|
1. stash your changes
|
|
25
44
|
2. checkout their branch
|
|
26
|
-
3. wait for `
|
|
45
|
+
3. wait for `npm install` to finish
|
|
27
46
|
4. review
|
|
28
47
|
5. checkout back
|
|
29
48
|
6. pop your stash
|
|
30
49
|
7. realize something is broken
|
|
31
50
|
|
|
32
|
-
**Or you use `gji` and
|
|
51
|
+
**Or you use `gji`, run `gji pr 1234`, and let the fresh worktree boot itself.**
|
|
52
|
+
|
|
53
|
+
## Why it matters more now
|
|
54
|
+
|
|
55
|
+
AI increases the amount of parallel work around a codebase.
|
|
56
|
+
|
|
57
|
+
It is increasingly normal to have:
|
|
58
|
+
|
|
59
|
+
1. your own branch open
|
|
60
|
+
2. another branch for review
|
|
61
|
+
3. a scratch space for testing an AI-generated change
|
|
62
|
+
4. a separate worktree for validating a risky migration or refactor
|
|
63
|
+
|
|
64
|
+
That makes Git worktrees more important, because a single shared checkout becomes the bottleneck. `gji` turns worktrees into a daily workflow instead of a Git power-user feature.
|
|
33
65
|
|
|
34
66
|
## Install
|
|
35
67
|
|
|
@@ -41,10 +73,32 @@ Then add shell integration so `gji go`, `gji new`, and `gji remove` can change y
|
|
|
41
73
|
|
|
42
74
|
```sh
|
|
43
75
|
# zsh
|
|
44
|
-
echo 'eval "$(gji init zsh)"' >> ~/.zshrc
|
|
76
|
+
echo 'eval "$(gji init zsh)"' >> ~/.zshrc
|
|
45
77
|
|
|
46
78
|
# bash
|
|
47
|
-
echo 'eval "$(gji init bash)"' >> ~/.bashrc
|
|
79
|
+
echo 'eval "$(gji init bash)"' >> ~/.bashrc
|
|
80
|
+
|
|
81
|
+
# fish
|
|
82
|
+
gji init fish --write
|
|
83
|
+
source ~/.config/fish/config.fish
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Install completions as separate files:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
# zsh
|
|
90
|
+
mkdir -p ~/.zsh/completions
|
|
91
|
+
gji completion zsh > ~/.zsh/completions/_gji
|
|
92
|
+
# add this before running compinit in ~/.zshrc
|
|
93
|
+
fpath=(~/.zsh/completions $fpath)
|
|
94
|
+
|
|
95
|
+
# bash
|
|
96
|
+
mkdir -p ~/.local/share/bash-completion/completions
|
|
97
|
+
gji completion bash > ~/.local/share/bash-completion/completions/gji
|
|
98
|
+
|
|
99
|
+
# fish
|
|
100
|
+
mkdir -p ~/.config/fish/completions
|
|
101
|
+
gji completion fish > ~/.config/fish/completions/gji.fish
|
|
48
102
|
```
|
|
49
103
|
|
|
50
104
|
## Quick start
|
|
@@ -88,17 +142,33 @@ gji go feature/auth-refactor # jump to a worktree
|
|
|
88
142
|
gji root # jump to repo root
|
|
89
143
|
|
|
90
144
|
gji status # health overview + ahead/behind counts
|
|
91
|
-
gji ls #
|
|
145
|
+
gji ls # list with status/upstream/last commit
|
|
146
|
+
gji ls --compact # branch/path only
|
|
92
147
|
|
|
93
148
|
gji sync # rebase current worktree onto default branch
|
|
94
149
|
gji sync --all # rebase every worktree
|
|
95
150
|
|
|
96
151
|
gji clean # interactive bulk cleanup
|
|
152
|
+
gji clean --stale # only target safe stale cleanup candidates
|
|
97
153
|
gji remove feature/auth-refactor # remove one worktree and its branch
|
|
98
154
|
|
|
99
155
|
gji trigger-hook afterCreate # re-run setup in the current worktree
|
|
100
156
|
```
|
|
101
157
|
|
|
158
|
+
## Comparison
|
|
159
|
+
|
|
160
|
+
`gji` sits between raw Git primitives and larger Git or repository tools:
|
|
161
|
+
|
|
162
|
+
- **vs raw `git worktree`**: same underlying capability, but with branch-first commands, shell handoff, PR checkout, hooks, sync, and cleanup built into the workflow
|
|
163
|
+
- **vs `lazygit`**: `lazygit` is a broad Git UI; `gji` is narrower and faster for opening, jumping between, and removing isolated branch directories
|
|
164
|
+
- **vs `ghq`**: `ghq` organizes repositories; `gji` organizes active branches and PRs within one repository
|
|
165
|
+
|
|
166
|
+
Use `gji` when your bottleneck is repeated context switching between features, reviews, and maintenance work without disturbing what is already open.
|
|
167
|
+
|
|
168
|
+
It is especially useful when those contexts are happening in parallel across both human and AI-assisted work.
|
|
169
|
+
|
|
170
|
+
See the full comparison in [website/docs/comparison.mdx](./website/docs/comparison.mdx).
|
|
171
|
+
|
|
102
172
|
## Shell setup
|
|
103
173
|
|
|
104
174
|
Without shell integration `gji` prints paths and exits — which is fine for scripts but means it cannot `cd` you into a new worktree. Install the integration once:
|
|
@@ -107,7 +177,7 @@ Without shell integration `gji` prints paths and exits — which is fine for scr
|
|
|
107
177
|
gji init zsh # prints the shell function, review it if you like
|
|
108
178
|
```
|
|
109
179
|
|
|
110
|
-
|
|
180
|
+
Install the wrapper once:
|
|
111
181
|
|
|
112
182
|
```sh
|
|
113
183
|
# zsh
|
|
@@ -115,12 +185,42 @@ echo 'eval "$(gji init zsh)"' >> ~/.zshrc
|
|
|
115
185
|
|
|
116
186
|
# bash
|
|
117
187
|
echo 'eval "$(gji init bash)"' >> ~/.bashrc
|
|
188
|
+
|
|
189
|
+
# fish
|
|
190
|
+
gji init fish --write
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Install completions separately so your shell rc stays small:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
# zsh
|
|
197
|
+
mkdir -p ~/.zsh/completions
|
|
198
|
+
gji completion zsh > ~/.zsh/completions/_gji
|
|
199
|
+
# add this before running compinit in ~/.zshrc
|
|
200
|
+
fpath=(~/.zsh/completions $fpath)
|
|
201
|
+
|
|
202
|
+
# bash
|
|
203
|
+
mkdir -p ~/.local/share/bash-completion/completions
|
|
204
|
+
gji completion bash > ~/.local/share/bash-completion/completions/gji
|
|
205
|
+
|
|
206
|
+
# fish
|
|
207
|
+
mkdir -p ~/.config/fish/completions
|
|
208
|
+
gji completion fish > ~/.config/fish/completions/gji.fish
|
|
118
209
|
```
|
|
119
210
|
|
|
120
|
-
After a reinstall or upgrade,
|
|
211
|
+
After a reinstall or upgrade, refresh both the wrapper and the completion file:
|
|
121
212
|
|
|
122
213
|
```sh
|
|
214
|
+
# zsh
|
|
123
215
|
eval "$(gji init zsh)"
|
|
216
|
+
gji completion zsh > ~/.zsh/completions/_gji
|
|
217
|
+
# if zsh is already running, refresh completion discovery too
|
|
218
|
+
autoload -Uz compinit && compinit
|
|
219
|
+
|
|
220
|
+
# fish
|
|
221
|
+
gji init fish --write
|
|
222
|
+
gji completion fish > ~/.config/fish/completions/gji.fish
|
|
223
|
+
source ~/.config/fish/config.fish
|
|
124
224
|
```
|
|
125
225
|
|
|
126
226
|
For scripts that need the raw path, use `--print`:
|
|
@@ -139,13 +239,14 @@ path=$(gji root --print)
|
|
|
139
239
|
| `gji go [branch] [--print]` | jump to a worktree |
|
|
140
240
|
| `gji root [--print]` | jump to the main repo root |
|
|
141
241
|
| `gji status [--json]` | repo overview, worktree health, ahead/behind |
|
|
142
|
-
| `gji ls [--json]` | list active worktrees |
|
|
242
|
+
| `gji ls [--compact] [--json]` | list active worktrees |
|
|
143
243
|
| `gji sync [--all]` | fetch and rebase worktrees onto default branch |
|
|
144
|
-
| `gji clean [--force] [--json]` | interactively prune
|
|
244
|
+
| `gji clean [--stale] [--force] [--json]` | interactively prune linked worktrees |
|
|
145
245
|
| `gji remove [branch] [--force] [--json]` | remove a worktree and its branch |
|
|
146
246
|
| `gji trigger-hook <hook>` | run a hook in the current worktree |
|
|
147
247
|
| `gji config [get\|set\|unset] [key] [value]` | manage global defaults |
|
|
148
248
|
| `gji init [shell]` | print or install shell integration |
|
|
249
|
+
| `gji completion [shell]` | print shell completion definitions |
|
|
149
250
|
|
|
150
251
|
## Configuration
|
|
151
252
|
|
|
@@ -283,6 +384,10 @@ gji new --json feature/dark-mode
|
|
|
283
384
|
gji pr --json 1234
|
|
284
385
|
# → { "branch": "pr/1234", "path": "/…/worktrees/repo/pr/1234" }
|
|
285
386
|
|
|
387
|
+
# detailed list
|
|
388
|
+
gji ls --json
|
|
389
|
+
# → [{ "branch": "...", "status": "clean", "upstream": { "kind": "tracked", ... }, ... }]
|
|
390
|
+
|
|
286
391
|
# remove
|
|
287
392
|
gji remove --json --force feature/dark-mode
|
|
288
393
|
# → { "branch": "feature/dark-mode", "path": "/…", "deleted": true }
|
|
@@ -291,10 +396,16 @@ gji remove --json --force feature/dark-mode
|
|
|
291
396
|
gji clean --json --force
|
|
292
397
|
# → { "removed": [{ "branch": "...", "path": "..." }, …] }
|
|
293
398
|
|
|
399
|
+
# stale-only clean
|
|
400
|
+
gji clean --stale --json --force
|
|
401
|
+
# → { "removed": [{ "branch": "...", "path": "..." }, …] }
|
|
402
|
+
|
|
294
403
|
# error shape (any command)
|
|
295
404
|
# stderr → { "error": "branch argument is required" }
|
|
296
405
|
```
|
|
297
406
|
|
|
407
|
+
`gji clean --stale` limits cleanup to clean branch worktrees whose upstream is gone and whose branch is already merged into the configured or remote default branch.
|
|
408
|
+
|
|
298
409
|
`--json` suppresses all interactive prompts. `--force` is required for `remove` and `clean` in JSON mode. `branch` is `null` for detached worktrees.
|
|
299
410
|
|
|
300
411
|
`gji ls --json` and `gji status --json` also produce structured output — see `gji status --json | jq` for the full schema.
|
|
@@ -309,11 +420,13 @@ GJI_NO_TUI=1 gji clean --force
|
|
|
309
420
|
|
|
310
421
|
`GJI_NO_TUI=1` disables all prompts. Commands that need confirmation require `--force`. `--json` implies the same behaviour.
|
|
311
422
|
|
|
423
|
+
Update notifications are also suppressed automatically in non-interactive and `--json` runs. Users can opt out explicitly with `NO_UPDATE_NOTIFIER=1` or `--no-update-notifier`.
|
|
424
|
+
|
|
312
425
|
## Notes
|
|
313
426
|
|
|
314
427
|
- Works from either the main repo root or inside any linked worktree
|
|
315
428
|
- The current worktree is never offered as a `gji clean` candidate
|
|
316
|
-
- `gji pr`
|
|
429
|
+
- `gji pr` fetches from `origin` using the first matching forge ref namespace: GitHub `refs/pull/<number>/head`, GitLab `refs/merge-requests/<number>/head`, then Bitbucket `refs/pull-requests/<number>/from`
|
|
317
430
|
|
|
318
431
|
## License
|
|
319
432
|
|
package/dist/back.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type HistoryEntry } from './history.js';
|
|
2
|
+
export declare const BACK_OUTPUT_FILE_ENV = "GJI_BACK_OUTPUT_FILE";
|
|
3
|
+
export interface BackCommandOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
home?: string;
|
|
6
|
+
n?: number;
|
|
7
|
+
print?: boolean;
|
|
8
|
+
stderr: (chunk: string) => void;
|
|
9
|
+
stdout: (chunk: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function runBackCommand(options: BackCommandOptions): Promise<number>;
|
|
12
|
+
export declare function formatHistoryList(history: HistoryEntry[], cwd: string): string;
|
|
13
|
+
export declare function formatAge(timestamp: number): string;
|
package/dist/back.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { loadEffectiveConfig } from './config.js';
|
|
4
|
+
import { extractHooks, runHook } from './hooks.js';
|
|
5
|
+
import { appendHistory, loadHistory } from './history.js';
|
|
6
|
+
import { detectRepository } from './repo.js';
|
|
7
|
+
import { writeShellOutput } from './shell-handoff.js';
|
|
8
|
+
export const BACK_OUTPUT_FILE_ENV = 'GJI_BACK_OUTPUT_FILE';
|
|
9
|
+
export async function runBackCommand(options) {
|
|
10
|
+
const history = await loadHistory(options.home);
|
|
11
|
+
const steps = options.n ?? 1;
|
|
12
|
+
if (steps < 1) {
|
|
13
|
+
options.stderr('gji back: step count must be at least 1\n');
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
let found = 0;
|
|
17
|
+
let target;
|
|
18
|
+
for (const entry of history) {
|
|
19
|
+
if (entry.path === options.cwd)
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
await access(entry.path);
|
|
23
|
+
found++;
|
|
24
|
+
if (found === steps) {
|
|
25
|
+
target = entry;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Path no longer exists — skip to the next entry
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!target) {
|
|
34
|
+
options.stderr('gji back: no previous worktree in history\n');
|
|
35
|
+
options.stderr("Hint: Use 'gji go', 'gji new', or 'gji pr' to navigate between worktrees\n");
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const repository = await detectRepository(target.path);
|
|
40
|
+
const config = await loadEffectiveConfig(repository.repoRoot, options.home, options.stderr);
|
|
41
|
+
const hooks = extractHooks(config);
|
|
42
|
+
await runHook(hooks.afterEnter, target.path, { branch: target.branch ?? undefined, path: target.path, repo: basename(repository.repoRoot) }, options.stderr);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Not in a git repo or hooks unavailable — proceed without hook
|
|
46
|
+
}
|
|
47
|
+
await appendHistory(target.path, target.branch, options.home);
|
|
48
|
+
await writeShellOutput(BACK_OUTPUT_FILE_ENV, target.path, options.stdout);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
export function formatHistoryList(history, cwd) {
|
|
52
|
+
const branchWidth = Math.max('BRANCH'.length, ...history.map((e) => (e.branch ?? '(detached)').length));
|
|
53
|
+
const lines = [' ' + 'BRANCH'.padEnd(branchWidth) + ' WHEN PATH'];
|
|
54
|
+
for (const entry of history) {
|
|
55
|
+
const isCurrent = entry.path === cwd;
|
|
56
|
+
const branch = (entry.branch ?? '(detached)').padEnd(branchWidth);
|
|
57
|
+
const when = formatAge(entry.timestamp).padEnd(10);
|
|
58
|
+
lines.push(`${isCurrent ? '*' : ' '} ${branch} ${when} ${entry.path}`);
|
|
59
|
+
}
|
|
60
|
+
return lines.join('\n') + '\n';
|
|
61
|
+
}
|
|
62
|
+
export function formatAge(timestamp) {
|
|
63
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
64
|
+
if (seconds < 60)
|
|
65
|
+
return 'just now';
|
|
66
|
+
const minutes = Math.floor(seconds / 60);
|
|
67
|
+
if (minutes < 60)
|
|
68
|
+
return `${minutes}m ago`;
|
|
69
|
+
const hours = Math.floor(minutes / 60);
|
|
70
|
+
if (hours < 24)
|
|
71
|
+
return `${hours}h ago`;
|
|
72
|
+
const days = Math.floor(hours / 24);
|
|
73
|
+
return `${days}d ago`;
|
|
74
|
+
}
|
package/dist/clean.d.ts
CHANGED
package/dist/clean.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { confirm, isCancel, multiselect } from '@clack/prompts';
|
|
2
|
-
import {
|
|
2
|
+
import { loadEffectiveConfig } from './config.js';
|
|
3
|
+
import { isBranchMergedInto, readWorktreeHealth, resolveRemoteDefaultBranch, runGit } from './git.js';
|
|
3
4
|
import { isHeadless } from './headless.js';
|
|
5
|
+
import { formatLastCommit, formatUpstreamState, formatWorktreeHint, readWorktreeInfos, serializeWorktreeInfo, } from './worktree-info.js';
|
|
4
6
|
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
|
|
5
7
|
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree } from './worktree-prompts.js';
|
|
6
8
|
export function createCleanCommand(dependencies = {}) {
|
|
@@ -10,8 +12,18 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
10
12
|
const confirmForceDeleteBranch = dependencies.confirmForceDeleteBranch ?? defaultConfirmForceDeleteBranch;
|
|
11
13
|
return async function runCleanCommand(options) {
|
|
12
14
|
const { linkedWorktrees, repository } = await loadLinkedWorktrees(options.cwd);
|
|
13
|
-
const
|
|
15
|
+
const linkedCleanupCandidates = linkedWorktrees.filter((worktree) => worktree.path !== repository.currentRoot);
|
|
16
|
+
const staleBaseRef = options.stale
|
|
17
|
+
? await resolveStaleBaseRef(repository.repoRoot, options.stderr)
|
|
18
|
+
: null;
|
|
19
|
+
const cleanupCandidates = options.stale
|
|
20
|
+
? await filterStaleCleanupCandidates(repository.repoRoot, linkedCleanupCandidates, staleBaseRef)
|
|
21
|
+
: linkedCleanupCandidates;
|
|
14
22
|
if (cleanupCandidates.length === 0) {
|
|
23
|
+
if (options.stale) {
|
|
24
|
+
emitNoStaleCandidates(options);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
15
27
|
emitError(options, 'No linked worktrees to clean');
|
|
16
28
|
return 1;
|
|
17
29
|
}
|
|
@@ -25,8 +37,8 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
25
37
|
}
|
|
26
38
|
return 1;
|
|
27
39
|
}
|
|
28
|
-
// With --force, or dry-
|
|
29
|
-
const shouldSelectAll = options.force || (options.dryRun && (options.json || isHeadless()));
|
|
40
|
+
// With --force, or non-interactive dry-runs, skip selection prompt and target all candidates.
|
|
41
|
+
const shouldSelectAll = options.force || (options.dryRun && (options.stale || options.json || isHeadless()));
|
|
30
42
|
const selections = shouldSelectAll
|
|
31
43
|
? cleanupCandidates.map((w) => w.path)
|
|
32
44
|
: await promptForWorktrees(cleanupCandidates);
|
|
@@ -39,19 +51,20 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
39
51
|
options.stderr('Selected worktree no longer exists\n');
|
|
40
52
|
return 1;
|
|
41
53
|
}
|
|
54
|
+
const selectedWorktreeInfos = await readWorktreeInfos(selectedWorktrees);
|
|
55
|
+
const selectedInfoByPath = new Map(selectedWorktreeInfos.map((info) => [info.path, info]));
|
|
42
56
|
if (!options.dryRun && !options.force && !(await confirmRemoval(selectedWorktrees))) {
|
|
43
57
|
options.stderr('Aborted\n');
|
|
44
58
|
return 1;
|
|
45
59
|
}
|
|
46
60
|
if (options.dryRun) {
|
|
47
61
|
if (options.json) {
|
|
48
|
-
const removed =
|
|
62
|
+
const removed = selectedWorktreeInfos.map((info) => serializeWorktreeInfo(info));
|
|
49
63
|
options.stdout(`${JSON.stringify({ removed, dryRun: true }, null, 2)}\n`);
|
|
50
64
|
}
|
|
51
65
|
else {
|
|
52
|
-
for (const
|
|
53
|
-
|
|
54
|
-
options.stdout(`Would remove worktree at ${w.path} (${desc})\n`);
|
|
66
|
+
for (const info of selectedWorktreeInfos) {
|
|
67
|
+
options.stdout(`Would remove worktree at ${info.path} (${formatCleanInfo(info)})\n`);
|
|
55
68
|
}
|
|
56
69
|
}
|
|
57
70
|
return 0;
|
|
@@ -59,6 +72,10 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
59
72
|
const removedPaths = [];
|
|
60
73
|
const removedWorktrees = [];
|
|
61
74
|
for (const worktree of selectedWorktrees) {
|
|
75
|
+
if (options.stale && !(await isStaleCleanupCandidate(repository.repoRoot, worktree, staleBaseRef))) {
|
|
76
|
+
options.stderr(`Skipped ${worktree.path}: no longer a safe stale cleanup candidate\n`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
62
79
|
try {
|
|
63
80
|
await removeWorktree(repository.repoRoot, worktree.path);
|
|
64
81
|
}
|
|
@@ -66,6 +83,10 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
66
83
|
if (!isWorktreeDirtyError(error)) {
|
|
67
84
|
throw error;
|
|
68
85
|
}
|
|
86
|
+
if (options.stale) {
|
|
87
|
+
options.stderr(`Skipped ${worktree.path}: no longer a safe stale cleanup candidate\n`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
69
90
|
if (!options.force && !(await confirmForceRemoveWorktree(worktree.path))) {
|
|
70
91
|
reportRemovedPaths(removedPaths, options.stderr);
|
|
71
92
|
options.stderr('Aborted\n');
|
|
@@ -107,7 +128,12 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
107
128
|
}
|
|
108
129
|
}
|
|
109
130
|
if (options.json) {
|
|
110
|
-
const removed = removedWorktrees.map((
|
|
131
|
+
const removed = removedWorktrees.map((worktree) => {
|
|
132
|
+
const info = selectedInfoByPath.get(worktree.path);
|
|
133
|
+
return info === undefined
|
|
134
|
+
? { branch: worktree.branch, path: worktree.path }
|
|
135
|
+
: serializeWorktreeInfo(info);
|
|
136
|
+
});
|
|
111
137
|
options.stdout(`${JSON.stringify({ removed }, null, 2)}\n`);
|
|
112
138
|
}
|
|
113
139
|
else {
|
|
@@ -117,6 +143,55 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
117
143
|
};
|
|
118
144
|
}
|
|
119
145
|
export const runCleanCommand = createCleanCommand();
|
|
146
|
+
async function filterStaleCleanupCandidates(repoRoot, worktrees, baseBranch) {
|
|
147
|
+
if (baseBranch === null) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const results = await Promise.all(worktrees.map((worktree) => isStaleCleanupCandidate(repoRoot, worktree, baseBranch)));
|
|
151
|
+
return worktrees.filter((_, index) => results[index]);
|
|
152
|
+
}
|
|
153
|
+
async function resolveStaleBaseRef(repoRoot, stderr) {
|
|
154
|
+
const config = await loadEffectiveConfig(repoRoot, undefined, stderr);
|
|
155
|
+
const remote = resolveConfiguredString(config.syncRemote) ?? 'origin';
|
|
156
|
+
const configuredDefaultBranch = resolveConfiguredString(config.syncDefaultBranch);
|
|
157
|
+
if (configuredDefaultBranch) {
|
|
158
|
+
return await resolveFetchedRemoteRef(repoRoot, remote, configuredDefaultBranch);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const remoteDefaultBranch = await resolveRemoteDefaultBranch(repoRoot, remote);
|
|
162
|
+
return remoteDefaultBranch === null
|
|
163
|
+
? null
|
|
164
|
+
: await resolveFetchedRemoteRef(repoRoot, remote, remoteDefaultBranch);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function resolveFetchedRemoteRef(repoRoot, remote, branch) {
|
|
171
|
+
try {
|
|
172
|
+
await runGit(repoRoot, ['fetch', '--prune', remote]);
|
|
173
|
+
return `${remote}/${branch}`;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function resolveConfiguredString(value) {
|
|
180
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
181
|
+
}
|
|
182
|
+
async function isStaleCleanupCandidate(repoRoot, worktree, baseBranch) {
|
|
183
|
+
if (baseBranch === null) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (worktree.branch === null) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
const health = await readWorktreeHealth(worktree.path);
|
|
190
|
+
if (health.status !== 'clean' || !health.upstreamGone) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return isBranchMergedInto(repoRoot, worktree.branch, baseBranch);
|
|
194
|
+
}
|
|
120
195
|
function resolveSelectedWorktrees(worktrees, selections) {
|
|
121
196
|
const selectedWorktrees = [];
|
|
122
197
|
const seenPaths = new Set();
|
|
@@ -135,6 +210,13 @@ function reportRemovedPaths(paths, stderr) {
|
|
|
135
210
|
stderr(`Already removed: ${paths.join(', ')}\n`);
|
|
136
211
|
}
|
|
137
212
|
}
|
|
213
|
+
function formatCleanInfo(info) {
|
|
214
|
+
const branch = info.branch === null ? 'detached' : `branch: ${info.branch}`;
|
|
215
|
+
const status = `status: ${info.status}`;
|
|
216
|
+
const upstream = `upstream: ${formatUpstreamState(info.upstream)}`;
|
|
217
|
+
const last = `last: ${formatLastCommit(info.lastCommitTimestamp)}`;
|
|
218
|
+
return [branch, status, upstream, last].join(', ');
|
|
219
|
+
}
|
|
138
220
|
function emitError(options, message) {
|
|
139
221
|
if (options.json) {
|
|
140
222
|
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
@@ -143,18 +225,26 @@ function emitError(options, message) {
|
|
|
143
225
|
options.stderr(`${message}\n`);
|
|
144
226
|
}
|
|
145
227
|
}
|
|
228
|
+
function emitNoStaleCandidates(options) {
|
|
229
|
+
if (options.json) {
|
|
230
|
+
const payload = options.dryRun
|
|
231
|
+
? { removed: [], dryRun: true }
|
|
232
|
+
: { removed: [] };
|
|
233
|
+
options.stdout(`${JSON.stringify(payload, null, 2)}\n`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
options.stdout('No stale linked worktrees to clean\n');
|
|
237
|
+
}
|
|
146
238
|
function toMessage(error) {
|
|
147
239
|
return error instanceof Error ? error.message : String(error);
|
|
148
240
|
}
|
|
149
241
|
async function defaultPromptForWorktrees(worktrees) {
|
|
150
|
-
const
|
|
242
|
+
const infos = await readWorktreeInfos(worktrees);
|
|
151
243
|
const choice = await multiselect({
|
|
152
244
|
message: 'Choose worktrees to clean',
|
|
153
245
|
options: worktrees.map((worktree, i) => {
|
|
154
|
-
const health = healthResults[i].status === 'fulfilled' ? healthResults[i].value : null;
|
|
155
|
-
const isStale = health?.upstreamGone === true;
|
|
156
246
|
return {
|
|
157
|
-
hint:
|
|
247
|
+
hint: formatWorktreeHint(infos[i]),
|
|
158
248
|
label: worktree.branch ?? '(detached)',
|
|
159
249
|
value: worktree.path,
|
|
160
250
|
};
|