@solaqua/gji 0.1.0 → 0.2.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 +163 -164
- package/dist/clean.d.ts +1 -0
- package/dist/clean.js +37 -5
- package/dist/cli.js +9 -3
- package/dist/config.d.ts +2 -0
- package/dist/config.js +14 -0
- package/dist/file-sync.d.ts +9 -0
- package/dist/file-sync.js +52 -0
- package/dist/go.js +5 -0
- package/dist/headless.d.ts +6 -0
- package/dist/headless.js +8 -0
- package/dist/install-prompt.d.ts +10 -0
- package/dist/install-prompt.js +99 -0
- package/dist/new.d.ts +3 -1
- package/dist/new.js +48 -2
- package/dist/package-manager.d.ts +5 -0
- package/dist/package-manager.js +108 -0
- package/dist/pr.d.ts +3 -1
- package/dist/pr.js +47 -4
- package/dist/remove.d.ts +1 -0
- package/dist/remove.js +38 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,277 +1,276 @@
|
|
|
1
|
-
# gji
|
|
1
|
+
# gji — Git worktrees without the hassle
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Jump between tasks instantly. No stash. No reinstall. No mess.
|
|
4
4
|
|
|
5
|
-
`gji`
|
|
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
|
-
|
|
7
|
+
```sh
|
|
8
|
+
gji new feature/payment-refactor # new branch + worktree, cd in
|
|
9
|
+
gji pr 1234 # review PR in isolation, cd in
|
|
10
|
+
gji go main # jump back, shell changes directory
|
|
11
|
+
gji remove feature/payment-refactor
|
|
12
|
+
```
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
---
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
- hopping between feature work and PR checks
|
|
13
|
-
- using multiple terminals, editors, or AI agents at the same time
|
|
16
|
+
**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.**
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
---
|
|
16
19
|
|
|
17
|
-
##
|
|
20
|
+
## The problem
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
You are deep in a feature branch. A colleague asks for a quick review. You:
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
1. stash your changes
|
|
25
|
+
2. checkout their branch
|
|
26
|
+
3. wait for `pnpm install` to finish
|
|
27
|
+
4. review
|
|
28
|
+
5. checkout back
|
|
29
|
+
6. pop your stash
|
|
30
|
+
7. realize something is broken
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
**Or you use `gji` and it is just `gji pr 1234`.**
|
|
33
|
+
|
|
34
|
+
## Install
|
|
30
35
|
|
|
31
36
|
```sh
|
|
32
|
-
gji
|
|
33
|
-
gji --help
|
|
37
|
+
npm install -g @solaqua/gji
|
|
34
38
|
```
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Inside a Git repository:
|
|
40
|
+
Then add shell integration so `gji go`, `gji new`, and `gji remove` can change your directory:
|
|
39
41
|
|
|
40
42
|
```sh
|
|
41
|
-
|
|
42
|
-
gji
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
That creates a linked worktree at a deterministic path:
|
|
43
|
+
# zsh
|
|
44
|
+
echo 'eval "$(gji init zsh)"' >> ~/.zshrc && source ~/.zshrc
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
# bash
|
|
47
|
+
echo 'eval "$(gji init bash)"' >> ~/.bashrc && source ~/.bashrc
|
|
49
48
|
```
|
|
50
49
|
|
|
51
|
-
##
|
|
52
|
-
|
|
53
|
-
`gji new`, `gji go`, `gji root`, and `gji remove`/`gji rm` can only change your current directory when shell integration is installed. Without shell integration, the raw CLI prints the target path so it stays script-friendly.
|
|
54
|
-
|
|
55
|
-
For zsh:
|
|
50
|
+
## Quick start
|
|
56
51
|
|
|
57
52
|
```sh
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
```
|
|
53
|
+
# start a new task
|
|
54
|
+
gji new feature/dark-mode
|
|
61
55
|
|
|
62
|
-
|
|
56
|
+
# review a pull request
|
|
57
|
+
gji pr 1234
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
gji
|
|
66
|
-
gji go feature/login-form
|
|
67
|
-
gji root
|
|
68
|
-
gji rm feature/login-form
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
changes your shell directory directly.
|
|
59
|
+
# see what's open
|
|
60
|
+
gji status
|
|
72
61
|
|
|
73
|
-
|
|
62
|
+
# jump between worktrees
|
|
63
|
+
gji go feature/dark-mode
|
|
64
|
+
gji go main
|
|
74
65
|
|
|
75
|
-
|
|
76
|
-
|
|
66
|
+
# clean up when done
|
|
67
|
+
gji remove feature/dark-mode
|
|
77
68
|
```
|
|
78
69
|
|
|
79
|
-
|
|
70
|
+
Worktrees land at a deterministic path so your editor bookmarks and scripts always know where to look:
|
|
80
71
|
|
|
81
|
-
```sh
|
|
82
|
-
gji new feature/login-form
|
|
83
|
-
gji go --print feature/login-form
|
|
84
|
-
gji root --print
|
|
85
72
|
```
|
|
86
|
-
|
|
87
|
-
|
|
73
|
+
../worktrees/<repo>/<branch>
|
|
74
|
+
```
|
|
88
75
|
|
|
89
76
|
## Daily workflow
|
|
90
77
|
|
|
91
|
-
Start a task:
|
|
92
|
-
|
|
93
78
|
```sh
|
|
94
|
-
gji new feature/refactor
|
|
95
|
-
|
|
79
|
+
gji new feature/auth-refactor # new branch + worktree
|
|
80
|
+
gji new --detached # scratch space, auto-named
|
|
96
81
|
|
|
97
|
-
|
|
82
|
+
gji pr 1234 # checkout PR locally
|
|
83
|
+
gji pr https://github.com/org/repo/pull/1234 # or paste the URL
|
|
98
84
|
|
|
99
|
-
|
|
100
|
-
gji
|
|
101
|
-
```
|
|
85
|
+
gji go feature/auth-refactor # jump to a worktree
|
|
86
|
+
gji root # jump to repo root
|
|
102
87
|
|
|
103
|
-
|
|
88
|
+
gji status # health overview + ahead/behind counts
|
|
89
|
+
gji ls # compact list
|
|
104
90
|
|
|
105
|
-
|
|
106
|
-
gji
|
|
107
|
-
gji ls
|
|
108
|
-
```
|
|
91
|
+
gji sync # rebase current worktree onto default branch
|
|
92
|
+
gji sync --all # rebase every worktree
|
|
109
93
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
```sh
|
|
113
|
-
gji pr 123
|
|
114
|
-
gji pr #123
|
|
115
|
-
gji pr https://github.com/owner/repo/pull/123
|
|
94
|
+
gji clean # interactive bulk cleanup
|
|
95
|
+
gji remove feature/auth-refactor # remove one worktree and its branch
|
|
116
96
|
```
|
|
117
97
|
|
|
118
|
-
|
|
98
|
+
## Shell setup
|
|
99
|
+
|
|
100
|
+
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:
|
|
119
101
|
|
|
120
102
|
```sh
|
|
121
|
-
gji
|
|
103
|
+
gji init zsh # prints the shell function, review it if you like
|
|
122
104
|
```
|
|
123
105
|
|
|
124
|
-
|
|
106
|
+
To install automatically:
|
|
125
107
|
|
|
126
108
|
```sh
|
|
127
|
-
|
|
109
|
+
# zsh
|
|
110
|
+
echo 'eval "$(gji init zsh)"' >> ~/.zshrc
|
|
111
|
+
|
|
112
|
+
# bash
|
|
113
|
+
echo 'eval "$(gji init bash)"' >> ~/.bashrc
|
|
128
114
|
```
|
|
129
115
|
|
|
130
|
-
|
|
116
|
+
After a reinstall or upgrade, re-source to pick up changes:
|
|
131
117
|
|
|
132
118
|
```sh
|
|
133
|
-
gji
|
|
119
|
+
eval "$(gji init zsh)"
|
|
134
120
|
```
|
|
135
121
|
|
|
136
|
-
|
|
122
|
+
For scripts that need the raw path, use `--print`:
|
|
137
123
|
|
|
138
124
|
```sh
|
|
139
|
-
gji
|
|
140
|
-
|
|
141
|
-
gji rm feature/refactor-auth
|
|
125
|
+
path=$(gji go --print feature/dark-mode)
|
|
126
|
+
path=$(gji root --print)
|
|
142
127
|
```
|
|
143
128
|
|
|
144
|
-
After removal, the shell-integrated command returns you to the repository root.
|
|
145
|
-
|
|
146
129
|
## Commands
|
|
147
130
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
131
|
+
| Command | Description |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `gji new [branch] [--detached] [--json]` | create branch + worktree, cd in |
|
|
134
|
+
| `gji pr <ref> [--json]` | fetch PR ref, create worktree, cd in |
|
|
135
|
+
| `gji go [branch] [--print]` | jump to a worktree |
|
|
136
|
+
| `gji root [--print]` | jump to the main repo root |
|
|
137
|
+
| `gji status [--json]` | repo overview, worktree health, ahead/behind |
|
|
138
|
+
| `gji ls [--json]` | list active worktrees |
|
|
139
|
+
| `gji sync [--all]` | fetch and rebase worktrees onto default branch |
|
|
140
|
+
| `gji clean [--force] [--json]` | interactively prune stale worktrees |
|
|
141
|
+
| `gji remove [branch] [--force] [--json]` | remove a worktree and its branch |
|
|
142
|
+
| `gji config [get\|set\|unset] [key] [value]` | manage global defaults |
|
|
143
|
+
| `gji init [shell]` | print or install shell integration |
|
|
160
144
|
|
|
161
145
|
## Configuration
|
|
162
146
|
|
|
163
|
-
|
|
147
|
+
No setup required. Optional config lives in:
|
|
164
148
|
|
|
165
|
-
-
|
|
166
|
-
- repo-local
|
|
149
|
+
- `~/.config/gji/config.json` — global defaults
|
|
150
|
+
- `.gji.json` — repo-local overrides (takes precedence)
|
|
167
151
|
|
|
168
|
-
|
|
152
|
+
### Available keys
|
|
169
153
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
154
|
+
| Key | Description |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `branchPrefix` | prefix added to new branch names (e.g. `"feature/"`) |
|
|
157
|
+
| `syncRemote` | remote for `gji sync` (default: `origin`) |
|
|
158
|
+
| `syncDefaultBranch` | branch to rebase onto (default: remote `HEAD`) |
|
|
159
|
+
| `syncFiles` | files to copy from main worktree into each new worktree |
|
|
160
|
+
| `skipInstallPrompt` | `true` to disable the auto-install prompt permanently |
|
|
161
|
+
| `hooks` | lifecycle scripts (see [Hooks](#hooks)) |
|
|
178
162
|
|
|
179
163
|
```json
|
|
180
164
|
{
|
|
181
165
|
"branchPrefix": "feature/",
|
|
182
166
|
"syncRemote": "upstream",
|
|
183
|
-
"syncDefaultBranch": "main"
|
|
167
|
+
"syncDefaultBranch": "main",
|
|
168
|
+
"syncFiles": [".env.example", ".nvmrc"]
|
|
184
169
|
}
|
|
185
170
|
```
|
|
186
171
|
|
|
187
|
-
|
|
172
|
+
### Config commands
|
|
188
173
|
|
|
189
|
-
|
|
190
|
-
|
|
174
|
+
```sh
|
|
175
|
+
gji config get
|
|
176
|
+
gji config get branchPrefix
|
|
177
|
+
gji config set branchPrefix feature/
|
|
178
|
+
gji config unset branchPrefix
|
|
179
|
+
```
|
|
191
180
|
|
|
192
181
|
## Hooks
|
|
193
182
|
|
|
194
|
-
|
|
183
|
+
Run scripts automatically at key lifecycle moments:
|
|
195
184
|
|
|
196
185
|
```json
|
|
197
186
|
{
|
|
198
187
|
"hooks": {
|
|
199
188
|
"afterCreate": "pnpm install",
|
|
200
|
-
"afterEnter": "echo switched to {{branch}}",
|
|
189
|
+
"afterEnter": "echo 'switched to {{branch}}'",
|
|
201
190
|
"beforeRemove": "pnpm run cleanup"
|
|
202
191
|
}
|
|
203
192
|
}
|
|
204
193
|
```
|
|
205
194
|
|
|
206
|
-
Hook
|
|
207
|
-
|
|
208
|
-
- `afterCreate` — runs after a new worktree is created, whether via `gji new` or `gji pr`
|
|
209
|
-
- `afterEnter` — runs after switching to a worktree via `gji go`
|
|
210
|
-
- `beforeRemove` — runs before a worktree is removed via `gji remove`
|
|
211
|
-
|
|
212
|
-
Each hook receives context in two ways:
|
|
213
|
-
|
|
214
|
-
**Template variables** (substituted into the command string):
|
|
215
|
-
|
|
216
|
-
| Variable | Value |
|
|
195
|
+
| Hook | When it runs |
|
|
217
196
|
|---|---|
|
|
218
|
-
| `
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
197
|
+
| `afterCreate` | after `gji new` or `gji pr` creates a worktree |
|
|
198
|
+
| `afterEnter` | after `gji go` switches to a worktree |
|
|
199
|
+
| `beforeRemove` | before `gji remove` deletes a worktree |
|
|
221
200
|
|
|
222
|
-
|
|
201
|
+
Hooks receive `{{branch}}`, `{{path}}`, `{{repo}}` as template variables and `GJI_BRANCH`, `GJI_PATH`, `GJI_REPO` as environment variables. A failing hook emits a warning but never aborts the command.
|
|
223
202
|
|
|
224
|
-
|
|
225
|
-
|---|---|
|
|
226
|
-
| `GJI_BRANCH` | branch name, or empty string for detached worktrees |
|
|
227
|
-
| `GJI_PATH` | absolute path to the worktree |
|
|
228
|
-
| `GJI_REPO` | repository directory name |
|
|
229
|
-
|
|
230
|
-
Hooks run inside the worktree directory. A non-zero exit emits a warning but does not abort the command.
|
|
231
|
-
|
|
232
|
-
Global and project-level hooks are merged per key — project values override global values for the same key, while keys only present in the global config still apply:
|
|
203
|
+
Global and repo-local hooks deep-merge per key:
|
|
233
204
|
|
|
234
|
-
```
|
|
205
|
+
```jsonc
|
|
235
206
|
// ~/.config/gji/config.json
|
|
236
207
|
{ "hooks": { "afterCreate": "nvm use", "afterEnter": "echo hi" } }
|
|
237
208
|
|
|
238
209
|
// .gji.json
|
|
239
210
|
{ "hooks": { "afterCreate": "pnpm install" } }
|
|
240
211
|
|
|
241
|
-
// effective
|
|
242
|
-
{ "afterCreate": "pnpm install", "afterEnter": "echo hi" }
|
|
212
|
+
// effective
|
|
213
|
+
{ "hooks": { "afterCreate": "pnpm install", "afterEnter": "echo hi" } }
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Install prompt
|
|
217
|
+
|
|
218
|
+
When `gji new` or `gji pr` creates a worktree, `gji` detects the project's package manager from its lockfile and offers to run the install command:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
Run `pnpm install` in the new worktree?
|
|
222
|
+
› Yes run once
|
|
223
|
+
No skip this time
|
|
224
|
+
Always save as afterCreate hook
|
|
225
|
+
Never disable this prompt for this repo
|
|
243
226
|
```
|
|
244
227
|
|
|
228
|
+
**Always** saves `hooks.afterCreate` to `.gji.json`; **Never** writes `skipInstallPrompt: true`. Both are local-only — global config is never modified.
|
|
229
|
+
|
|
245
230
|
## JSON output
|
|
246
231
|
|
|
247
|
-
|
|
232
|
+
Every mutating command supports `--json` for scripting and AI agent use. Success goes to stdout, errors go to stderr with exit code 1.
|
|
248
233
|
|
|
249
234
|
```sh
|
|
250
|
-
|
|
235
|
+
# create
|
|
236
|
+
gji new --json feature/dark-mode
|
|
237
|
+
# → { "branch": "feature/dark-mode", "path": "/…/worktrees/repo/feature/dark-mode" }
|
|
238
|
+
|
|
239
|
+
# fetch PR
|
|
240
|
+
gji pr --json 1234
|
|
241
|
+
# → { "branch": "pr/1234", "path": "/…/worktrees/repo/pr/1234" }
|
|
242
|
+
|
|
243
|
+
# remove
|
|
244
|
+
gji remove --json --force feature/dark-mode
|
|
245
|
+
# → { "branch": "feature/dark-mode", "path": "/…", "deleted": true }
|
|
246
|
+
|
|
247
|
+
# bulk clean
|
|
248
|
+
gji clean --json --force
|
|
249
|
+
# → { "removed": [{ "branch": "...", "path": "..." }, …] }
|
|
250
|
+
|
|
251
|
+
# error shape (any command)
|
|
252
|
+
# stderr → { "error": "branch argument is required" }
|
|
251
253
|
```
|
|
252
254
|
|
|
253
|
-
`
|
|
255
|
+
`--json` suppresses all interactive prompts. `--force` is required for `remove` and `clean` in JSON mode. `branch` is `null` for detached worktrees.
|
|
256
|
+
|
|
257
|
+
`gji ls --json` and `gji status --json` also produce structured output — see `gji status --json | jq` for the full schema.
|
|
254
258
|
|
|
255
|
-
-
|
|
256
|
-
- `currentRoot`
|
|
257
|
-
- `worktrees`
|
|
259
|
+
## Non-interactive / CI mode
|
|
258
260
|
|
|
259
|
-
|
|
261
|
+
```sh
|
|
262
|
+
GJI_NO_TUI=1 gji new feature/ci-branch
|
|
263
|
+
GJI_NO_TUI=1 gji remove --force feature/ci-branch
|
|
264
|
+
GJI_NO_TUI=1 gji clean --force
|
|
265
|
+
```
|
|
260
266
|
|
|
261
|
-
|
|
262
|
-
- `current`
|
|
263
|
-
- `path`
|
|
264
|
-
- `status`: `clean` or `dirty`
|
|
265
|
-
- `upstream`: one of
|
|
266
|
-
- `{ "kind": "detached" }`
|
|
267
|
-
- `{ "kind": "no-upstream" }`
|
|
268
|
-
- `{ "kind": "tracked", "ahead": number, "behind": number }`
|
|
267
|
+
`GJI_NO_TUI=1` disables all prompts. Commands that need confirmation require `--force`. `--json` implies the same behaviour.
|
|
269
268
|
|
|
270
269
|
## Notes
|
|
271
270
|
|
|
272
|
-
-
|
|
273
|
-
-
|
|
274
|
-
- `gji pr`
|
|
271
|
+
- Works from either the main repo root or inside any linked worktree
|
|
272
|
+
- The current worktree is never offered as a `gji clean` candidate
|
|
273
|
+
- `gji pr` parses GitHub, GitLab, and Bitbucket URLs but always fetches via `refs/pull/<number>/head` from `origin`
|
|
275
274
|
|
|
276
275
|
## License
|
|
277
276
|
|
package/dist/clean.d.ts
CHANGED
package/dist/clean.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { confirm, isCancel, multiselect } from '@clack/prompts';
|
|
2
|
+
import { isHeadless } from './headless.js';
|
|
2
3
|
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
|
|
3
4
|
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree } from './worktree-prompts.js';
|
|
4
5
|
export function createCleanCommand(dependencies = {}) {
|
|
@@ -10,10 +11,23 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
10
11
|
const { linkedWorktrees, repository } = await loadLinkedWorktrees(options.cwd);
|
|
11
12
|
const cleanupCandidates = linkedWorktrees.filter((worktree) => worktree.path !== repository.currentRoot);
|
|
12
13
|
if (cleanupCandidates.length === 0) {
|
|
13
|
-
options
|
|
14
|
+
emitError(options, 'No linked worktrees to clean');
|
|
14
15
|
return 1;
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
+
if (!options.force && (options.json || isHeadless())) {
|
|
18
|
+
const message = '--force is required';
|
|
19
|
+
if (options.json) {
|
|
20
|
+
emitError(options, message);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
options.stderr(`gji clean: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
24
|
+
}
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
// With --force, skip selection prompt and target all candidates.
|
|
28
|
+
const selections = options.force
|
|
29
|
+
? cleanupCandidates.map((w) => w.path)
|
|
30
|
+
: await promptForWorktrees(cleanupCandidates);
|
|
17
31
|
if (!selections || selections.length === 0) {
|
|
18
32
|
options.stderr('Aborted\n');
|
|
19
33
|
return 1;
|
|
@@ -28,6 +42,7 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
28
42
|
return 1;
|
|
29
43
|
}
|
|
30
44
|
const removedPaths = [];
|
|
45
|
+
const removedWorktrees = [];
|
|
31
46
|
for (const worktree of selectedWorktrees) {
|
|
32
47
|
try {
|
|
33
48
|
await removeWorktree(repository.repoRoot, worktree.path);
|
|
@@ -45,12 +60,15 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
45
60
|
await forceRemoveWorktree(repository.repoRoot, worktree.path);
|
|
46
61
|
}
|
|
47
62
|
catch (forceError) {
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
if (!options.json) {
|
|
64
|
+
reportRemovedPaths(removedPaths, options.stderr);
|
|
65
|
+
}
|
|
66
|
+
emitError(options, `Failed to remove worktree at ${worktree.path}: ${toMessage(forceError)}`);
|
|
50
67
|
return 1;
|
|
51
68
|
}
|
|
52
69
|
}
|
|
53
70
|
removedPaths.push(worktree.path);
|
|
71
|
+
removedWorktrees.push(worktree);
|
|
54
72
|
if (worktree.branch) {
|
|
55
73
|
try {
|
|
56
74
|
await deleteBranch(repository.repoRoot, worktree.branch);
|
|
@@ -73,7 +91,13 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
73
91
|
}
|
|
74
92
|
}
|
|
75
93
|
}
|
|
76
|
-
options.
|
|
94
|
+
if (options.json) {
|
|
95
|
+
const removed = removedWorktrees.map((w) => ({ branch: w.branch, path: w.path }));
|
|
96
|
+
options.stdout(`${JSON.stringify({ removed }, null, 2)}\n`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
options.stdout(`${repository.repoRoot}\n`);
|
|
100
|
+
}
|
|
77
101
|
return 0;
|
|
78
102
|
};
|
|
79
103
|
}
|
|
@@ -96,6 +120,14 @@ function reportRemovedPaths(paths, stderr) {
|
|
|
96
120
|
stderr(`Already removed: ${paths.join(', ')}\n`);
|
|
97
121
|
}
|
|
98
122
|
}
|
|
123
|
+
function emitError(options, message) {
|
|
124
|
+
if (options.json) {
|
|
125
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
options.stderr(`${message}\n`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
99
131
|
function toMessage(error) {
|
|
100
132
|
return error instanceof Error ? error.message : String(error);
|
|
101
133
|
}
|
package/dist/cli.js
CHANGED
|
@@ -59,6 +59,7 @@ function registerCommands(program) {
|
|
|
59
59
|
.command('new [branch]')
|
|
60
60
|
.description('create a new branch or detached linked worktree')
|
|
61
61
|
.option('--detached', 'create a detached worktree without a branch')
|
|
62
|
+
.option('--json', 'emit JSON on success or error instead of human-readable output')
|
|
62
63
|
.action(notImplemented('new'));
|
|
63
64
|
program
|
|
64
65
|
.command('init [shell]')
|
|
@@ -68,6 +69,7 @@ function registerCommands(program) {
|
|
|
68
69
|
program
|
|
69
70
|
.command('pr <number>')
|
|
70
71
|
.description('fetch a pull request ref and create a linked worktree')
|
|
72
|
+
.option('--json', 'emit JSON on success or error instead of human-readable output')
|
|
71
73
|
.action(notImplemented('pr'));
|
|
72
74
|
program
|
|
73
75
|
.command('go [branch]')
|
|
@@ -98,12 +100,14 @@ function registerCommands(program) {
|
|
|
98
100
|
.command('clean')
|
|
99
101
|
.description('interactively prune linked worktrees')
|
|
100
102
|
.option('-f, --force', 'bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches')
|
|
103
|
+
.option('--json', 'emit JSON on success or error instead of human-readable output')
|
|
101
104
|
.action(notImplemented('clean'));
|
|
102
105
|
program
|
|
103
106
|
.command('remove [branch]')
|
|
104
107
|
.alias('rm')
|
|
105
108
|
.description('remove a linked worktree and delete its branch when present')
|
|
106
109
|
.option('-f, --force', 'bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch')
|
|
110
|
+
.option('--json', 'emit JSON on success or error instead of human-readable output')
|
|
107
111
|
.action(notImplemented('remove'));
|
|
108
112
|
const configCommand = program
|
|
109
113
|
.command('config')
|
|
@@ -126,7 +130,7 @@ function attachCommandActions(program, options) {
|
|
|
126
130
|
program.commands
|
|
127
131
|
.find((command) => command.name() === 'new')
|
|
128
132
|
?.action(async (branch, commandOptions) => {
|
|
129
|
-
const exitCode = await runNewCommand({ ...options, branch, detached: commandOptions.detached });
|
|
133
|
+
const exitCode = await runNewCommand({ ...options, branch, detached: commandOptions.detached, json: commandOptions.json });
|
|
130
134
|
if (exitCode !== 0) {
|
|
131
135
|
throw commanderExit(exitCode);
|
|
132
136
|
}
|
|
@@ -146,8 +150,8 @@ function attachCommandActions(program, options) {
|
|
|
146
150
|
});
|
|
147
151
|
program.commands
|
|
148
152
|
.find((command) => command.name() === 'pr')
|
|
149
|
-
?.action(async (number) => {
|
|
150
|
-
const exitCode = await runPrCommand({ cwd: options.cwd, number, stderr: options.stderr, stdout: options.stdout });
|
|
153
|
+
?.action(async (number, commandOptions) => {
|
|
154
|
+
const exitCode = await runPrCommand({ cwd: options.cwd, json: commandOptions.json, number, stderr: options.stderr, stdout: options.stdout });
|
|
151
155
|
if (exitCode !== 0) {
|
|
152
156
|
throw commanderExit(exitCode);
|
|
153
157
|
}
|
|
@@ -221,6 +225,7 @@ function attachCommandActions(program, options) {
|
|
|
221
225
|
const exitCode = await runCleanCommand({
|
|
222
226
|
cwd: options.cwd,
|
|
223
227
|
force: commandOptions.force,
|
|
228
|
+
json: commandOptions.json,
|
|
224
229
|
stderr: options.stderr,
|
|
225
230
|
stdout: options.stdout,
|
|
226
231
|
});
|
|
@@ -233,6 +238,7 @@ function attachCommandActions(program, options) {
|
|
|
233
238
|
branch,
|
|
234
239
|
cwd: options.cwd,
|
|
235
240
|
force: commandOptions.force,
|
|
241
|
+
json: commandOptions.json,
|
|
236
242
|
stderr: options.stderr,
|
|
237
243
|
stdout: options.stdout,
|
|
238
244
|
});
|
package/dist/config.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare const DEFAULT_CONFIG: GjiConfig;
|
|
|
11
11
|
export declare function loadConfig(root: string): Promise<LoadedConfig>;
|
|
12
12
|
export declare function loadEffectiveConfig(root: string, home?: string): Promise<GjiConfig>;
|
|
13
13
|
export declare function loadGlobalConfig(home?: string): Promise<LoadedConfig>;
|
|
14
|
+
export declare function saveLocalConfig(root: string, config: GjiConfig): Promise<string>;
|
|
15
|
+
export declare function updateLocalConfigKey(root: string, key: string, value: unknown): Promise<GjiConfig>;
|
|
14
16
|
export declare function saveGlobalConfig(config: GjiConfig, home?: string): Promise<string>;
|
|
15
17
|
export declare function unsetGlobalConfigKey(key: string, home?: string): Promise<GjiConfig>;
|
|
16
18
|
export declare function updateGlobalConfigKey(key: string, value: unknown, home?: string): Promise<GjiConfig>;
|
package/dist/config.js
CHANGED
|
@@ -25,6 +25,20 @@ export async function loadEffectiveConfig(root, home = homedir()) {
|
|
|
25
25
|
export async function loadGlobalConfig(home = homedir()) {
|
|
26
26
|
return loadConfigFile(GLOBAL_CONFIG_FILE_PATH(home));
|
|
27
27
|
}
|
|
28
|
+
export async function saveLocalConfig(root, config) {
|
|
29
|
+
const path = join(root, CONFIG_FILE_NAME);
|
|
30
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
export async function updateLocalConfigKey(root, key, value) {
|
|
34
|
+
const loaded = await loadConfig(root);
|
|
35
|
+
const nextConfig = {
|
|
36
|
+
...loaded.config,
|
|
37
|
+
[key]: value,
|
|
38
|
+
};
|
|
39
|
+
await saveLocalConfig(root, nextConfig);
|
|
40
|
+
return nextConfig;
|
|
41
|
+
}
|
|
28
42
|
export async function saveGlobalConfig(config, home = homedir()) {
|
|
29
43
|
const path = GLOBAL_CONFIG_FILE_PATH(home);
|
|
30
44
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies files matching each pattern (relative to mainRoot) into the equivalent
|
|
3
|
+
* relative path under targetPath, creating parent directories as needed.
|
|
4
|
+
*
|
|
5
|
+
* - Non-destructive: silently skips if the target file already exists.
|
|
6
|
+
* - Silently skips if the source file does not exist.
|
|
7
|
+
* - Rejects patterns that are absolute paths or contain `..` segments.
|
|
8
|
+
*/
|
|
9
|
+
export declare function syncFiles(mainRoot: string, targetPath: string, patterns: string[]): Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { copyFile, mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, normalize } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Copies files matching each pattern (relative to mainRoot) into the equivalent
|
|
5
|
+
* relative path under targetPath, creating parent directories as needed.
|
|
6
|
+
*
|
|
7
|
+
* - Non-destructive: silently skips if the target file already exists.
|
|
8
|
+
* - Silently skips if the source file does not exist.
|
|
9
|
+
* - Rejects patterns that are absolute paths or contain `..` segments.
|
|
10
|
+
*/
|
|
11
|
+
export async function syncFiles(mainRoot, targetPath, patterns) {
|
|
12
|
+
for (const pattern of patterns) {
|
|
13
|
+
if (isAbsolute(pattern)) {
|
|
14
|
+
throw new Error(`syncFiles: pattern must be a relative path, got: ${pattern}`);
|
|
15
|
+
}
|
|
16
|
+
const normalized = normalize(pattern);
|
|
17
|
+
if (normalized.startsWith('..')) {
|
|
18
|
+
throw new Error(`syncFiles: pattern must not contain '..' segments, got: ${pattern}`);
|
|
19
|
+
}
|
|
20
|
+
const sourcePath = join(mainRoot, normalized);
|
|
21
|
+
const destPath = join(targetPath, normalized);
|
|
22
|
+
// Skip silently if source does not exist
|
|
23
|
+
const sourceExists = await fileExists(sourcePath);
|
|
24
|
+
if (!sourceExists) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// Skip silently if target already exists
|
|
28
|
+
const destExists = await fileExists(destPath);
|
|
29
|
+
if (destExists) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
33
|
+
await copyFile(sourcePath, destPath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function fileExists(path) {
|
|
37
|
+
try {
|
|
38
|
+
await stat(path);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (isNotFoundError(error)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function isNotFoundError(error) {
|
|
49
|
+
return (error instanceof Error &&
|
|
50
|
+
'code' in error &&
|
|
51
|
+
error.code === 'ENOENT');
|
|
52
|
+
}
|
package/dist/go.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { isCancel, select } from '@clack/prompts';
|
|
3
3
|
import { loadEffectiveConfig } from './config.js';
|
|
4
|
+
import { isHeadless } from './headless.js';
|
|
4
5
|
import { extractHooks, runHook } from './hooks.js';
|
|
5
6
|
import { detectRepository, listWorktrees } from './repo.js';
|
|
6
7
|
import { writeShellOutput } from './shell-handoff.js';
|
|
@@ -12,6 +13,10 @@ export function createGoCommand(dependencies = {}) {
|
|
|
12
13
|
listWorktrees(options.cwd),
|
|
13
14
|
detectRepository(options.cwd),
|
|
14
15
|
]);
|
|
16
|
+
if (!options.branch && isHeadless()) {
|
|
17
|
+
options.stderr('gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n');
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
15
20
|
const prompted = options.branch ? null : await prompt(worktrees);
|
|
16
21
|
const resolvedPath = options.branch
|
|
17
22
|
? worktrees.find((entry) => entry.branch === options.branch)?.path
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when running in a non-interactive (headless) environment.
|
|
3
|
+
* Set GJI_NO_TUI=1 to disable all interactive prompts.
|
|
4
|
+
* Commands that would otherwise hang waiting for input must fail fast instead.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isHeadless(): boolean;
|
package/dist/headless.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when running in a non-interactive (headless) environment.
|
|
3
|
+
* Set GJI_NO_TUI=1 to disable all interactive prompts.
|
|
4
|
+
* Commands that would otherwise hang waiting for input must fail fast instead.
|
|
5
|
+
*/
|
|
6
|
+
export function isHeadless() {
|
|
7
|
+
return process.env.GJI_NO_TUI === '1';
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type GjiConfig } from './config.js';
|
|
2
|
+
import { type PackageManager } from './package-manager.js';
|
|
3
|
+
export type InstallChoice = 'yes' | 'no' | 'always' | 'never';
|
|
4
|
+
export interface InstallPromptDependencies {
|
|
5
|
+
detectInstallPackageManager?: (root: string) => Promise<PackageManager | null>;
|
|
6
|
+
promptForInstallChoice?: (pm: PackageManager) => Promise<InstallChoice | null>;
|
|
7
|
+
runInstallCommand?: (command: string, cwd: string, stderr: (chunk: string) => void) => Promise<void>;
|
|
8
|
+
writeConfigKey?: (root: string, key: string, value: unknown) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function maybeRunInstallPrompt(worktreePath: string, repoRoot: string, config: GjiConfig, stderr: (chunk: string) => void, dependencies?: InstallPromptDependencies, nonInteractive?: boolean): Promise<void>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { isCancel, select } from '@clack/prompts';
|
|
3
|
+
import { loadConfig, updateLocalConfigKey } from './config.js';
|
|
4
|
+
import { isHeadless } from './headless.js';
|
|
5
|
+
import { detectPackageManager } from './package-manager.js';
|
|
6
|
+
export async function maybeRunInstallPrompt(worktreePath, repoRoot, config, stderr, dependencies = {}, nonInteractive = false) {
|
|
7
|
+
// Skip in non-interactive mode — no prompt can be shown.
|
|
8
|
+
if (isHeadless() || nonInteractive) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
// Skip if afterCreate hook is already configured in effective config.
|
|
12
|
+
const hooks = isPlainObject(config.hooks) ? config.hooks : null;
|
|
13
|
+
if (typeof hooks?.afterCreate === 'string' && hooks.afterCreate.length > 0) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Skip if user has permanently opted out of install prompts.
|
|
17
|
+
if (config.skipInstallPrompt === true) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const detect = dependencies.detectInstallPackageManager ?? detectPackageManager;
|
|
21
|
+
const pm = await detect(worktreePath);
|
|
22
|
+
if (!pm) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const prompt = dependencies.promptForInstallChoice ?? defaultPromptForInstallChoice;
|
|
26
|
+
const choice = await prompt(pm);
|
|
27
|
+
if (!choice || choice === 'no') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (choice === 'yes' || choice === 'always') {
|
|
31
|
+
const runner = dependencies.runInstallCommand ?? defaultRunInstallCommand;
|
|
32
|
+
try {
|
|
33
|
+
await runner(pm.installCommand, worktreePath, stderr);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
stderr(`gji: install command failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const writeKey = dependencies.writeConfigKey ?? defaultWriteConfigKey;
|
|
40
|
+
if (choice === 'always') {
|
|
41
|
+
try {
|
|
42
|
+
// Read local config hooks to deep-merge so other hook keys (e.g. afterEnter) are preserved.
|
|
43
|
+
const { config: localConfig } = await loadConfig(repoRoot);
|
|
44
|
+
const existingLocalHooks = isPlainObject(localConfig.hooks) ? localConfig.hooks : {};
|
|
45
|
+
await writeKey(repoRoot, 'hooks', { ...existingLocalHooks, afterCreate: pm.installCommand });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
stderr(`gji: failed to save config: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (choice === 'never') {
|
|
52
|
+
try {
|
|
53
|
+
await writeKey(repoRoot, 'skipInstallPrompt', true);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
stderr(`gji: failed to save config: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function defaultRunInstallCommand(command, cwd, stderr) {
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
const child = spawn(command, { cwd, shell: true, stdio: ['ignore', 'inherit', 'pipe'] });
|
|
63
|
+
child.stderr.on('data', (chunk) => {
|
|
64
|
+
stderr(chunk.toString());
|
|
65
|
+
});
|
|
66
|
+
child.on('close', (code) => {
|
|
67
|
+
if (code !== 0) {
|
|
68
|
+
reject(new Error(`exited with code ${code}`));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
resolve();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
child.on('error', (err) => {
|
|
75
|
+
reject(err);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function defaultWriteConfigKey(root, key, value) {
|
|
80
|
+
await updateLocalConfigKey(root, key, value);
|
|
81
|
+
}
|
|
82
|
+
async function defaultPromptForInstallChoice(pm) {
|
|
83
|
+
const choice = await select({
|
|
84
|
+
message: `Run \`${pm.installCommand}\` in the new worktree?`,
|
|
85
|
+
options: [
|
|
86
|
+
{ value: 'yes', label: 'Yes', hint: 'run once' },
|
|
87
|
+
{ value: 'no', label: 'No', hint: 'skip this time' },
|
|
88
|
+
{ value: 'always', label: 'Always', hint: 'save as afterCreate hook' },
|
|
89
|
+
{ value: 'never', label: 'Never', hint: 'disable this prompt for this repo' },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
if (isCancel(choice)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return choice;
|
|
96
|
+
}
|
|
97
|
+
function isPlainObject(value) {
|
|
98
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
package/dist/new.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { type InstallPromptDependencies } from './install-prompt.js';
|
|
1
2
|
import { type PathConflictChoice } from './conflict.js';
|
|
2
3
|
export type { PathConflictChoice };
|
|
3
4
|
export interface NewCommandOptions {
|
|
4
5
|
branch?: string;
|
|
5
6
|
cwd: string;
|
|
6
7
|
detached?: boolean;
|
|
8
|
+
json?: boolean;
|
|
7
9
|
stderr: (chunk: string) => void;
|
|
8
10
|
stdout: (chunk: string) => void;
|
|
9
11
|
}
|
|
10
|
-
export interface NewCommandDependencies {
|
|
12
|
+
export interface NewCommandDependencies extends InstallPromptDependencies {
|
|
11
13
|
createBranchPlaceholder: () => string;
|
|
12
14
|
promptForBranch: (placeholder: string) => Promise<string | null>;
|
|
13
15
|
promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
|
package/dist/new.js
CHANGED
|
@@ -4,7 +4,10 @@ import { execFile } from 'node:child_process';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { isCancel, text } from '@clack/prompts';
|
|
6
6
|
import { loadEffectiveConfig } from './config.js';
|
|
7
|
+
import { syncFiles } from './file-sync.js';
|
|
7
8
|
import { extractHooks, runHook } from './hooks.js';
|
|
9
|
+
import { isHeadless } from './headless.js';
|
|
10
|
+
import { maybeRunInstallPrompt } from './install-prompt.js';
|
|
8
11
|
import { pathExists, promptForPathConflict } from './conflict.js';
|
|
9
12
|
import { detectRepository, resolveWorktreePath } from './repo.js';
|
|
10
13
|
import { writeShellOutput } from './shell-handoff.js';
|
|
@@ -18,11 +21,26 @@ export function createNewCommand(dependencies = {}) {
|
|
|
18
21
|
const repository = await detectRepository(options.cwd);
|
|
19
22
|
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
20
23
|
const usesGeneratedDetachedName = options.detached && options.branch === undefined;
|
|
24
|
+
if (!options.detached && !options.branch && (options.json || isHeadless())) {
|
|
25
|
+
const message = 'branch argument is required';
|
|
26
|
+
if (options.json) {
|
|
27
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
31
|
+
}
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
21
34
|
const rawBranch = options.detached
|
|
22
35
|
? options.branch ?? createBranchPlaceholder()
|
|
23
36
|
: options.branch ?? await promptForBranch(createBranchPlaceholder());
|
|
24
37
|
if (!rawBranch) {
|
|
25
|
-
options.
|
|
38
|
+
if (options.json) {
|
|
39
|
+
options.stderr(`${JSON.stringify({ error: 'Aborted' }, null, 2)}\n`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
options.stderr('Aborted\n');
|
|
43
|
+
}
|
|
26
44
|
return 1;
|
|
27
45
|
}
|
|
28
46
|
const worktreeName = options.detached
|
|
@@ -32,6 +50,16 @@ export function createNewCommand(dependencies = {}) {
|
|
|
32
50
|
? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName)
|
|
33
51
|
: resolveWorktreePath(repository.repoRoot, worktreeName);
|
|
34
52
|
if (!usesGeneratedDetachedName && await pathExists(worktreePath)) {
|
|
53
|
+
if (options.json || isHeadless()) {
|
|
54
|
+
const message = `target worktree path already exists: ${worktreePath}`;
|
|
55
|
+
if (options.json) {
|
|
56
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
60
|
+
}
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
35
63
|
const choice = await prompt(worktreePath);
|
|
36
64
|
if (choice === 'reuse') {
|
|
37
65
|
await writeOutput(worktreePath, options.stdout);
|
|
@@ -47,9 +75,27 @@ export function createNewCommand(dependencies = {}) {
|
|
|
47
75
|
? ['worktree', 'add', worktreePath, worktreeName]
|
|
48
76
|
: ['worktree', 'add', '-b', worktreeName, worktreePath];
|
|
49
77
|
await execFileAsync('git', gitArgs, { cwd: repository.repoRoot });
|
|
78
|
+
// Sync files from main worktree before afterCreate so synced files are available to install scripts.
|
|
79
|
+
const syncPatterns = Array.isArray(config.syncFiles)
|
|
80
|
+
? config.syncFiles.filter((p) => typeof p === 'string')
|
|
81
|
+
: [];
|
|
82
|
+
for (const pattern of syncPatterns) {
|
|
83
|
+
try {
|
|
84
|
+
await syncFiles(repository.repoRoot, worktreePath, [pattern]);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
|
|
50
91
|
const hooks = extractHooks(config);
|
|
51
92
|
await runHook(hooks.afterCreate, worktreePath, { branch: worktreeName, path: worktreePath, repo: basename(repository.repoRoot) }, options.stderr);
|
|
52
|
-
|
|
93
|
+
if (options.json) {
|
|
94
|
+
options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath }, null, 2)}\n`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
await writeOutput(worktreePath, options.stdout);
|
|
98
|
+
}
|
|
53
99
|
return 0;
|
|
54
100
|
};
|
|
55
101
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { access, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const ENTRIES = [
|
|
4
|
+
// JavaScript / TypeScript
|
|
5
|
+
{ name: 'pnpm', signals: ['pnpm-lock.yaml'], command: 'pnpm install' },
|
|
6
|
+
{ name: 'yarn', signals: ['yarn.lock'], command: 'yarn install' },
|
|
7
|
+
{ name: 'bun', signals: ['bun.lockb'], command: 'bun install' },
|
|
8
|
+
{ name: 'npm', signals: ['package-lock.json'], command: 'npm install' },
|
|
9
|
+
{ name: 'deno', signals: ['deno.json', 'deno.jsonc'], command: 'deno cache' },
|
|
10
|
+
// Python
|
|
11
|
+
{ name: 'poetry', signals: ['poetry.lock'], command: 'poetry install' },
|
|
12
|
+
{ name: 'uv', signals: ['uv.lock'], command: 'uv sync' },
|
|
13
|
+
{ name: 'pipenv', signals: ['Pipfile.lock'], command: 'pipenv install' },
|
|
14
|
+
{ name: 'pdm', signals: ['pdm.lock'], command: 'pdm install' },
|
|
15
|
+
{ name: 'conda-lock', signals: ['conda-lock.yml'], command: 'conda-lock install' },
|
|
16
|
+
{ name: 'conda', signals: ['environment.yml'], command: 'conda env update --file environment.yml' },
|
|
17
|
+
// R
|
|
18
|
+
{ name: 'renv', signals: ['renv.lock'], command: "Rscript -e 'renv::restore()'" },
|
|
19
|
+
// Rust
|
|
20
|
+
{ name: 'cargo', signals: ['Cargo.lock'], command: 'cargo build' },
|
|
21
|
+
// Go
|
|
22
|
+
{ name: 'go', signals: ['go.sum'], command: 'go mod download' },
|
|
23
|
+
// Ruby
|
|
24
|
+
{ name: 'bundler', signals: ['Gemfile.lock'], command: 'bundle install' },
|
|
25
|
+
// PHP
|
|
26
|
+
{ name: 'composer', signals: ['composer.lock'], command: 'composer install' },
|
|
27
|
+
// Elixir / Erlang
|
|
28
|
+
{ name: 'mix', signals: ['mix.lock'], command: 'mix deps.get' },
|
|
29
|
+
{ name: 'rebar3', signals: ['rebar.lock'], command: 'rebar3 deps' },
|
|
30
|
+
// Dart / Flutter
|
|
31
|
+
{ name: 'dart', signals: ['pubspec.lock'], command: 'dart pub get' },
|
|
32
|
+
// Java / Kotlin / Scala
|
|
33
|
+
{ name: 'maven', signals: ['pom.xml'], command: 'mvn install' },
|
|
34
|
+
{ name: 'gradle', signals: ['gradlew'], command: './gradlew build' },
|
|
35
|
+
{ name: 'gradle', signals: ['build.gradle', 'build.gradle.kts'], command: 'gradle build' },
|
|
36
|
+
{ name: 'sbt', signals: ['build.sbt'], command: 'sbt compile' },
|
|
37
|
+
// .NET (C# / F# / VB)
|
|
38
|
+
{ name: 'dotnet', signals: ['*.sln', '*.csproj', '*.fsproj', '*.vbproj'], command: 'dotnet restore', glob: true },
|
|
39
|
+
// Swift
|
|
40
|
+
{ name: 'swift', signals: ['Package.swift'], command: 'swift package resolve' },
|
|
41
|
+
// Haskell
|
|
42
|
+
{ name: 'stack', signals: ['stack.yaml'], command: 'stack build' },
|
|
43
|
+
{ name: 'cabal', signals: ['cabal.project'], command: 'cabal install --only-dependencies' },
|
|
44
|
+
{ name: 'cabal', signals: ['*.cabal'], command: 'cabal install --only-dependencies', glob: true },
|
|
45
|
+
// Clojure
|
|
46
|
+
{ name: 'clojure', signals: ['deps.edn'], command: 'clojure -P' },
|
|
47
|
+
{ name: 'leiningen', signals: ['project.clj'], command: 'lein deps' },
|
|
48
|
+
// OCaml
|
|
49
|
+
{ name: 'dune', signals: ['dune-project'], command: 'dune build' },
|
|
50
|
+
// Julia
|
|
51
|
+
{ name: 'julia', signals: ['Manifest.toml'], command: "julia --project -e 'using Pkg; Pkg.instantiate()'" },
|
|
52
|
+
// Nim
|
|
53
|
+
{ name: 'nimble', signals: ['*.nimble'], command: 'nimble install', glob: true },
|
|
54
|
+
// Crystal
|
|
55
|
+
{ name: 'shards', signals: ['shard.yml'], command: 'shards install' },
|
|
56
|
+
// Perl
|
|
57
|
+
{ name: 'cpanm', signals: ['cpanfile'], command: 'cpanm --installdeps .' },
|
|
58
|
+
// Zig
|
|
59
|
+
{ name: 'zig', signals: ['build.zig.zon'], command: 'zig build' },
|
|
60
|
+
// C / C++
|
|
61
|
+
{ name: 'vcpkg', signals: ['vcpkg.json'], command: 'vcpkg install' },
|
|
62
|
+
{ name: 'conan', signals: ['conanfile.py', 'conanfile.txt'], command: 'conan install .' },
|
|
63
|
+
// Nix
|
|
64
|
+
{ name: 'nix', signals: ['flake.nix'], command: 'nix develop' },
|
|
65
|
+
{ name: 'nix-shell', signals: ['shell.nix'], command: 'nix-shell' },
|
|
66
|
+
// Terraform / OpenTofu
|
|
67
|
+
{ name: 'terraform', signals: ['terraform.lock.hcl'], command: 'terraform init' },
|
|
68
|
+
];
|
|
69
|
+
export async function detectPackageManager(repoRoot) {
|
|
70
|
+
for (const entry of ENTRIES) {
|
|
71
|
+
const matched = entry.glob
|
|
72
|
+
? await matchesGlob(repoRoot, entry.signals)
|
|
73
|
+
: await matchesExact(repoRoot, entry.signals);
|
|
74
|
+
if (matched) {
|
|
75
|
+
return { name: entry.name, installCommand: entry.command };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
async function matchesExact(repoRoot, signals) {
|
|
81
|
+
for (const signal of signals) {
|
|
82
|
+
try {
|
|
83
|
+
await access(join(repoRoot, signal));
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// file not found, try next signal
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
async function matchesGlob(repoRoot, patterns) {
|
|
93
|
+
let files;
|
|
94
|
+
try {
|
|
95
|
+
files = await readdir(repoRoot);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const regexes = patterns.map(patternToRegex);
|
|
101
|
+
return files.some((file) => regexes.some((re) => re.test(file)));
|
|
102
|
+
}
|
|
103
|
+
function patternToRegex(pattern) {
|
|
104
|
+
const escaped = pattern
|
|
105
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
106
|
+
.replace(/\*/g, '[^/]*');
|
|
107
|
+
return new RegExp(`^${escaped}$`);
|
|
108
|
+
}
|
package/dist/pr.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { type PathConflictChoice } from './conflict.js';
|
|
2
|
+
import { type InstallPromptDependencies } from './install-prompt.js';
|
|
2
3
|
export type { PathConflictChoice };
|
|
3
4
|
export interface PrCommandOptions {
|
|
4
5
|
cwd: string;
|
|
6
|
+
json?: boolean;
|
|
5
7
|
number: string;
|
|
6
8
|
stderr: (chunk: string) => void;
|
|
7
9
|
stdout: (chunk: string) => void;
|
|
8
10
|
}
|
|
9
|
-
export interface PrCommandDependencies {
|
|
11
|
+
export interface PrCommandDependencies extends InstallPromptDependencies {
|
|
10
12
|
promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
|
|
11
13
|
}
|
|
12
14
|
export declare function parsePrInput(input: string): string | null;
|
package/dist/pr.js
CHANGED
|
@@ -3,8 +3,11 @@ import { basename, dirname } from 'node:path';
|
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { loadEffectiveConfig } from './config.js';
|
|
6
|
+
import { syncFiles } from './file-sync.js';
|
|
6
7
|
import { pathExists, promptForPathConflict } from './conflict.js';
|
|
7
8
|
import { extractHooks, runHook } from './hooks.js';
|
|
9
|
+
import { isHeadless } from './headless.js';
|
|
10
|
+
import { maybeRunInstallPrompt } from './install-prompt.js';
|
|
8
11
|
import { detectRepository, resolveWorktreePath } from './repo.js';
|
|
9
12
|
import { writeShellOutput } from './shell-handoff.js';
|
|
10
13
|
const execFileAsync = promisify(execFile);
|
|
@@ -25,14 +28,31 @@ export function createPrCommand(dependencies = {}) {
|
|
|
25
28
|
return async function runPrCommand(options) {
|
|
26
29
|
const prNumber = parsePrInput(options.number);
|
|
27
30
|
if (!prNumber) {
|
|
28
|
-
|
|
31
|
+
const message = `Invalid PR reference: ${options.number}`;
|
|
32
|
+
if (options.json) {
|
|
33
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
options.stderr(`${message}\n`);
|
|
37
|
+
}
|
|
29
38
|
return 1;
|
|
30
39
|
}
|
|
31
40
|
const repository = await detectRepository(options.cwd);
|
|
41
|
+
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
32
42
|
const branchName = `pr/${prNumber}`;
|
|
33
43
|
const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`;
|
|
34
44
|
const worktreePath = resolveWorktreePath(repository.repoRoot, branchName);
|
|
35
45
|
if (await pathExists(worktreePath)) {
|
|
46
|
+
if (options.json || isHeadless()) {
|
|
47
|
+
const message = `target worktree path already exists: ${worktreePath}`;
|
|
48
|
+
if (options.json) {
|
|
49
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
options.stderr(`gji pr: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
53
|
+
}
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
36
56
|
const choice = await prompt(worktreePath);
|
|
37
57
|
if (choice === 'reuse') {
|
|
38
58
|
await writeOutput(worktreePath, options.stdout);
|
|
@@ -45,7 +65,13 @@ export function createPrCommand(dependencies = {}) {
|
|
|
45
65
|
await execFileAsync('git', ['fetch', 'origin', `refs/pull/${prNumber}/head:${remoteRef}`], { cwd: repository.repoRoot });
|
|
46
66
|
}
|
|
47
67
|
catch {
|
|
48
|
-
|
|
68
|
+
const message = `Failed to fetch PR #${prNumber} from origin`;
|
|
69
|
+
if (options.json) {
|
|
70
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
options.stderr(`${message}\n`);
|
|
74
|
+
}
|
|
49
75
|
return 1;
|
|
50
76
|
}
|
|
51
77
|
await mkdir(dirname(worktreePath), { recursive: true });
|
|
@@ -54,10 +80,27 @@ export function createPrCommand(dependencies = {}) {
|
|
|
54
80
|
? ['worktree', 'add', worktreePath, branchName]
|
|
55
81
|
: ['worktree', 'add', '-b', branchName, worktreePath, remoteRef];
|
|
56
82
|
await execFileAsync('git', worktreeArgs, { cwd: repository.repoRoot });
|
|
57
|
-
|
|
83
|
+
// Sync files from main worktree before afterCreate so synced files are available to install scripts.
|
|
84
|
+
const syncPatterns = Array.isArray(config.syncFiles)
|
|
85
|
+
? config.syncFiles.filter((p) => typeof p === 'string')
|
|
86
|
+
: [];
|
|
87
|
+
for (const pattern of syncPatterns) {
|
|
88
|
+
try {
|
|
89
|
+
await syncFiles(repository.repoRoot, worktreePath, [pattern]);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
|
|
58
96
|
const hooks = extractHooks(config);
|
|
59
97
|
await runHook(hooks.afterCreate, worktreePath, { branch: branchName, path: worktreePath, repo: basename(repository.repoRoot) }, options.stderr);
|
|
60
|
-
|
|
98
|
+
if (options.json) {
|
|
99
|
+
options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath }, null, 2)}\n`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
await writeOutput(worktreePath, options.stdout);
|
|
103
|
+
}
|
|
61
104
|
return 0;
|
|
62
105
|
};
|
|
63
106
|
}
|
package/dist/remove.d.ts
CHANGED
package/dist/remove.js
CHANGED
|
@@ -2,6 +2,7 @@ import { basename } from 'node:path';
|
|
|
2
2
|
import { confirm, isCancel, select } from '@clack/prompts';
|
|
3
3
|
import { loadEffectiveConfig } from './config.js';
|
|
4
4
|
import { extractHooks, runHook } from './hooks.js';
|
|
5
|
+
import { isHeadless } from './headless.js';
|
|
5
6
|
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
|
|
6
7
|
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree } from './worktree-prompts.js';
|
|
7
8
|
import { writeShellOutput } from './shell-handoff.js';
|
|
@@ -14,7 +15,17 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
14
15
|
return async function runRemoveCommand(options) {
|
|
15
16
|
const { linkedWorktrees, repository } = await loadLinkedWorktrees(options.cwd);
|
|
16
17
|
if (linkedWorktrees.length === 0) {
|
|
17
|
-
options
|
|
18
|
+
emitError(options, 'No linked worktrees to finish');
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
if (!options.branch && (options.json || isHeadless())) {
|
|
22
|
+
const message = 'branch argument is required';
|
|
23
|
+
if (options.json) {
|
|
24
|
+
emitError(options, message);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
options.stderr(`gji remove: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
28
|
+
}
|
|
18
29
|
return 1;
|
|
19
30
|
}
|
|
20
31
|
const selection = options.branch ?? (await promptForWorktree(linkedWorktrees));
|
|
@@ -24,7 +35,17 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
24
35
|
}
|
|
25
36
|
const worktree = linkedWorktrees.find((entry) => entry.branch === selection || entry.path === selection);
|
|
26
37
|
if (!worktree) {
|
|
27
|
-
options
|
|
38
|
+
emitError(options, `No linked worktree found for branch: ${selection}`);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
if (!options.force && (options.json || isHeadless())) {
|
|
42
|
+
const message = '--force is required';
|
|
43
|
+
if (options.json) {
|
|
44
|
+
emitError(options, message);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
options.stderr(`gji remove: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
48
|
+
}
|
|
28
49
|
return 1;
|
|
29
50
|
}
|
|
30
51
|
if (!options.force && !(await confirmRemoval(worktree))) {
|
|
@@ -49,7 +70,7 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
49
70
|
await forceRemoveWorktree(repository.repoRoot, worktree.path);
|
|
50
71
|
}
|
|
51
72
|
catch (forceError) {
|
|
52
|
-
options
|
|
73
|
+
emitError(options, `Failed to remove worktree at ${worktree.path}: ${toMessage(forceError)}`);
|
|
53
74
|
return 1;
|
|
54
75
|
}
|
|
55
76
|
}
|
|
@@ -74,7 +95,12 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
74
95
|
}
|
|
75
96
|
}
|
|
76
97
|
}
|
|
77
|
-
|
|
98
|
+
if (options.json) {
|
|
99
|
+
options.stdout(`${JSON.stringify({ branch: worktree.branch, path: worktree.path, deleted: true }, null, 2)}\n`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
await writeOutput(repository.repoRoot, options.stdout);
|
|
103
|
+
}
|
|
78
104
|
return 0;
|
|
79
105
|
};
|
|
80
106
|
}
|
|
@@ -104,6 +130,14 @@ async function defaultConfirmRemoval(worktree) {
|
|
|
104
130
|
async function writeOutput(repoRoot, stdout) {
|
|
105
131
|
await writeShellOutput(REMOVE_OUTPUT_FILE_ENV, repoRoot, stdout);
|
|
106
132
|
}
|
|
133
|
+
function emitError(options, message) {
|
|
134
|
+
if (options.json) {
|
|
135
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
options.stderr(`${message}\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
107
141
|
function toMessage(error) {
|
|
108
142
|
return error instanceof Error ? error.message : String(error);
|
|
109
143
|
}
|