@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 CHANGED
@@ -1,277 +1,276 @@
1
- # gji
1
+ # gji — Git worktrees without the hassle
2
2
 
3
- Context switching without the mess.
3
+ > Jump between tasks instantly. No stash. No reinstall. No mess.
4
4
 
5
- `gji` is a Git worktree CLI for people who jump between tasks all day. It gives each branch or PR its own directory, so you stop doing `stash`, `pop`, reinstall cycles, and fragile branch juggling.
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
- ## Why
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
- Standard branch switching gets annoying when you are:
14
+ ---
10
15
 
11
- - fixing one bug while reviewing another branch
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
- `gji` keeps those contexts isolated in separate worktrees with deterministic paths.
18
+ ---
16
19
 
17
- ## Install
20
+ ## The problem
18
21
 
19
- Current source install:
22
+ You are deep in a feature branch. A colleague asks for a quick review. You:
20
23
 
21
- ```sh
22
- git clone https://github.com/sjquant/gji.git
23
- cd gji
24
- pnpm install
25
- pnpm build
26
- npm install -g .
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
- Confirm the CLI is available:
32
+ **Or you use `gji` and it is just `gji pr 1234`.**
33
+
34
+ ## Install
30
35
 
31
36
  ```sh
32
- gji --version
33
- gji --help
37
+ npm install -g @solaqua/gji
34
38
  ```
35
39
 
36
- ## Quick start
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
- gji new feature/login-form
42
- gji status
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
- ```text
48
- ../worktrees/<repo>/<branch>
46
+ # bash
47
+ echo 'eval "$(gji init bash)"' >> ~/.bashrc && source ~/.bashrc
49
48
  ```
50
49
 
51
- ## Shell setup
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
- echo 'eval "$(gji init zsh)"' >> ~/.zshrc
59
- source ~/.zshrc
60
- ```
53
+ # start a new task
54
+ gji new feature/dark-mode
61
55
 
62
- After that:
56
+ # review a pull request
57
+ gji pr 1234
63
58
 
64
- ```sh
65
- gji new feature/login-form
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
- If you reinstall or upgrade `gji`, refresh the shell function:
62
+ # jump between worktrees
63
+ gji go feature/dark-mode
64
+ gji go main
74
65
 
75
- ```sh
76
- eval "$(gji init zsh)"
66
+ # clean up when done
67
+ gji remove feature/dark-mode
77
68
  ```
78
69
 
79
- For scripts or explicit piping:
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
- `gji new` and `gji remove` print their destination paths in raw CLI mode, but in a shell-integrated session they change directory directly.
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-auth
95
- ```
79
+ gji new feature/auth-refactor # new branch + worktree
80
+ gji new --detached # scratch space, auto-named
96
81
 
97
- Start a detached scratch worktree:
82
+ gji pr 1234 # checkout PR locally
83
+ gji pr https://github.com/org/repo/pull/1234 # or paste the URL
98
84
 
99
- ```sh
100
- gji new --detached
101
- ```
85
+ gji go feature/auth-refactor # jump to a worktree
86
+ gji root # jump to repo root
102
87
 
103
- Check what is active:
88
+ gji status # health overview + ahead/behind counts
89
+ gji ls # compact list
104
90
 
105
- ```sh
106
- gji status
107
- gji ls
108
- ```
91
+ gji sync # rebase current worktree onto default branch
92
+ gji sync --all # rebase every worktree
109
93
 
110
- Pull a PR into its own worktree:
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
- Sync the current worktree with the latest default branch:
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 sync
103
+ gji init zsh # prints the shell function, review it if you like
122
104
  ```
123
105
 
124
- Sync every worktree in the repository:
106
+ To install automatically:
125
107
 
126
108
  ```sh
127
- gji sync --all
109
+ # zsh
110
+ echo 'eval "$(gji init zsh)"' >> ~/.zshrc
111
+
112
+ # bash
113
+ echo 'eval "$(gji init bash)"' >> ~/.bashrc
128
114
  ```
129
115
 
130
- Clean up stale linked worktrees interactively:
116
+ After a reinstall or upgrade, re-source to pick up changes:
131
117
 
132
118
  ```sh
133
- gji clean
119
+ eval "$(gji init zsh)"
134
120
  ```
135
121
 
136
- Finish a single worktree explicitly:
122
+ For scripts that need the raw path, use `--print`:
137
123
 
138
124
  ```sh
139
- gji remove feature/refactor-auth
140
- # or
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
- - `gji --version` prints the installed CLI version
149
- - `gji init [shell]` prints shell integration for `zsh`, `bash`, or `fish`
150
- - `gji new [branch] [--detached]` creates a branch and linked worktree; with shell integration it moves into the new worktree, and `--detached` creates a detached worktree instead
151
- - `gji pr <ref>` accepts `123`, `#123`, or a full PR/MR URL, extracts the numeric ID, then fetches `origin/pull/<number>/head` and creates a linked `pr/<number>` worktree
152
- - `gji go [branch] [--print]` jumps to an existing worktree when shell integration is installed, or prints the matching worktree path otherwise
153
- - `gji root [--print]` jumps to the main repository root when shell integration is installed, or prints it otherwise
154
- - `gji status [--json]` prints repository metadata, worktree health, and upstream divergence
155
- - `gji sync [--all]` fetches from the configured remote and rebases or fast-forwards worktrees onto the configured default branch
156
- - `gji ls [--json]` lists active worktrees in a table or JSON
157
- - `gji clean` interactively prunes one or more linked worktrees, including detached entries, while excluding the current worktree
158
- - `gji remove [branch]` and `gji rm [branch]` remove a linked worktree and delete its branch when present; with shell integration they return to the repository root
159
- - `gji config` reads or updates global defaults
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
- `gji` is usable without setup, but it supports defaults through:
147
+ No setup required. Optional config lives in:
164
148
 
165
- - global config at `~/.config/gji/config.json`
166
- - repo-local config at `.gji.json`
149
+ - `~/.config/gji/config.json` — global defaults
150
+ - `.gji.json` — repo-local overrides (takes precedence)
167
151
 
168
- Repo-local values override global defaults.
152
+ ### Available keys
169
153
 
170
- Supported keys:
171
-
172
- - `branchPrefix`
173
- - `syncRemote`
174
- - `syncDefaultBranch`
175
- - `hooks`
176
-
177
- Example:
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
- Behavior:
172
+ ### Config commands
188
173
 
189
- - if `syncRemote` is unset, `gji sync` defaults to `origin`
190
- - if `syncDefaultBranch` is unset, `gji sync` resolves the remote default branch from `HEAD`
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
- `hooks` runs shell commands at key points in the worktree lifecycle. Configure it in `.gji.json` or `~/.config/gji/config.json`:
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 keys:
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
- | `{{branch}}` | branch name, or empty string for detached worktrees |
219
- | `{{path}}` | absolute path to the worktree |
220
- | `{{repo}}` | repository directory name |
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
- **Environment variables** (available to the hook process):
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
- | Variable | Value |
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
- ```json
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 hooks
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
- `gji ls --json` returns branch/path entries:
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
- gji ls --json
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
- `gji status --json` returns a top-level object with:
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
- - `repoRoot`
256
- - `currentRoot`
257
- - `worktrees`
259
+ ## Non-interactive / CI mode
258
260
 
259
- Each worktree entry contains:
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
- - `branch`: branch name or `null` for detached worktrees
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
- - `gji` works from either the main repository root or any linked worktree
273
- - the current worktree is never offered as a `gji clean` removal candidate
274
- - `gji pr` accepts GitHub, GitLab, and Bitbucket-style PR/MR links, but still fetches from `origin` using GitHub-style `refs/pull/<number>/head`
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
@@ -2,6 +2,7 @@ import type { WorktreeEntry } from './repo.js';
2
2
  export interface CleanCommandOptions {
3
3
  cwd: string;
4
4
  force?: boolean;
5
+ json?: boolean;
5
6
  stderr: (chunk: string) => void;
6
7
  stdout: (chunk: string) => void;
7
8
  }
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.stderr('No linked worktrees to clean\n');
14
+ emitError(options, 'No linked worktrees to clean');
14
15
  return 1;
15
16
  }
16
- const selections = await promptForWorktrees(cleanupCandidates);
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
- reportRemovedPaths(removedPaths, options.stderr);
49
- options.stderr(`Failed to remove worktree at ${worktree.path}: ${toMessage(forceError)}\n`);
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.stdout(`${repository.repoRoot}\n`);
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;
@@ -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.stderr('Aborted\n');
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
- await writeOutput(worktreePath, options.stdout);
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,5 @@
1
+ export interface PackageManager {
2
+ name: string;
3
+ installCommand: string;
4
+ }
5
+ export declare function detectPackageManager(repoRoot: string): Promise<PackageManager | null>;
@@ -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
- options.stderr(`Invalid PR reference: ${options.number}\n`);
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
- options.stderr(`Failed to fetch PR #${prNumber} from origin\n`);
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
- const config = await loadEffectiveConfig(repository.repoRoot);
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
- await writeOutput(worktreePath, options.stdout);
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
@@ -3,6 +3,7 @@ export interface RemoveCommandOptions {
3
3
  branch?: string;
4
4
  cwd: string;
5
5
  force?: boolean;
6
+ json?: boolean;
6
7
  stderr: (chunk: string) => void;
7
8
  stdout: (chunk: string) => void;
8
9
  }
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.stderr('No linked worktrees to finish\n');
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.stderr(`No linked worktree found for branch: ${selection}\n`);
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.stderr(`Failed to remove worktree at ${worktree.path}: ${toMessage(forceError)}\n`);
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
- await writeOutput(repository.repoRoot, options.stdout);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solaqua/gji",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Git worktree CLI for fast context switching.",
5
5
  "license": "MIT",
6
6
  "author": "sjquant",