@solaqua/gji 0.4.1 → 0.6.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
@@ -107,6 +107,9 @@ gji completion fish > ~/.config/fish/completions/gji.fish
107
107
  # start a new task
108
108
  gji new feature/dark-mode
109
109
 
110
+ # start a task and open it straight in your editor
111
+ gji new feature/dark-mode --open --editor cursor
112
+
110
113
  # review a pull request
111
114
  gji pr 1234
112
115
 
@@ -117,6 +120,10 @@ gji status
117
120
  gji go feature/dark-mode
118
121
  gji go main
119
122
 
123
+ # open any worktree in an editor (interactive picker)
124
+ gji open
125
+ gji open feature/dark-mode --editor code
126
+
120
127
  # clean up when done
121
128
  gji remove feature/dark-mode
122
129
  ```
@@ -234,8 +241,9 @@ path=$(gji root --print)
234
241
 
235
242
  | Command | Description |
236
243
  |---|---|
237
- | `gji new [branch] [--detached] [--json]` | create branch + worktree, cd in (validates branch name against Git rules) |
244
+ | `gji new [branch] [--detached] [--open] [--editor <cli>] [--json]` | create branch + worktree, cd in (validates branch name against Git rules) |
238
245
  | `gji pr <ref> [--json]` | fetch PR ref, create worktree, cd in |
246
+ | `gji open [branch] [--editor <cli>] [--save] [--workspace]` | open a worktree in an editor |
239
247
  | `gji go [branch] [--print]` | jump to a worktree |
240
248
  | `gji root [--print]` | jump to the main repo root |
241
249
  | `gji status [--json]` | repo overview, worktree health, ahead/behind |
@@ -260,6 +268,7 @@ No setup required. Optional config lives in:
260
268
  | Key | Description |
261
269
  |---|---|
262
270
  | `branchPrefix` | prefix added to new branch names (e.g. `"feature/"`) |
271
+ | `editor` | default editor CLI for `gji open` and `gji new --open` (e.g. `"cursor"`, `"code"`, `"zed"`); set automatically with `gji open --save` |
263
272
  | `worktreePath` | base directory for new worktrees (absolute or `~/…`); overrides the default `../worktrees/<repo>/` layout |
264
273
  | `syncRemote` | remote for `gji sync` (default: `origin`) |
265
274
  | `syncDefaultBranch` | branch to rebase onto (default: remote `HEAD`) |
@@ -314,8 +323,8 @@ Run scripts automatically at key lifecycle moments:
314
323
  ```json
315
324
  {
316
325
  "hooks": {
317
- "afterCreate": "pnpm install",
318
- "afterEnter": "echo 'switched to {{branch}}'",
326
+ "afterCreate": ["pnpm", "install"],
327
+ "afterEnter": ["printf", "switched to %s\n", "{{branch}}"],
319
328
  "beforeRemove": "pnpm run cleanup"
320
329
  }
321
330
  }
@@ -329,6 +338,31 @@ Run scripts automatically at key lifecycle moments:
329
338
 
330
339
  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.
331
340
 
341
+ Prefer argv-array hooks for simple commands:
342
+
343
+ ```json
344
+ {
345
+ "hooks": {
346
+ "afterCreate": ["pnpm", "install"],
347
+ "afterEnter": ["printf", "switched to %s at %s\n", "{{branch}}", "{{path}}"]
348
+ }
349
+ }
350
+ ```
351
+
352
+ Array hooks run without a shell and pass each array item as exactly one argument. Use string hooks only when you need shell features like `&&`, pipes, redirects, shell functions, or `nvm use`.
353
+
354
+ Template values are interpolated before the shell parses string hooks, so avoid putting `{{branch}}`, `{{path}}`, or `{{repo}}` directly into shell strings. For shell-string hooks, the safer pattern is to use the environment variables and double-quote each expansion:
355
+
356
+ ```json
357
+ {
358
+ "hooks": {
359
+ "afterCreate": "pnpm install && printf 'ready: %s\n' \"$GJI_PATH\""
360
+ }
361
+ }
362
+ ```
363
+
364
+ Avoid unquoted template values in shell strings, such as `echo {{branch}}` or `cd {{path}}`.
365
+
332
366
  Hooks from all three config layers merge per key — different keys from different layers both apply, same key the higher-precedence layer wins:
333
367
 
334
368
  ```jsonc
package/dist/back.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type HistoryEntry } from './history.js';
2
+ export declare const BACK_OUTPUT_FILE_ENV = "GJI_BACK_OUTPUT_FILE";
3
+ export interface BackCommandOptions {
4
+ cwd: string;
5
+ home?: string;
6
+ n?: number;
7
+ print?: boolean;
8
+ stderr: (chunk: string) => void;
9
+ stdout: (chunk: string) => void;
10
+ }
11
+ export declare function runBackCommand(options: BackCommandOptions): Promise<number>;
12
+ export declare function formatHistoryList(history: HistoryEntry[], cwd: string): string;
13
+ export declare function formatAge(timestamp: number): string;
package/dist/back.js ADDED
@@ -0,0 +1,74 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { loadEffectiveConfig } from './config.js';
4
+ import { extractHooks, runHook } from './hooks.js';
5
+ import { appendHistory, loadHistory } from './history.js';
6
+ import { detectRepository } from './repo.js';
7
+ import { writeShellOutput } from './shell-handoff.js';
8
+ export const BACK_OUTPUT_FILE_ENV = 'GJI_BACK_OUTPUT_FILE';
9
+ export async function runBackCommand(options) {
10
+ const history = await loadHistory(options.home);
11
+ const steps = options.n ?? 1;
12
+ if (steps < 1) {
13
+ options.stderr('gji back: step count must be at least 1\n');
14
+ return 1;
15
+ }
16
+ let found = 0;
17
+ let target;
18
+ for (const entry of history) {
19
+ if (entry.path === options.cwd)
20
+ continue;
21
+ try {
22
+ await access(entry.path);
23
+ found++;
24
+ if (found === steps) {
25
+ target = entry;
26
+ break;
27
+ }
28
+ }
29
+ catch {
30
+ // Path no longer exists — skip to the next entry
31
+ }
32
+ }
33
+ if (!target) {
34
+ options.stderr('gji back: no previous worktree in history\n');
35
+ options.stderr("Hint: Use 'gji go', 'gji new', or 'gji pr' to navigate between worktrees\n");
36
+ return 1;
37
+ }
38
+ try {
39
+ const repository = await detectRepository(target.path);
40
+ const config = await loadEffectiveConfig(repository.repoRoot, options.home, options.stderr);
41
+ const hooks = extractHooks(config);
42
+ await runHook(hooks.afterEnter, target.path, { branch: target.branch ?? undefined, path: target.path, repo: basename(repository.repoRoot) }, options.stderr);
43
+ }
44
+ catch {
45
+ // Not in a git repo or hooks unavailable — proceed without hook
46
+ }
47
+ await appendHistory(target.path, target.branch, options.home);
48
+ await writeShellOutput(BACK_OUTPUT_FILE_ENV, target.path, options.stdout);
49
+ return 0;
50
+ }
51
+ export function formatHistoryList(history, cwd) {
52
+ const branchWidth = Math.max('BRANCH'.length, ...history.map((e) => (e.branch ?? '(detached)').length));
53
+ const lines = [' ' + 'BRANCH'.padEnd(branchWidth) + ' WHEN PATH'];
54
+ for (const entry of history) {
55
+ const isCurrent = entry.path === cwd;
56
+ const branch = (entry.branch ?? '(detached)').padEnd(branchWidth);
57
+ const when = formatAge(entry.timestamp).padEnd(10);
58
+ lines.push(`${isCurrent ? '*' : ' '} ${branch} ${when} ${entry.path}`);
59
+ }
60
+ return lines.join('\n') + '\n';
61
+ }
62
+ export function formatAge(timestamp) {
63
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
64
+ if (seconds < 60)
65
+ return 'just now';
66
+ const minutes = Math.floor(seconds / 60);
67
+ if (minutes < 60)
68
+ return `${minutes}m ago`;
69
+ const hours = Math.floor(minutes / 60);
70
+ if (hours < 24)
71
+ return `${hours}h ago`;
72
+ const days = Math.floor(hours / 24);
73
+ return `${days}d ago`;
74
+ }
package/dist/cli.js CHANGED
@@ -1,20 +1,26 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { Command } from 'commander';
3
3
  import updateNotifier from 'update-notifier';
4
+ import { runBackCommand } from './back.js';
4
5
  import { runCleanCommand } from './clean.js';
6
+ import { runHistoryCommand } from './history-command.js';
5
7
  import { runCompletionCommand } from './completion.js';
6
8
  import { runConfigCommand } from './config-command.js';
7
9
  import { runGoCommand } from './go.js';
10
+ import { runOpenCommand } from './open.js';
8
11
  import { isHeadless } from './headless.js';
9
12
  import { runInitCommand } from './init.js';
10
13
  import { runLsCommand } from './ls.js';
11
14
  import { runNewCommand } from './new.js';
12
15
  import { runPrCommand } from './pr.js';
13
16
  import { runRemoveCommand } from './remove.js';
17
+ import { registerRepo } from './repo-registry.js';
18
+ import { detectRepository } from './repo.js';
14
19
  import { runRootCommand } from './root.js';
15
20
  import { runStatusCommand } from './status.js';
16
21
  import { runSyncCommand } from './sync.js';
17
22
  import { runTriggerHookCommand } from './trigger-hook.js';
23
+ import { runWarpCommand } from './warp.js';
18
24
  export function createProgram() {
19
25
  const program = new Command();
20
26
  const packageMetadata = readPackageMetadata();
@@ -37,6 +43,7 @@ function readPackageMetadata() {
37
43
  }
38
44
  export async function runCli(argv, options = {}) {
39
45
  await maybeNotifyForUpdates(argv);
46
+ maybeRegisterCurrentRepo(options.cwd ?? process.cwd());
40
47
  const program = createProgram();
41
48
  const cwd = options.cwd ?? process.cwd();
42
49
  const stdout = options.stdout ?? (() => undefined);
@@ -92,12 +99,19 @@ function defaultNotifyForUpdates(pkg) {
92
99
  const notifier = updateNotifier({ pkg });
93
100
  notifier.notify();
94
101
  }
102
+ function maybeRegisterCurrentRepo(cwd) {
103
+ detectRepository(cwd)
104
+ .then(({ repoRoot }) => registerRepo(repoRoot))
105
+ .catch(() => undefined);
106
+ }
95
107
  function registerCommands(program) {
96
108
  program
97
109
  .command('new [branch]')
98
110
  .description('create a new branch or detached linked worktree')
99
111
  .option('-f, --force', 'remove and recreate the worktree if the target path already exists')
100
112
  .option('--detached', 'create a detached worktree without a branch')
113
+ .option('--open', 'open the new worktree in an editor after creation')
114
+ .option('--editor <cli>', 'editor CLI to use with --open (code, cursor, zed, …)')
101
115
  .option('--dry-run', 'show what would be created without executing any git commands or writing files')
102
116
  .option('--json', 'emit JSON on success or error instead of human-readable output')
103
117
  .action(notImplemented('new'));
@@ -116,8 +130,26 @@ function registerCommands(program) {
116
130
  .option('--dry-run', 'show what would be created without executing any git commands or writing files')
117
131
  .option('--json', 'emit JSON on success or error instead of human-readable output')
118
132
  .action(notImplemented('pr'));
133
+ program
134
+ .command('back [n]')
135
+ .description('navigate to the previously visited worktree, optionally N steps back')
136
+ .option('--print', 'print the resolved worktree path explicitly')
137
+ .action(notImplemented('back'));
138
+ program
139
+ .command('history')
140
+ .description('show navigation history')
141
+ .option('--json', 'print history as JSON')
142
+ .action(notImplemented('history'));
143
+ program
144
+ .command('open [branch]')
145
+ .description('open the worktree in an editor')
146
+ .option('--editor <cli>', 'editor CLI to use (code, cursor, zed, windsurf, subl, …)')
147
+ .option('--save', 'save the chosen editor to global config')
148
+ .option('--workspace', 'generate a .code-workspace file before opening (VS Code / Cursor / Windsurf)')
149
+ .action(notImplemented('open'));
119
150
  program
120
151
  .command('go [branch]')
152
+ .alias('jump')
121
153
  .description('print or select a worktree path')
122
154
  .option('--print', 'print the resolved worktree path explicitly')
123
155
  .action(notImplemented('go'));
@@ -163,6 +195,15 @@ function registerCommands(program) {
163
195
  .command('trigger-hook <hook>')
164
196
  .description('run a named hook (afterCreate, afterEnter, beforeRemove) in the current worktree')
165
197
  .action(notImplemented('trigger-hook'));
198
+ program
199
+ .command('warp [branch]')
200
+ .description('jump to any worktree across all known repos')
201
+ .option('-n, --new [branch]', 'create a new worktree in a registered repo')
202
+ // --print is the shell-wrapper bypass signal (see SHELL_WRAPPED_COMMANDS in init.ts).
203
+ // The shell omits GJI_WARP_OUTPUT_FILE, so writeShellOutput falls through to stdout.
204
+ .option('--print', 'print the resolved worktree path without changing directory')
205
+ .option('--json', 'emit JSON on success or error instead of human-readable output')
206
+ .action(notImplemented('warp'));
166
207
  const configCommand = program
167
208
  .command('config')
168
209
  .description('manage global config defaults')
@@ -184,7 +225,7 @@ function attachCommandActions(program, options) {
184
225
  program.commands
185
226
  .find((command) => command.name() === 'new')
186
227
  ?.action(async (branch, commandOptions) => {
187
- const exitCode = await runNewCommand({ ...options, branch, detached: commandOptions.detached, dryRun: commandOptions.dryRun, force: commandOptions.force, json: commandOptions.json });
228
+ const exitCode = await runNewCommand({ ...options, branch, detached: commandOptions.detached, dryRun: commandOptions.dryRun, editor: commandOptions.editor, force: commandOptions.force, json: commandOptions.json, open: commandOptions.open });
188
229
  if (exitCode !== 0) {
189
230
  throw commanderExit(exitCode);
190
231
  }
@@ -223,6 +264,53 @@ function attachCommandActions(program, options) {
223
264
  throw commanderExit(exitCode);
224
265
  }
225
266
  });
267
+ program.commands
268
+ .find((command) => command.name() === 'back')
269
+ ?.action(async (n, commandOptions) => {
270
+ if (n !== undefined && !/^\d+$/.test(n)) {
271
+ options.stderr(`gji back: invalid step count: ${n}\n`);
272
+ throw commanderExit(1);
273
+ }
274
+ const steps = n !== undefined ? parseInt(n, 10) : undefined;
275
+ const exitCode = await runBackCommand({
276
+ cwd: options.cwd,
277
+ n: steps,
278
+ print: commandOptions.print,
279
+ stderr: options.stderr,
280
+ stdout: options.stdout,
281
+ });
282
+ if (exitCode !== 0) {
283
+ throw commanderExit(exitCode);
284
+ }
285
+ });
286
+ program.commands
287
+ .find((command) => command.name() === 'history')
288
+ ?.action(async (commandOptions) => {
289
+ const exitCode = await runHistoryCommand({
290
+ cwd: options.cwd,
291
+ json: commandOptions.json,
292
+ stdout: options.stdout,
293
+ });
294
+ if (exitCode !== 0) {
295
+ throw commanderExit(exitCode);
296
+ }
297
+ });
298
+ program.commands
299
+ .find((command) => command.name() === 'open')
300
+ ?.action(async (branch, commandOptions) => {
301
+ const exitCode = await runOpenCommand({
302
+ branch,
303
+ cwd: options.cwd,
304
+ editor: commandOptions.editor,
305
+ save: commandOptions.save,
306
+ stderr: options.stderr,
307
+ stdout: options.stdout,
308
+ workspace: commandOptions.workspace,
309
+ });
310
+ if (exitCode !== 0) {
311
+ throw commanderExit(exitCode);
312
+ }
313
+ });
226
314
  program.commands
227
315
  .find((command) => command.name() === 'go')
228
316
  ?.action(async (branch, commandOptions) => {
@@ -333,6 +421,24 @@ function attachCommandActions(program, options) {
333
421
  throw commanderExit(exitCode);
334
422
  }
335
423
  });
424
+ program.commands
425
+ .find((command) => command.name() === 'warp')
426
+ ?.action(async (branch, commandOptions) => {
427
+ const newFlag = commandOptions.new;
428
+ const newWorktree = newFlag !== undefined && newFlag !== false;
429
+ const newBranch = typeof newFlag === 'string' ? newFlag : undefined;
430
+ const exitCode = await runWarpCommand({
431
+ branch: newWorktree ? (newBranch ?? branch) : branch,
432
+ cwd: options.cwd,
433
+ json: commandOptions.json,
434
+ newWorktree,
435
+ stderr: options.stderr,
436
+ stdout: options.stdout,
437
+ });
438
+ if (exitCode !== 0) {
439
+ throw commanderExit(exitCode);
440
+ }
441
+ });
336
442
  const configCommand = program.commands.find((command) => command.name() === 'config');
337
443
  configCommand?.action(async () => {
338
444
  const exitCode = await runConfigCommand({
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ export const GLOBAL_CONFIG_DIRECTORY = '.config/gji';
6
6
  export const GLOBAL_CONFIG_NAME = 'config.json';
7
7
  export const KNOWN_CONFIG_KEYS = new Set([
8
8
  'branchPrefix',
9
+ 'editor',
9
10
  'hooks',
10
11
  'installSaveTarget',
11
12
  'shellIntegration',
@@ -0,0 +1,8 @@
1
+ export interface EditorDefinition {
2
+ cli: string;
3
+ name: string;
4
+ newWindowFlag?: string;
5
+ supportsWorkspace: boolean;
6
+ }
7
+ export declare const EDITORS: EditorDefinition[];
8
+ export declare function defaultSpawnEditor(cli: string, args: string[]): Promise<void>;
package/dist/editor.js ADDED
@@ -0,0 +1,17 @@
1
+ import { spawn } from 'node:child_process';
2
+ // Ordered by likely popularity among the target audience.
3
+ export const EDITORS = [
4
+ { cli: 'cursor', name: 'Cursor', newWindowFlag: '--new-window', supportsWorkspace: true },
5
+ { cli: 'code', name: 'VS Code', newWindowFlag: '--new-window', supportsWorkspace: true },
6
+ { cli: 'windsurf', name: 'Windsurf', newWindowFlag: '--new-window', supportsWorkspace: true },
7
+ { cli: 'zed', name: 'Zed', supportsWorkspace: false },
8
+ { cli: 'subl', name: 'Sublime Text', newWindowFlag: '--new-window', supportsWorkspace: false },
9
+ ];
10
+ export async function defaultSpawnEditor(cli, args) {
11
+ const child = spawn(cli, args, { detached: true, stdio: 'ignore' });
12
+ await new Promise((resolve, reject) => {
13
+ child.once('error', reject);
14
+ child.once('spawn', resolve);
15
+ });
16
+ child.unref();
17
+ }