@solaqua/gji 0.4.0 → 0.4.1

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