@solaqua/gji 0.5.0 → 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 +37 -3
- package/dist/cli.js +64 -1
- package/dist/config.js +1 -0
- package/dist/editor.d.ts +8 -0
- package/dist/editor.js +17 -0
- package/dist/gji-bundle.mjs +1306 -760
- package/dist/go.js +22 -4
- package/dist/hooks.d.ts +5 -4
- package/dist/hooks.js +61 -9
- package/dist/init.js +26 -11
- package/dist/install-prompt.js +9 -1
- package/dist/new.d.ts +3 -0
- package/dist/new.js +33 -1
- package/dist/open.d.ts +20 -0
- package/dist/open.js +155 -0
- package/dist/repo-registry.d.ts +8 -0
- package/dist/repo-registry.js +52 -0
- package/dist/warp.d.ts +20 -0
- package/dist/warp.js +196 -0
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +1 -1
- package/man/man1/gji-completion.1 +1 -1
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-go.1 +1 -1
- package/man/man1/gji-history.1 +1 -1
- package/man/man1/gji-init.1 +1 -1
- package/man/man1/gji-ls.1 +1 -1
- package/man/man1/gji-new.1 +7 -1
- package/man/man1/gji-open.1 +19 -0
- package/man/man1/gji-pr.1 +1 -1
- package/man/man1/gji-remove.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-trigger-hook.1 +1 -1
- package/man/man1/gji-warp.1 +19 -0
- package/man/man1/gji.1 +11 -1
- package/package.json +4 -2
package/dist/go.js
CHANGED
|
@@ -7,14 +7,32 @@ import { extractHooks, runHook } from './hooks.js';
|
|
|
7
7
|
import { appendHistory } from './history.js';
|
|
8
8
|
import { detectRepository, listWorktrees, sortByCurrentFirst } from './repo.js';
|
|
9
9
|
import { writeShellOutput } from './shell-handoff.js';
|
|
10
|
+
import { resolveWarpTarget } from './warp.js';
|
|
10
11
|
const GO_OUTPUT_FILE_ENV = 'GJI_GO_OUTPUT_FILE';
|
|
11
12
|
export function createGoCommand(dependencies = {}) {
|
|
12
13
|
const prompt = dependencies.promptForWorktree ?? promptForWorktree;
|
|
13
14
|
return async function runGoCommand(options) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|
|
18
36
|
if (!options.branch && isHeadless()) {
|
|
19
37
|
options.stderr('gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n');
|
|
20
38
|
return 1;
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
export type GjiHookCommand = string | string[];
|
|
1
2
|
export interface GjiHooks {
|
|
2
|
-
afterCreate?:
|
|
3
|
-
afterEnter?:
|
|
4
|
-
beforeRemove?:
|
|
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:
|
|
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:
|
|
47
|
-
afterEnter:
|
|
48
|
-
beforeRemove:
|
|
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,47 +8,54 @@ const START_MARKER = '# >>> gji init >>>';
|
|
|
8
8
|
const END_MARKER = '# <<< gji init <<<';
|
|
9
9
|
const SHELL_WRAPPED_COMMANDS = [
|
|
10
10
|
{
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
+
bypassOptions: ['--print'],
|
|
26
26
|
commandName: 'back',
|
|
27
27
|
envVar: 'GJI_BACK_OUTPUT_FILE',
|
|
28
28
|
names: ['back'],
|
|
29
29
|
tempPrefix: 'gji-back',
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
|
-
|
|
32
|
+
bypassOptions: ['--print'],
|
|
33
33
|
commandName: 'go',
|
|
34
34
|
envVar: 'GJI_GO_OUTPUT_FILE',
|
|
35
|
-
names: ['go'],
|
|
35
|
+
names: ['go', 'jump'],
|
|
36
36
|
tempPrefix: 'gji-go',
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
|
-
|
|
39
|
+
bypassOptions: ['--print'],
|
|
40
40
|
commandName: 'root',
|
|
41
41
|
envVar: 'GJI_ROOT_OUTPUT_FILE',
|
|
42
42
|
names: ['root'],
|
|
43
43
|
tempPrefix: 'gji-root',
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
|
-
|
|
46
|
+
bypassOptions: ['--help'],
|
|
47
47
|
commandName: 'remove',
|
|
48
48
|
envVar: 'GJI_REMOVE_OUTPUT_FILE',
|
|
49
49
|
names: ['remove', 'rm'],
|
|
50
50
|
tempPrefix: 'gji-remove',
|
|
51
51
|
},
|
|
52
|
+
{
|
|
53
|
+
bypassOptions: ['--print', '--json'],
|
|
54
|
+
commandName: 'warp',
|
|
55
|
+
envVar: 'GJI_WARP_OUTPUT_FILE',
|
|
56
|
+
names: ['warp'],
|
|
57
|
+
tempPrefix: 'gji-warp',
|
|
58
|
+
},
|
|
52
59
|
];
|
|
53
60
|
export async function runInitCommand(options) {
|
|
54
61
|
const shell = resolveSupportedShell(options.shell, process.env.SHELL);
|
|
@@ -178,10 +185,17 @@ function isMissingFileError(error) {
|
|
|
178
185
|
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
179
186
|
}
|
|
180
187
|
function renderFishWrapper(command) {
|
|
181
|
-
const
|
|
182
|
-
|
|
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}
|
|
183
197
|
set -e argv[1]
|
|
184
|
-
if test (count $argv) -gt 0; and
|
|
198
|
+
if test (count $argv) -gt 0; and ${bypassCondition}
|
|
185
199
|
command gji ${command.commandName} $argv
|
|
186
200
|
return $status
|
|
187
201
|
end
|
|
@@ -202,9 +216,10 @@ end`;
|
|
|
202
216
|
}
|
|
203
217
|
function renderPosixWrapper(command) {
|
|
204
218
|
const tests = command.names.map((name) => `[ "$1" = "${name}" ]`).join(' || ');
|
|
219
|
+
const bypassTests = command.bypassOptions.map((opt) => `[ "\${1:-}" = "${opt}" ]`).join(' || ');
|
|
205
220
|
return `if ${tests}; then
|
|
206
221
|
shift
|
|
207
|
-
if
|
|
222
|
+
if ${bypassTests}; then
|
|
208
223
|
command gji ${command.commandName} "$@"
|
|
209
224
|
return $?
|
|
210
225
|
fi
|
package/dist/install-prompt.js
CHANGED
|
@@ -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 (
|
|
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,6 +4,7 @@ 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';
|
|
9
10
|
import { appendHistory } from './history.js';
|
|
@@ -18,10 +19,14 @@ export function createNewCommand(dependencies = {}) {
|
|
|
18
19
|
const createBranchPlaceholder = dependencies.createBranchPlaceholder ?? generateBranchPlaceholder;
|
|
19
20
|
const promptForBranch = dependencies.promptForBranch ?? defaultPromptForBranch;
|
|
20
21
|
const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
|
|
22
|
+
const spawnEditor = dependencies.spawnEditor ?? defaultSpawnEditor;
|
|
21
23
|
return async function runNewCommand(options) {
|
|
22
24
|
const repository = await detectRepository(options.cwd);
|
|
23
25
|
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
24
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
|
+
}
|
|
25
30
|
if (!options.detached && !options.branch && (options.json || isHeadless())) {
|
|
26
31
|
const message = 'branch argument is required';
|
|
27
32
|
if (options.json) {
|
|
@@ -119,7 +124,11 @@ export function createNewCommand(dependencies = {}) {
|
|
|
119
124
|
options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath, dryRun: true }, null, 2)}\n`);
|
|
120
125
|
}
|
|
121
126
|
else {
|
|
122
|
-
|
|
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`);
|
|
123
132
|
}
|
|
124
133
|
return 0;
|
|
125
134
|
}
|
|
@@ -152,6 +161,10 @@ export function createNewCommand(dependencies = {}) {
|
|
|
152
161
|
await appendHistory(worktreePath, worktreeName);
|
|
153
162
|
await writeOutput(worktreePath, options.stdout);
|
|
154
163
|
}
|
|
164
|
+
if (options.open) {
|
|
165
|
+
const resolvedEditor = options.editor ?? resolveConfigString(config, 'editor');
|
|
166
|
+
await openWorktree(worktreePath, resolvedEditor, spawnEditor, options.stderr);
|
|
167
|
+
}
|
|
155
168
|
return 0;
|
|
156
169
|
};
|
|
157
170
|
}
|
|
@@ -259,3 +272,22 @@ function hasExecStderr(error) {
|
|
|
259
272
|
function toExecMessage(error) {
|
|
260
273
|
return hasExecStderr(error) ? error.stderr.trim() : String(error);
|
|
261
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
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { GLOBAL_CONFIG_DIRECTORY } from './config.js';
|
|
5
|
+
const REGISTRY_FILE_NAME = 'repos.json';
|
|
6
|
+
const MAX_REGISTRY_ENTRIES = 100;
|
|
7
|
+
export function REGISTRY_FILE_PATH(home = homedir()) {
|
|
8
|
+
const configDir = process.env.GJI_CONFIG_DIR;
|
|
9
|
+
if (configDir) {
|
|
10
|
+
return join(resolve(configDir), REGISTRY_FILE_NAME);
|
|
11
|
+
}
|
|
12
|
+
return join(home, GLOBAL_CONFIG_DIRECTORY, REGISTRY_FILE_NAME);
|
|
13
|
+
}
|
|
14
|
+
export async function loadRegistry(home = homedir()) {
|
|
15
|
+
const path = REGISTRY_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(isRegistryEntry);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function registerRepo(repoPath, home = homedir()) {
|
|
28
|
+
const registryPath = REGISTRY_FILE_PATH(home);
|
|
29
|
+
const existing = await loadRegistry(home);
|
|
30
|
+
// Skip write if this repo is already the most-recently-used entry (common case).
|
|
31
|
+
if (existing.length > 0 && existing[0].path === repoPath)
|
|
32
|
+
return;
|
|
33
|
+
const entry = {
|
|
34
|
+
lastUsed: Date.now(),
|
|
35
|
+
name: basename(repoPath),
|
|
36
|
+
path: repoPath,
|
|
37
|
+
};
|
|
38
|
+
const filtered = existing.filter((e) => e.path !== repoPath);
|
|
39
|
+
const next = [entry, ...filtered].slice(0, MAX_REGISTRY_ENTRIES);
|
|
40
|
+
await mkdir(dirname(registryPath), { recursive: true });
|
|
41
|
+
await writeFile(registryPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
function isRegistryEntry(value) {
|
|
44
|
+
return (typeof value === 'object' &&
|
|
45
|
+
value !== null &&
|
|
46
|
+
'path' in value &&
|
|
47
|
+
typeof value.path === 'string' &&
|
|
48
|
+
'name' in value &&
|
|
49
|
+
typeof value.name === 'string' &&
|
|
50
|
+
'lastUsed' in value &&
|
|
51
|
+
typeof value.lastUsed === 'number');
|
|
52
|
+
}
|
package/dist/warp.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface WarpCommandOptions {
|
|
2
|
+
branch?: string;
|
|
3
|
+
cwd: string;
|
|
4
|
+
json?: boolean;
|
|
5
|
+
newWorktree?: boolean;
|
|
6
|
+
stderr: (chunk: string) => void;
|
|
7
|
+
stdout: (chunk: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function runWarpCommand(options: WarpCommandOptions): Promise<number>;
|
|
10
|
+
export interface WarpTarget {
|
|
11
|
+
branch: string | null;
|
|
12
|
+
path: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function resolveWarpTarget(options: {
|
|
15
|
+
branch?: string;
|
|
16
|
+
commandName?: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
json?: boolean;
|
|
19
|
+
stderr: (chunk: string) => void;
|
|
20
|
+
}): Promise<WarpTarget | null>;
|