@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/dist/go.js CHANGED
@@ -4,16 +4,35 @@ import { loadEffectiveConfig } from './config.js';
4
4
  import { readWorktreeHealth } from './git.js';
5
5
  import { isHeadless } from './headless.js';
6
6
  import { extractHooks, runHook } from './hooks.js';
7
+ import { appendHistory } from './history.js';
7
8
  import { detectRepository, listWorktrees, sortByCurrentFirst } from './repo.js';
8
9
  import { writeShellOutput } from './shell-handoff.js';
10
+ import { resolveWarpTarget } from './warp.js';
9
11
  const GO_OUTPUT_FILE_ENV = 'GJI_GO_OUTPUT_FILE';
10
12
  export function createGoCommand(dependencies = {}) {
11
13
  const prompt = dependencies.promptForWorktree ?? promptForWorktree;
12
14
  return async function runGoCommand(options) {
13
- const [worktrees, repository] = await Promise.all([
14
- listWorktrees(options.cwd),
15
- detectRepository(options.cwd),
16
- ]);
15
+ let worktrees;
16
+ let repository;
17
+ try {
18
+ [worktrees, repository] = await Promise.all([
19
+ listWorktrees(options.cwd),
20
+ detectRepository(options.cwd),
21
+ ]);
22
+ }
23
+ catch {
24
+ // Not inside a git repo — fall back to cross-repo navigation.
25
+ if (isHeadless() && !options.branch) {
26
+ options.stderr('gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n');
27
+ return 1;
28
+ }
29
+ const target = await resolveWarpTarget({ ...options, commandName: 'gji go' });
30
+ if (!target)
31
+ return 1;
32
+ appendHistory(target.path, target.branch).catch(() => undefined);
33
+ await writeShellOutput(GO_OUTPUT_FILE_ENV, target.path, options.stdout);
34
+ return 0;
35
+ }
17
36
  if (!options.branch && isHeadless()) {
18
37
  options.stderr('gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n');
19
38
  return 1;
@@ -36,6 +55,7 @@ export function createGoCommand(dependencies = {}) {
36
55
  const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
37
56
  const hooks = extractHooks(config);
38
57
  await runHook(hooks.afterEnter, resolvedPath, { branch: chosenWorktree?.branch ?? undefined, path: resolvedPath, repo: basename(repository.repoRoot) }, options.stderr);
58
+ appendHistory(resolvedPath, chosenWorktree?.branch ?? null).catch(() => undefined);
39
59
  await writeShellOutput(GO_OUTPUT_FILE_ENV, resolvedPath, options.stdout);
40
60
  return 0;
41
61
  };
@@ -0,0 +1,7 @@
1
+ export interface HistoryCommandOptions {
2
+ cwd: string;
3
+ home?: string;
4
+ json?: boolean;
5
+ stdout: (chunk: string) => void;
6
+ }
7
+ export declare function runHistoryCommand(options: HistoryCommandOptions): Promise<number>;
@@ -0,0 +1,15 @@
1
+ import { loadHistory } from './history.js';
2
+ import { formatHistoryList } from './back.js';
3
+ export async function runHistoryCommand(options) {
4
+ const history = await loadHistory(options.home);
5
+ if (options.json) {
6
+ options.stdout(`${JSON.stringify(history, null, 2)}\n`);
7
+ return 0;
8
+ }
9
+ if (history.length === 0) {
10
+ options.stdout('No navigation history.\n');
11
+ return 0;
12
+ }
13
+ options.stdout(formatHistoryList(history, options.cwd));
14
+ return 0;
15
+ }
@@ -0,0 +1,9 @@
1
+ export declare const HISTORY_FILE_NAME = "history.json";
2
+ export interface HistoryEntry {
3
+ branch: string | null;
4
+ path: string;
5
+ timestamp: number;
6
+ }
7
+ export declare function HISTORY_FILE_PATH(home?: string): string;
8
+ export declare function loadHistory(home?: string): Promise<HistoryEntry[]>;
9
+ export declare function appendHistory(path: string, branch: string | null, home?: string): Promise<void>;
@@ -0,0 +1,46 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { GLOBAL_CONFIG_DIRECTORY } from './config.js';
5
+ export const HISTORY_FILE_NAME = 'history.json';
6
+ const MAX_HISTORY_ENTRIES = 50;
7
+ export function HISTORY_FILE_PATH(home = homedir()) {
8
+ const configDir = process.env.GJI_CONFIG_DIR;
9
+ if (configDir) {
10
+ return join(resolve(configDir), HISTORY_FILE_NAME);
11
+ }
12
+ return join(home, GLOBAL_CONFIG_DIRECTORY, HISTORY_FILE_NAME);
13
+ }
14
+ export async function loadHistory(home = homedir()) {
15
+ const path = HISTORY_FILE_PATH(home);
16
+ try {
17
+ const raw = await readFile(path, 'utf8');
18
+ const parsed = JSON.parse(raw);
19
+ if (!Array.isArray(parsed))
20
+ return [];
21
+ return parsed.filter(isHistoryEntry);
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ export async function appendHistory(path, branch, home = homedir()) {
28
+ const historyPath = HISTORY_FILE_PATH(home);
29
+ const existing = await loadHistory(home);
30
+ // Skip if the most recent entry is the same path (no-op navigation)
31
+ if (existing.length > 0 && existing[0].path === path) {
32
+ return;
33
+ }
34
+ const entry = { branch, path, timestamp: Date.now() };
35
+ const next = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES);
36
+ await mkdir(dirname(historyPath), { recursive: true });
37
+ await writeFile(historyPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
38
+ }
39
+ function isHistoryEntry(value) {
40
+ return (typeof value === 'object' &&
41
+ value !== null &&
42
+ 'path' in value &&
43
+ typeof value.path === 'string' &&
44
+ 'timestamp' in value &&
45
+ typeof value.timestamp === 'number');
46
+ }
package/dist/hooks.d.ts CHANGED
@@ -1,13 +1,14 @@
1
+ export type GjiHookCommand = string | string[];
1
2
  export interface GjiHooks {
2
- afterCreate?: string;
3
- afterEnter?: string;
4
- beforeRemove?: string;
3
+ afterCreate?: GjiHookCommand;
4
+ afterEnter?: GjiHookCommand;
5
+ beforeRemove?: GjiHookCommand;
5
6
  }
6
7
  export interface HookContext {
7
8
  branch?: string;
8
9
  path: string;
9
10
  repo: string;
10
11
  }
11
- export declare function runHook(hookCmd: string | undefined, cwd: string, context: HookContext, stderr: (chunk: string) => void): Promise<void>;
12
+ export declare function runHook(hookCmd: GjiHookCommand | undefined, cwd: string, context: HookContext, stderr: (chunk: string) => void): Promise<void>;
12
13
  export declare function interpolate(template: string, context: HookContext): string;
13
14
  export declare function extractHooks(config: Record<string, unknown>): GjiHooks;
package/dist/hooks.js CHANGED
@@ -2,18 +2,48 @@ import { spawn } from 'node:child_process';
2
2
  export async function runHook(hookCmd, cwd, context, stderr) {
3
3
  if (!hookCmd)
4
4
  return;
5
+ if (Array.isArray(hookCmd)) {
6
+ await runArgvHook(hookCmd, cwd, context, stderr);
7
+ return;
8
+ }
9
+ await runShellHook(hookCmd, cwd, context, stderr);
10
+ }
11
+ async function runArgvHook(hookCmd, cwd, context, stderr) {
12
+ const [command, ...args] = hookCmd.map((arg) => interpolate(arg, context));
13
+ if (!command) {
14
+ stderr('gji: hook argv command must include a non-empty command\n');
15
+ return;
16
+ }
17
+ await new Promise((resolve) => {
18
+ const child = spawn(command, args, {
19
+ cwd,
20
+ shell: false,
21
+ stdio: ['ignore', 'inherit', 'pipe'],
22
+ env: hookEnvironment(context),
23
+ });
24
+ child.stderr.on('data', (chunk) => {
25
+ stderr(chunk.toString());
26
+ });
27
+ child.on('close', (code) => {
28
+ if (code !== 0) {
29
+ stderr(`gji: hook exited with code ${code}: ${formatArgvHook(command, args)}\n`);
30
+ }
31
+ resolve();
32
+ });
33
+ child.on('error', (err) => {
34
+ stderr(`gji: hook failed to start: ${err.message}\n`);
35
+ resolve();
36
+ });
37
+ });
38
+ }
39
+ async function runShellHook(hookCmd, cwd, context, stderr) {
5
40
  const interpolated = interpolate(hookCmd, context);
6
41
  await new Promise((resolve) => {
7
42
  const child = spawn(interpolated, {
8
43
  cwd,
9
44
  shell: true,
10
45
  stdio: ['ignore', 'inherit', 'pipe'],
11
- env: {
12
- ...process.env,
13
- GJI_BRANCH: context.branch ?? '',
14
- GJI_PATH: context.path,
15
- GJI_REPO: context.repo,
16
- },
46
+ env: hookEnvironment(context),
17
47
  });
18
48
  child.stderr.on('data', (chunk) => {
19
49
  stderr(chunk.toString());
@@ -30,6 +60,17 @@ export async function runHook(hookCmd, cwd, context, stderr) {
30
60
  });
31
61
  });
32
62
  }
63
+ function hookEnvironment(context) {
64
+ return {
65
+ ...process.env,
66
+ GJI_BRANCH: context.branch ?? '',
67
+ GJI_PATH: context.path,
68
+ GJI_REPO: context.repo,
69
+ };
70
+ }
71
+ function formatArgvHook(command, args) {
72
+ return JSON.stringify([command, ...args]);
73
+ }
33
74
  export function interpolate(template, context) {
34
75
  return template
35
76
  .replace(/\{\{branch\}\}/g, context.branch ?? '')
@@ -43,8 +84,19 @@ export function extractHooks(config) {
43
84
  }
44
85
  const hooks = raw;
45
86
  return {
46
- afterCreate: typeof hooks.afterCreate === 'string' ? hooks.afterCreate : undefined,
47
- afterEnter: typeof hooks.afterEnter === 'string' ? hooks.afterEnter : undefined,
48
- beforeRemove: typeof hooks.beforeRemove === 'string' ? hooks.beforeRemove : undefined,
87
+ afterCreate: parseHookCommand(hooks.afterCreate),
88
+ afterEnter: parseHookCommand(hooks.afterEnter),
89
+ beforeRemove: parseHookCommand(hooks.beforeRemove),
49
90
  };
50
91
  }
92
+ function parseHookCommand(value) {
93
+ if (typeof value === 'string')
94
+ return value;
95
+ if (Array.isArray(value) &&
96
+ value.length > 0 &&
97
+ value[0] !== '' &&
98
+ value.every((item) => typeof item === 'string')) {
99
+ return value;
100
+ }
101
+ return undefined;
102
+ }
package/dist/init.js CHANGED
@@ -8,40 +8,54 @@ const START_MARKER = '# >>> gji init >>>';
8
8
  const END_MARKER = '# <<< gji init <<<';
9
9
  const SHELL_WRAPPED_COMMANDS = [
10
10
  {
11
- bypassOption: '--help',
11
+ bypassOptions: ['--help'],
12
12
  commandName: 'new',
13
13
  envVar: 'GJI_NEW_OUTPUT_FILE',
14
14
  names: ['new'],
15
15
  tempPrefix: 'gji-new',
16
16
  },
17
17
  {
18
- bypassOption: '--help',
18
+ bypassOptions: ['--help'],
19
19
  commandName: 'pr',
20
20
  envVar: 'GJI_PR_OUTPUT_FILE',
21
21
  names: ['pr'],
22
22
  tempPrefix: 'gji-pr',
23
23
  },
24
24
  {
25
- bypassOption: '--print',
25
+ bypassOptions: ['--print'],
26
+ commandName: 'back',
27
+ envVar: 'GJI_BACK_OUTPUT_FILE',
28
+ names: ['back'],
29
+ tempPrefix: 'gji-back',
30
+ },
31
+ {
32
+ bypassOptions: ['--print'],
26
33
  commandName: 'go',
27
34
  envVar: 'GJI_GO_OUTPUT_FILE',
28
- names: ['go'],
35
+ names: ['go', 'jump'],
29
36
  tempPrefix: 'gji-go',
30
37
  },
31
38
  {
32
- bypassOption: '--print',
39
+ bypassOptions: ['--print'],
33
40
  commandName: 'root',
34
41
  envVar: 'GJI_ROOT_OUTPUT_FILE',
35
42
  names: ['root'],
36
43
  tempPrefix: 'gji-root',
37
44
  },
38
45
  {
39
- bypassOption: '--help',
46
+ bypassOptions: ['--help'],
40
47
  commandName: 'remove',
41
48
  envVar: 'GJI_REMOVE_OUTPUT_FILE',
42
49
  names: ['remove', 'rm'],
43
50
  tempPrefix: 'gji-remove',
44
51
  },
52
+ {
53
+ bypassOptions: ['--print', '--json'],
54
+ commandName: 'warp',
55
+ envVar: 'GJI_WARP_OUTPUT_FILE',
56
+ names: ['warp'],
57
+ tempPrefix: 'gji-warp',
58
+ },
45
59
  ];
46
60
  export async function runInitCommand(options) {
47
61
  const shell = resolveSupportedShell(options.shell, process.env.SHELL);
@@ -171,10 +185,17 @@ function isMissingFileError(error) {
171
185
  return error instanceof Error && 'code' in error && error.code === 'ENOENT';
172
186
  }
173
187
  function renderFishWrapper(command) {
174
- const tests = command.names.map((name) => `test $argv[1] = ${name}`).join('; or ');
175
- return `if test (count $argv) -gt 0; and ${tests}
188
+ const nameTests = command.names.map((name) => `test $argv[1] = ${name}`);
189
+ const nameCondition = nameTests.length === 1
190
+ ? nameTests[0]
191
+ : `begin; ${nameTests.join('; or ')}; end`;
192
+ const bypassTests = command.bypassOptions.map((opt) => `test $argv[1] = ${opt}`);
193
+ const bypassCondition = bypassTests.length === 1
194
+ ? bypassTests[0]
195
+ : `begin; ${bypassTests.join('; or ')}; end`;
196
+ return `if test (count $argv) -gt 0; and ${nameCondition}
176
197
  set -e argv[1]
177
- if test (count $argv) -gt 0; and test $argv[1] = ${command.bypassOption}
198
+ if test (count $argv) -gt 0; and ${bypassCondition}
178
199
  command gji ${command.commandName} $argv
179
200
  return $status
180
201
  end
@@ -195,9 +216,10 @@ end`;
195
216
  }
196
217
  function renderPosixWrapper(command) {
197
218
  const tests = command.names.map((name) => `[ "$1" = "${name}" ]`).join(' || ');
219
+ const bypassTests = command.bypassOptions.map((opt) => `[ "\${1:-}" = "${opt}" ]`).join(' || ');
198
220
  return `if ${tests}; then
199
221
  shift
200
- if [ "\${1:-}" = "${command.bypassOption}" ]; then
222
+ if ${bypassTests}; then
201
223
  command gji ${command.commandName} "$@"
202
224
  return $?
203
225
  fi
@@ -10,7 +10,7 @@ export async function maybeRunInstallPrompt(worktreePath, repoRoot, config, stde
10
10
  }
11
11
  // Skip if afterCreate hook is already configured in effective config.
12
12
  const hooks = isPlainObject(config.hooks) ? config.hooks : null;
13
- if (typeof hooks?.afterCreate === 'string' && hooks.afterCreate.length > 0) {
13
+ if (isConfiguredHookCommand(hooks?.afterCreate)) {
14
14
  return;
15
15
  }
16
16
  // Skip if user has permanently opted out of install prompts.
@@ -120,3 +120,11 @@ async function defaultPromptForInstallChoice(pm) {
120
120
  function isPlainObject(value) {
121
121
  return typeof value === 'object' && value !== null && !Array.isArray(value);
122
122
  }
123
+ function isConfiguredHookCommand(value) {
124
+ if (typeof value === 'string')
125
+ return value.length > 0;
126
+ return (Array.isArray(value) &&
127
+ value.length > 0 &&
128
+ value[0] !== '' &&
129
+ value.every((item) => typeof item === 'string'));
130
+ }
package/dist/new.d.ts CHANGED
@@ -6,8 +6,10 @@ export interface NewCommandOptions {
6
6
  cwd: string;
7
7
  detached?: boolean;
8
8
  dryRun?: boolean;
9
+ editor?: string;
9
10
  force?: boolean;
10
11
  json?: boolean;
12
+ open?: boolean;
11
13
  stderr: (chunk: string) => void;
12
14
  stdout: (chunk: string) => void;
13
15
  }
@@ -15,6 +17,7 @@ export interface NewCommandDependencies extends InstallPromptDependencies {
15
17
  createBranchPlaceholder: () => string;
16
18
  promptForBranch: (placeholder: string) => Promise<string | null>;
17
19
  promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
20
+ spawnEditor: (cli: string, args: string[]) => Promise<void>;
18
21
  }
19
22
  export declare function createNewCommand(dependencies?: Partial<NewCommandDependencies>): (options: NewCommandOptions) => Promise<number>;
20
23
  export declare const runNewCommand: (options: NewCommandOptions) => Promise<number>;
package/dist/new.js CHANGED
@@ -4,8 +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, resolveConfigString } from './config.js';
7
+ import { defaultSpawnEditor, EDITORS } from './editor.js';
7
8
  import { syncFiles } from './file-sync.js';
8
9
  import { extractHooks, runHook } from './hooks.js';
10
+ import { appendHistory } from './history.js';
9
11
  import { isHeadless } from './headless.js';
10
12
  import { maybeRunInstallPrompt } from './install-prompt.js';
11
13
  import { pathExists, promptForPathConflict } from './conflict.js';
@@ -17,10 +19,14 @@ export function createNewCommand(dependencies = {}) {
17
19
  const createBranchPlaceholder = dependencies.createBranchPlaceholder ?? generateBranchPlaceholder;
18
20
  const promptForBranch = dependencies.promptForBranch ?? defaultPromptForBranch;
19
21
  const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
22
+ const spawnEditor = dependencies.spawnEditor ?? defaultSpawnEditor;
20
23
  return async function runNewCommand(options) {
21
24
  const repository = await detectRepository(options.cwd);
22
25
  const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
23
26
  const usesGeneratedDetachedName = options.detached && options.branch === undefined;
27
+ if (options.editor && !options.open) {
28
+ options.stderr('gji new: --editor has no effect without --open\n');
29
+ }
24
30
  if (!options.detached && !options.branch && (options.json || isHeadless())) {
25
31
  const message = 'branch argument is required';
26
32
  if (options.json) {
@@ -105,6 +111,7 @@ export function createNewCommand(dependencies = {}) {
105
111
  else {
106
112
  const choice = await prompt(worktreePath);
107
113
  if (choice === 'reuse') {
114
+ appendHistory(worktreePath, worktreeName).catch(() => undefined);
108
115
  await writeOutput(worktreePath, options.stdout);
109
116
  return 0;
110
117
  }
@@ -117,7 +124,11 @@ export function createNewCommand(dependencies = {}) {
117
124
  options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath, dryRun: true }, null, 2)}\n`);
118
125
  }
119
126
  else {
120
- options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName})\n`);
127
+ const resolvedEditor = options.open
128
+ ? (options.editor ?? resolveConfigString(config, 'editor'))
129
+ : undefined;
130
+ const openNote = resolvedEditor ? `, then open in ${resolvedEditor}` : '';
131
+ options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n`);
121
132
  }
122
133
  return 0;
123
134
  }
@@ -147,8 +158,13 @@ export function createNewCommand(dependencies = {}) {
147
158
  options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath }, null, 2)}\n`);
148
159
  }
149
160
  else {
161
+ await appendHistory(worktreePath, worktreeName);
150
162
  await writeOutput(worktreePath, options.stdout);
151
163
  }
164
+ if (options.open) {
165
+ const resolvedEditor = options.editor ?? resolveConfigString(config, 'editor');
166
+ await openWorktree(worktreePath, resolvedEditor, spawnEditor, options.stderr);
167
+ }
152
168
  return 0;
153
169
  };
154
170
  }
@@ -256,3 +272,22 @@ function hasExecStderr(error) {
256
272
  function toExecMessage(error) {
257
273
  return hasExecStderr(error) ? error.stderr.trim() : String(error);
258
274
  }
275
+ async function openWorktree(worktreePath, editorCli, spawnFn, stderr) {
276
+ if (!editorCli) {
277
+ stderr('gji new: --open requires --editor <cli> or a saved editor in config\n');
278
+ return;
279
+ }
280
+ const editorDef = EDITORS.find((e) => e.cli === editorCli);
281
+ const args = [];
282
+ if (editorDef?.newWindowFlag) {
283
+ args.push(editorDef.newWindowFlag);
284
+ }
285
+ args.push(worktreePath);
286
+ try {
287
+ await spawnFn(editorCli, args);
288
+ }
289
+ catch (error) {
290
+ const message = error instanceof Error ? error.message : String(error);
291
+ stderr(`gji new: failed to open editor: ${message}\n`);
292
+ }
293
+ }
package/dist/open.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { type EditorDefinition } from './editor.js';
2
+ import { type WorktreeEntry } from './repo.js';
3
+ export type { EditorDefinition };
4
+ export interface OpenCommandOptions {
5
+ branch?: string;
6
+ cwd: string;
7
+ editor?: string;
8
+ save?: boolean;
9
+ stderr: (chunk: string) => void;
10
+ stdout: (chunk: string) => void;
11
+ workspace?: boolean;
12
+ }
13
+ export interface OpenCommandDependencies {
14
+ detectEditors: () => Promise<EditorDefinition[]>;
15
+ promptForEditor: (editors: EditorDefinition[]) => Promise<string | null>;
16
+ promptForWorktree: (worktrees: WorktreeEntry[]) => Promise<string | null>;
17
+ spawnEditor: (cli: string, args: string[]) => Promise<void>;
18
+ }
19
+ export declare function createOpenCommand(dependencies?: Partial<OpenCommandDependencies>): (options: OpenCommandOptions) => Promise<number>;
20
+ export declare const runOpenCommand: (options: OpenCommandOptions) => Promise<number>;
package/dist/open.js ADDED
@@ -0,0 +1,155 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { isCancel, select } from '@clack/prompts';
6
+ import { loadEffectiveConfig, resolveConfigString, updateGlobalConfigKey } from './config.js';
7
+ import { defaultSpawnEditor, EDITORS } from './editor.js';
8
+ import { isHeadless } from './headless.js';
9
+ import { detectRepository, listWorktrees, sortByCurrentFirst } from './repo.js';
10
+ const execFileAsync = promisify(execFile);
11
+ export function createOpenCommand(dependencies = {}) {
12
+ const detectEditors = dependencies.detectEditors ?? detectInstalledEditors;
13
+ const promptForEditor = dependencies.promptForEditor ?? defaultPromptForEditor;
14
+ const promptForWorktree = dependencies.promptForWorktree ?? defaultPromptForWorktree;
15
+ const spawnEditor = dependencies.spawnEditor ?? defaultSpawnEditor;
16
+ return async function runOpenCommand(options) {
17
+ const [worktrees, repository] = await Promise.all([
18
+ listWorktrees(options.cwd),
19
+ detectRepository(options.cwd),
20
+ ]);
21
+ // Resolve target worktree path.
22
+ let targetPath;
23
+ if (options.branch) {
24
+ const entry = worktrees.find((w) => w.branch === options.branch);
25
+ if (!entry) {
26
+ options.stderr(`gji open: no worktree found for branch: ${options.branch}\n`);
27
+ options.stderr(`Hint: Use 'gji ls' to see available worktrees\n`);
28
+ return 1;
29
+ }
30
+ targetPath = entry.path;
31
+ }
32
+ else if (isHeadless()) {
33
+ targetPath = worktrees.find((w) => w.isCurrent)?.path ?? options.cwd;
34
+ }
35
+ else {
36
+ const chosen = await promptForWorktree(sortByCurrentFirst(worktrees));
37
+ if (!chosen) {
38
+ options.stderr('Aborted\n');
39
+ return 1;
40
+ }
41
+ targetPath = chosen;
42
+ }
43
+ // Resolve which editor to use.
44
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
45
+ const savedEditor = resolveConfigString(config, 'editor');
46
+ let editorCli;
47
+ if (options.editor) {
48
+ editorCli = options.editor;
49
+ }
50
+ else if (savedEditor) {
51
+ editorCli = savedEditor;
52
+ }
53
+ else {
54
+ const installed = await detectEditors();
55
+ if (installed.length === 0) {
56
+ options.stderr('gji open: no supported editor detected. Use --editor <code|cursor|zed|...> to specify one.\n');
57
+ return 1;
58
+ }
59
+ if (installed.length === 1 || isHeadless()) {
60
+ editorCli = installed[0].cli;
61
+ }
62
+ else {
63
+ const chosen = await promptForEditor(installed);
64
+ if (!chosen) {
65
+ options.stderr('Aborted\n');
66
+ return 1;
67
+ }
68
+ editorCli = chosen;
69
+ }
70
+ }
71
+ // Persist editor choice when requested.
72
+ if (options.save && editorCli !== savedEditor) {
73
+ await updateGlobalConfigKey('editor', editorCli);
74
+ const displayName = EDITORS.find((e) => e.cli === editorCli)?.name ?? editorCli;
75
+ options.stdout(`Saved editor "${displayName}" to global config\n`);
76
+ }
77
+ // Build open args.
78
+ const editorDef = EDITORS.find((e) => e.cli === editorCli);
79
+ let openTarget = targetPath;
80
+ if (options.workspace) {
81
+ if (editorDef?.supportsWorkspace) {
82
+ openTarget = await ensureWorkspaceFile(targetPath, repository.repoName);
83
+ }
84
+ else {
85
+ const displayName = editorDef?.name ?? editorCli;
86
+ options.stderr(`gji open: --workspace is not supported for ${displayName}, ignoring\n`);
87
+ }
88
+ }
89
+ const args = [];
90
+ if (editorDef?.newWindowFlag) {
91
+ args.push(editorDef.newWindowFlag);
92
+ }
93
+ args.push(openTarget);
94
+ try {
95
+ await spawnEditor(editorCli, args);
96
+ }
97
+ catch (error) {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ options.stderr(`gji open: failed to launch editor: ${message}\n`);
100
+ return 1;
101
+ }
102
+ const displayName = editorDef?.name ?? editorCli;
103
+ options.stdout(`Opened ${targetPath} in ${displayName}\n`);
104
+ return 0;
105
+ };
106
+ }
107
+ export const runOpenCommand = createOpenCommand();
108
+ async function detectInstalledEditors() {
109
+ const results = await Promise.all(EDITORS.map(async (editor) => ({ editor, available: await isCommandAvailable(editor.cli) })));
110
+ return results.filter((r) => r.available).map((r) => r.editor);
111
+ }
112
+ async function isCommandAvailable(command) {
113
+ try {
114
+ await execFileAsync('which', [command]);
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ async function defaultPromptForWorktree(worktrees) {
122
+ const choice = await select({
123
+ message: 'Choose a worktree to open',
124
+ options: worktrees.map((w) => ({
125
+ value: w.path,
126
+ label: w.branch ?? '(detached)',
127
+ hint: w.isCurrent ? `${w.path} (current)` : w.path,
128
+ })),
129
+ });
130
+ if (isCancel(choice))
131
+ return null;
132
+ return choice;
133
+ }
134
+ async function defaultPromptForEditor(editors) {
135
+ const choice = await select({
136
+ message: 'Choose an editor',
137
+ options: editors.map((e) => ({ value: e.cli, label: e.name })),
138
+ });
139
+ if (isCancel(choice))
140
+ return null;
141
+ return choice;
142
+ }
143
+ async function ensureWorkspaceFile(worktreePath, repoName) {
144
+ const workspacePath = join(worktreePath, `${repoName}.code-workspace`);
145
+ try {
146
+ await access(workspacePath);
147
+ return workspacePath;
148
+ }
149
+ catch {
150
+ // File doesn't exist yet — create it.
151
+ }
152
+ const workspace = { folders: [{ path: '.' }], settings: {} };
153
+ await writeFile(workspacePath, `${JSON.stringify(workspace, null, 2)}\n`, 'utf8');
154
+ return workspacePath;
155
+ }
package/dist/pr.js CHANGED
@@ -6,6 +6,7 @@ import { loadEffectiveConfig, resolveConfigString } from './config.js';
6
6
  import { syncFiles } from './file-sync.js';
7
7
  import { pathExists, promptForPathConflict } from './conflict.js';
8
8
  import { extractHooks, runHook } from './hooks.js';
9
+ import { appendHistory } from './history.js';
9
10
  import { isHeadless } from './headless.js';
10
11
  import { maybeRunInstallPrompt } from './install-prompt.js';
11
12
  import { detectRepository, resolveWorktreePath } from './repo.js';
@@ -58,6 +59,7 @@ export function createPrCommand(dependencies = {}) {
58
59
  }
59
60
  const choice = await prompt(worktreePath);
60
61
  if (choice === 'reuse') {
62
+ appendHistory(worktreePath, branchName).catch(() => undefined);
61
63
  await writeOutput(worktreePath, options.stdout);
62
64
  return 0;
63
65
  }
@@ -112,6 +114,7 @@ export function createPrCommand(dependencies = {}) {
112
114
  options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath }, null, 2)}\n`);
113
115
  }
114
116
  else {
117
+ await appendHistory(worktreePath, branchName);
115
118
  await writeOutput(worktreePath, options.stdout);
116
119
  }
117
120
  return 0;
@@ -0,0 +1,8 @@
1
+ export interface RepoRegistryEntry {
2
+ lastUsed: number;
3
+ name: string;
4
+ path: string;
5
+ }
6
+ export declare function REGISTRY_FILE_PATH(home?: string): string;
7
+ export declare function loadRegistry(home?: string): Promise<RepoRegistryEntry[]>;
8
+ export declare function registerRepo(repoPath: string, home?: string): Promise<void>;