@solaqua/gji 0.1.0-beta.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -130
- package/dist/clean.d.ts +4 -0
- package/dist/clean.js +90 -7
- package/dist/cli.js +15 -5
- package/dist/config.d.ts +2 -0
- package/dist/config.js +24 -1
- package/dist/file-sync.d.ts +9 -0
- package/dist/file-sync.js +52 -0
- package/dist/go.js +25 -14
- package/dist/headless.d.ts +6 -0
- package/dist/headless.js +8 -0
- package/dist/hooks.d.ts +13 -0
- package/dist/hooks.js +50 -0
- package/dist/init.js +1 -1
- package/dist/install-prompt.d.ts +10 -0
- package/dist/install-prompt.js +99 -0
- package/dist/new.d.ts +3 -1
- package/dist/new.js +52 -3
- package/dist/package-manager.d.ts +5 -0
- package/dist/package-manager.js +108 -0
- package/dist/pr.d.ts +3 -1
- package/dist/pr.js +52 -4
- package/dist/remove.d.ts +4 -0
- package/dist/remove.js +89 -7
- package/dist/worktree-management.d.ts +4 -0
- package/dist/worktree-management.js +19 -2
- package/dist/worktree-prompts.d.ts +2 -0
- package/dist/worktree-prompts.js +19 -0
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -14,11 +14,31 @@ export async function loadEffectiveConfig(root, home = homedir()) {
|
|
|
14
14
|
loadGlobalConfig(home),
|
|
15
15
|
loadConfig(root),
|
|
16
16
|
]);
|
|
17
|
-
|
|
17
|
+
const merged = mergeConfig(globalConfig.config, localConfig.config);
|
|
18
|
+
const globalHooks = isPlainObject(globalConfig.config.hooks) ? globalConfig.config.hooks : {};
|
|
19
|
+
const localHooks = isPlainObject(localConfig.config.hooks) ? localConfig.config.hooks : {};
|
|
20
|
+
if (Object.keys(globalHooks).length > 0 || Object.keys(localHooks).length > 0) {
|
|
21
|
+
merged.hooks = { ...globalHooks, ...localHooks };
|
|
22
|
+
}
|
|
23
|
+
return merged;
|
|
18
24
|
}
|
|
19
25
|
export async function loadGlobalConfig(home = homedir()) {
|
|
20
26
|
return loadConfigFile(GLOBAL_CONFIG_FILE_PATH(home));
|
|
21
27
|
}
|
|
28
|
+
export async function saveLocalConfig(root, config) {
|
|
29
|
+
const path = join(root, CONFIG_FILE_NAME);
|
|
30
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
export async function updateLocalConfigKey(root, key, value) {
|
|
34
|
+
const loaded = await loadConfig(root);
|
|
35
|
+
const nextConfig = {
|
|
36
|
+
...loaded.config,
|
|
37
|
+
[key]: value,
|
|
38
|
+
};
|
|
39
|
+
await saveLocalConfig(root, nextConfig);
|
|
40
|
+
return nextConfig;
|
|
41
|
+
}
|
|
22
42
|
export async function saveGlobalConfig(config, home = homedir()) {
|
|
23
43
|
const path = GLOBAL_CONFIG_FILE_PATH(home);
|
|
24
44
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -79,6 +99,9 @@ function mergeConfig(...values) {
|
|
|
79
99
|
...value,
|
|
80
100
|
}), { ...DEFAULT_CONFIG });
|
|
81
101
|
}
|
|
102
|
+
function isPlainObject(value) {
|
|
103
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
104
|
+
}
|
|
82
105
|
function isMissingFileError(error) {
|
|
83
106
|
return (error instanceof Error &&
|
|
84
107
|
'code' in error &&
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies files matching each pattern (relative to mainRoot) into the equivalent
|
|
3
|
+
* relative path under targetPath, creating parent directories as needed.
|
|
4
|
+
*
|
|
5
|
+
* - Non-destructive: silently skips if the target file already exists.
|
|
6
|
+
* - Silently skips if the source file does not exist.
|
|
7
|
+
* - Rejects patterns that are absolute paths or contain `..` segments.
|
|
8
|
+
*/
|
|
9
|
+
export declare function syncFiles(mainRoot: string, targetPath: string, patterns: string[]): Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { copyFile, mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, normalize } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Copies files matching each pattern (relative to mainRoot) into the equivalent
|
|
5
|
+
* relative path under targetPath, creating parent directories as needed.
|
|
6
|
+
*
|
|
7
|
+
* - Non-destructive: silently skips if the target file already exists.
|
|
8
|
+
* - Silently skips if the source file does not exist.
|
|
9
|
+
* - Rejects patterns that are absolute paths or contain `..` segments.
|
|
10
|
+
*/
|
|
11
|
+
export async function syncFiles(mainRoot, targetPath, patterns) {
|
|
12
|
+
for (const pattern of patterns) {
|
|
13
|
+
if (isAbsolute(pattern)) {
|
|
14
|
+
throw new Error(`syncFiles: pattern must be a relative path, got: ${pattern}`);
|
|
15
|
+
}
|
|
16
|
+
const normalized = normalize(pattern);
|
|
17
|
+
if (normalized.startsWith('..')) {
|
|
18
|
+
throw new Error(`syncFiles: pattern must not contain '..' segments, got: ${pattern}`);
|
|
19
|
+
}
|
|
20
|
+
const sourcePath = join(mainRoot, normalized);
|
|
21
|
+
const destPath = join(targetPath, normalized);
|
|
22
|
+
// Skip silently if source does not exist
|
|
23
|
+
const sourceExists = await fileExists(sourcePath);
|
|
24
|
+
if (!sourceExists) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// Skip silently if target already exists
|
|
28
|
+
const destExists = await fileExists(destPath);
|
|
29
|
+
if (destExists) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
33
|
+
await copyFile(sourcePath, destPath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function fileExists(path) {
|
|
37
|
+
try {
|
|
38
|
+
await stat(path);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (isNotFoundError(error)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function isNotFoundError(error) {
|
|
49
|
+
return (error instanceof Error &&
|
|
50
|
+
'code' in error &&
|
|
51
|
+
error.code === 'ENOENT');
|
|
52
|
+
}
|
package/dist/go.js
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
1
2
|
import { isCancel, select } from '@clack/prompts';
|
|
2
|
-
import {
|
|
3
|
+
import { loadEffectiveConfig } from './config.js';
|
|
4
|
+
import { isHeadless } from './headless.js';
|
|
5
|
+
import { extractHooks, runHook } from './hooks.js';
|
|
6
|
+
import { detectRepository, listWorktrees } from './repo.js';
|
|
3
7
|
import { writeShellOutput } from './shell-handoff.js';
|
|
4
8
|
const GO_OUTPUT_FILE_ENV = 'GJI_GO_OUTPUT_FILE';
|
|
5
9
|
export function createGoCommand(dependencies = {}) {
|
|
6
10
|
const prompt = dependencies.promptForWorktree ?? promptForWorktree;
|
|
7
11
|
return async function runGoCommand(options) {
|
|
8
|
-
const worktrees = await
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
options.stdout(`${worktree.path}\n`);
|
|
16
|
-
return 0;
|
|
12
|
+
const [worktrees, repository] = await Promise.all([
|
|
13
|
+
listWorktrees(options.cwd),
|
|
14
|
+
detectRepository(options.cwd),
|
|
15
|
+
]);
|
|
16
|
+
if (!options.branch && isHeadless()) {
|
|
17
|
+
options.stderr('gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n');
|
|
18
|
+
return 1;
|
|
17
19
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const prompted = options.branch ? null : await prompt(worktrees);
|
|
21
|
+
const resolvedPath = options.branch
|
|
22
|
+
? worktrees.find((entry) => entry.branch === options.branch)?.path
|
|
23
|
+
: prompted ?? undefined;
|
|
24
|
+
if (!resolvedPath) {
|
|
25
|
+
options.stderr(options.branch
|
|
26
|
+
? `No worktree found for branch: ${options.branch}\n`
|
|
27
|
+
: 'Aborted\n');
|
|
21
28
|
return 1;
|
|
22
29
|
}
|
|
23
|
-
|
|
30
|
+
const chosenWorktree = worktrees.find((w) => w.path === resolvedPath);
|
|
31
|
+
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
32
|
+
const hooks = extractHooks(config);
|
|
33
|
+
await runHook(hooks.afterEnter, resolvedPath, { branch: chosenWorktree?.branch ?? undefined, path: resolvedPath, repo: basename(repository.repoRoot) }, options.stderr);
|
|
34
|
+
await writeShellOutput(GO_OUTPUT_FILE_ENV, resolvedPath, options.stdout);
|
|
24
35
|
return 0;
|
|
25
36
|
};
|
|
26
37
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when running in a non-interactive (headless) environment.
|
|
3
|
+
* Set GJI_NO_TUI=1 to disable all interactive prompts.
|
|
4
|
+
* Commands that would otherwise hang waiting for input must fail fast instead.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isHeadless(): boolean;
|
package/dist/headless.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when running in a non-interactive (headless) environment.
|
|
3
|
+
* Set GJI_NO_TUI=1 to disable all interactive prompts.
|
|
4
|
+
* Commands that would otherwise hang waiting for input must fail fast instead.
|
|
5
|
+
*/
|
|
6
|
+
export function isHeadless() {
|
|
7
|
+
return process.env.GJI_NO_TUI === '1';
|
|
8
|
+
}
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface GjiHooks {
|
|
2
|
+
afterCreate?: string;
|
|
3
|
+
afterEnter?: string;
|
|
4
|
+
beforeRemove?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface HookContext {
|
|
7
|
+
branch?: string;
|
|
8
|
+
path: string;
|
|
9
|
+
repo: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function runHook(hookCmd: string | undefined, cwd: string, context: HookContext, stderr: (chunk: string) => void): Promise<void>;
|
|
12
|
+
export declare function interpolate(template: string, context: HookContext): string;
|
|
13
|
+
export declare function extractHooks(config: Record<string, unknown>): GjiHooks;
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export async function runHook(hookCmd, cwd, context, stderr) {
|
|
3
|
+
if (!hookCmd)
|
|
4
|
+
return;
|
|
5
|
+
const interpolated = interpolate(hookCmd, context);
|
|
6
|
+
await new Promise((resolve) => {
|
|
7
|
+
const child = spawn(interpolated, {
|
|
8
|
+
cwd,
|
|
9
|
+
shell: true,
|
|
10
|
+
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
|
+
},
|
|
17
|
+
});
|
|
18
|
+
child.stderr.on('data', (chunk) => {
|
|
19
|
+
stderr(chunk.toString());
|
|
20
|
+
});
|
|
21
|
+
child.on('close', (code) => {
|
|
22
|
+
if (code !== 0) {
|
|
23
|
+
stderr(`gji: hook exited with code ${code}: ${interpolated}\n`);
|
|
24
|
+
}
|
|
25
|
+
resolve();
|
|
26
|
+
});
|
|
27
|
+
child.on('error', (err) => {
|
|
28
|
+
stderr(`gji: hook failed to start: ${err.message}\n`);
|
|
29
|
+
resolve();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function interpolate(template, context) {
|
|
34
|
+
return template
|
|
35
|
+
.replace(/\{\{branch\}\}/g, context.branch ?? '')
|
|
36
|
+
.replace(/\{\{path\}\}/g, context.path)
|
|
37
|
+
.replace(/\{\{repo\}\}/g, context.repo);
|
|
38
|
+
}
|
|
39
|
+
export function extractHooks(config) {
|
|
40
|
+
const raw = config.hooks;
|
|
41
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
const hooks = raw;
|
|
45
|
+
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,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -181,7 +181,7 @@ function renderPosixWrapper(command) {
|
|
|
181
181
|
local target
|
|
182
182
|
local output_file
|
|
183
183
|
output_file="$(mktemp -t ${command.tempPrefix}.XXXXXX)" || return 1
|
|
184
|
-
${command.envVar}="$output_file" command gji ${command.commandName} "$@" || { local
|
|
184
|
+
${command.envVar}="$output_file" command gji ${command.commandName} "$@" || { local exit_code=$?; rm -f "$output_file"; return $exit_code; }
|
|
185
185
|
target="$(cat "$output_file")"
|
|
186
186
|
rm -f "$output_file"
|
|
187
187
|
cd "$target" || return $?
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type GjiConfig } from './config.js';
|
|
2
|
+
import { type PackageManager } from './package-manager.js';
|
|
3
|
+
export type InstallChoice = 'yes' | 'no' | 'always' | 'never';
|
|
4
|
+
export interface InstallPromptDependencies {
|
|
5
|
+
detectInstallPackageManager?: (root: string) => Promise<PackageManager | null>;
|
|
6
|
+
promptForInstallChoice?: (pm: PackageManager) => Promise<InstallChoice | null>;
|
|
7
|
+
runInstallCommand?: (command: string, cwd: string, stderr: (chunk: string) => void) => Promise<void>;
|
|
8
|
+
writeConfigKey?: (root: string, key: string, value: unknown) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function maybeRunInstallPrompt(worktreePath: string, repoRoot: string, config: GjiConfig, stderr: (chunk: string) => void, dependencies?: InstallPromptDependencies, nonInteractive?: boolean): Promise<void>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { isCancel, select } from '@clack/prompts';
|
|
3
|
+
import { loadConfig, updateLocalConfigKey } from './config.js';
|
|
4
|
+
import { isHeadless } from './headless.js';
|
|
5
|
+
import { detectPackageManager } from './package-manager.js';
|
|
6
|
+
export async function maybeRunInstallPrompt(worktreePath, repoRoot, config, stderr, dependencies = {}, nonInteractive = false) {
|
|
7
|
+
// Skip in non-interactive mode — no prompt can be shown.
|
|
8
|
+
if (isHeadless() || nonInteractive) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
// Skip if afterCreate hook is already configured in effective config.
|
|
12
|
+
const hooks = isPlainObject(config.hooks) ? config.hooks : null;
|
|
13
|
+
if (typeof hooks?.afterCreate === 'string' && hooks.afterCreate.length > 0) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Skip if user has permanently opted out of install prompts.
|
|
17
|
+
if (config.skipInstallPrompt === true) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const detect = dependencies.detectInstallPackageManager ?? detectPackageManager;
|
|
21
|
+
const pm = await detect(worktreePath);
|
|
22
|
+
if (!pm) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const prompt = dependencies.promptForInstallChoice ?? defaultPromptForInstallChoice;
|
|
26
|
+
const choice = await prompt(pm);
|
|
27
|
+
if (!choice || choice === 'no') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (choice === 'yes' || choice === 'always') {
|
|
31
|
+
const runner = dependencies.runInstallCommand ?? defaultRunInstallCommand;
|
|
32
|
+
try {
|
|
33
|
+
await runner(pm.installCommand, worktreePath, stderr);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
stderr(`gji: install command failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const writeKey = dependencies.writeConfigKey ?? defaultWriteConfigKey;
|
|
40
|
+
if (choice === 'always') {
|
|
41
|
+
try {
|
|
42
|
+
// Read local config hooks to deep-merge so other hook keys (e.g. afterEnter) are preserved.
|
|
43
|
+
const { config: localConfig } = await loadConfig(repoRoot);
|
|
44
|
+
const existingLocalHooks = isPlainObject(localConfig.hooks) ? localConfig.hooks : {};
|
|
45
|
+
await writeKey(repoRoot, 'hooks', { ...existingLocalHooks, afterCreate: pm.installCommand });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
stderr(`gji: failed to save config: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (choice === 'never') {
|
|
52
|
+
try {
|
|
53
|
+
await writeKey(repoRoot, 'skipInstallPrompt', true);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
stderr(`gji: failed to save config: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function defaultRunInstallCommand(command, cwd, stderr) {
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
const child = spawn(command, { cwd, shell: true, stdio: ['ignore', 'inherit', 'pipe'] });
|
|
63
|
+
child.stderr.on('data', (chunk) => {
|
|
64
|
+
stderr(chunk.toString());
|
|
65
|
+
});
|
|
66
|
+
child.on('close', (code) => {
|
|
67
|
+
if (code !== 0) {
|
|
68
|
+
reject(new Error(`exited with code ${code}`));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
resolve();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
child.on('error', (err) => {
|
|
75
|
+
reject(err);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function defaultWriteConfigKey(root, key, value) {
|
|
80
|
+
await updateLocalConfigKey(root, key, value);
|
|
81
|
+
}
|
|
82
|
+
async function defaultPromptForInstallChoice(pm) {
|
|
83
|
+
const choice = await select({
|
|
84
|
+
message: `Run \`${pm.installCommand}\` in the new worktree?`,
|
|
85
|
+
options: [
|
|
86
|
+
{ value: 'yes', label: 'Yes', hint: 'run once' },
|
|
87
|
+
{ value: 'no', label: 'No', hint: 'skip this time' },
|
|
88
|
+
{ value: 'always', label: 'Always', hint: 'save as afterCreate hook' },
|
|
89
|
+
{ value: 'never', label: 'Never', hint: 'disable this prompt for this repo' },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
if (isCancel(choice)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return choice;
|
|
96
|
+
}
|
|
97
|
+
function isPlainObject(value) {
|
|
98
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
package/dist/new.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { type InstallPromptDependencies } from './install-prompt.js';
|
|
1
2
|
import { type PathConflictChoice } from './conflict.js';
|
|
2
3
|
export type { PathConflictChoice };
|
|
3
4
|
export interface NewCommandOptions {
|
|
4
5
|
branch?: string;
|
|
5
6
|
cwd: string;
|
|
6
7
|
detached?: boolean;
|
|
8
|
+
json?: boolean;
|
|
7
9
|
stderr: (chunk: string) => void;
|
|
8
10
|
stdout: (chunk: string) => void;
|
|
9
11
|
}
|
|
10
|
-
export interface NewCommandDependencies {
|
|
12
|
+
export interface NewCommandDependencies extends InstallPromptDependencies {
|
|
11
13
|
createBranchPlaceholder: () => string;
|
|
12
14
|
promptForBranch: (placeholder: string) => Promise<string | null>;
|
|
13
15
|
promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
|
package/dist/new.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
2
|
+
import { basename, dirname } from 'node:path';
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { isCancel, text } from '@clack/prompts';
|
|
6
6
|
import { loadEffectiveConfig } from './config.js';
|
|
7
|
+
import { syncFiles } from './file-sync.js';
|
|
8
|
+
import { extractHooks, runHook } from './hooks.js';
|
|
9
|
+
import { isHeadless } from './headless.js';
|
|
10
|
+
import { maybeRunInstallPrompt } from './install-prompt.js';
|
|
7
11
|
import { pathExists, promptForPathConflict } from './conflict.js';
|
|
8
12
|
import { detectRepository, resolveWorktreePath } from './repo.js';
|
|
9
13
|
import { writeShellOutput } from './shell-handoff.js';
|
|
@@ -17,11 +21,26 @@ export function createNewCommand(dependencies = {}) {
|
|
|
17
21
|
const repository = await detectRepository(options.cwd);
|
|
18
22
|
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
19
23
|
const usesGeneratedDetachedName = options.detached && options.branch === undefined;
|
|
24
|
+
if (!options.detached && !options.branch && (options.json || isHeadless())) {
|
|
25
|
+
const message = 'branch argument is required';
|
|
26
|
+
if (options.json) {
|
|
27
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
31
|
+
}
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
20
34
|
const rawBranch = options.detached
|
|
21
35
|
? options.branch ?? createBranchPlaceholder()
|
|
22
36
|
: options.branch ?? await promptForBranch(createBranchPlaceholder());
|
|
23
37
|
if (!rawBranch) {
|
|
24
|
-
options.
|
|
38
|
+
if (options.json) {
|
|
39
|
+
options.stderr(`${JSON.stringify({ error: 'Aborted' }, null, 2)}\n`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
options.stderr('Aborted\n');
|
|
43
|
+
}
|
|
25
44
|
return 1;
|
|
26
45
|
}
|
|
27
46
|
const worktreeName = options.detached
|
|
@@ -31,6 +50,16 @@ export function createNewCommand(dependencies = {}) {
|
|
|
31
50
|
? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName)
|
|
32
51
|
: resolveWorktreePath(repository.repoRoot, worktreeName);
|
|
33
52
|
if (!usesGeneratedDetachedName && await pathExists(worktreePath)) {
|
|
53
|
+
if (options.json || isHeadless()) {
|
|
54
|
+
const message = `target worktree path already exists: ${worktreePath}`;
|
|
55
|
+
if (options.json) {
|
|
56
|
+
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
|
|
60
|
+
}
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
34
63
|
const choice = await prompt(worktreePath);
|
|
35
64
|
if (choice === 'reuse') {
|
|
36
65
|
await writeOutput(worktreePath, options.stdout);
|
|
@@ -46,7 +75,27 @@ export function createNewCommand(dependencies = {}) {
|
|
|
46
75
|
? ['worktree', 'add', worktreePath, worktreeName]
|
|
47
76
|
: ['worktree', 'add', '-b', worktreeName, worktreePath];
|
|
48
77
|
await execFileAsync('git', gitArgs, { cwd: repository.repoRoot });
|
|
49
|
-
|
|
78
|
+
// Sync files from main worktree before afterCreate so synced files are available to install scripts.
|
|
79
|
+
const syncPatterns = Array.isArray(config.syncFiles)
|
|
80
|
+
? config.syncFiles.filter((p) => typeof p === 'string')
|
|
81
|
+
: [];
|
|
82
|
+
for (const pattern of syncPatterns) {
|
|
83
|
+
try {
|
|
84
|
+
await syncFiles(repository.repoRoot, worktreePath, [pattern]);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
|
|
91
|
+
const hooks = extractHooks(config);
|
|
92
|
+
await runHook(hooks.afterCreate, worktreePath, { branch: worktreeName, path: worktreePath, repo: basename(repository.repoRoot) }, options.stderr);
|
|
93
|
+
if (options.json) {
|
|
94
|
+
options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath }, null, 2)}\n`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
await writeOutput(worktreePath, options.stdout);
|
|
98
|
+
}
|
|
50
99
|
return 0;
|
|
51
100
|
};
|
|
52
101
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { access, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const ENTRIES = [
|
|
4
|
+
// JavaScript / TypeScript
|
|
5
|
+
{ name: 'pnpm', signals: ['pnpm-lock.yaml'], command: 'pnpm install' },
|
|
6
|
+
{ name: 'yarn', signals: ['yarn.lock'], command: 'yarn install' },
|
|
7
|
+
{ name: 'bun', signals: ['bun.lockb'], command: 'bun install' },
|
|
8
|
+
{ name: 'npm', signals: ['package-lock.json'], command: 'npm install' },
|
|
9
|
+
{ name: 'deno', signals: ['deno.json', 'deno.jsonc'], command: 'deno cache' },
|
|
10
|
+
// Python
|
|
11
|
+
{ name: 'poetry', signals: ['poetry.lock'], command: 'poetry install' },
|
|
12
|
+
{ name: 'uv', signals: ['uv.lock'], command: 'uv sync' },
|
|
13
|
+
{ name: 'pipenv', signals: ['Pipfile.lock'], command: 'pipenv install' },
|
|
14
|
+
{ name: 'pdm', signals: ['pdm.lock'], command: 'pdm install' },
|
|
15
|
+
{ name: 'conda-lock', signals: ['conda-lock.yml'], command: 'conda-lock install' },
|
|
16
|
+
{ name: 'conda', signals: ['environment.yml'], command: 'conda env update --file environment.yml' },
|
|
17
|
+
// R
|
|
18
|
+
{ name: 'renv', signals: ['renv.lock'], command: "Rscript -e 'renv::restore()'" },
|
|
19
|
+
// Rust
|
|
20
|
+
{ name: 'cargo', signals: ['Cargo.lock'], command: 'cargo build' },
|
|
21
|
+
// Go
|
|
22
|
+
{ name: 'go', signals: ['go.sum'], command: 'go mod download' },
|
|
23
|
+
// Ruby
|
|
24
|
+
{ name: 'bundler', signals: ['Gemfile.lock'], command: 'bundle install' },
|
|
25
|
+
// PHP
|
|
26
|
+
{ name: 'composer', signals: ['composer.lock'], command: 'composer install' },
|
|
27
|
+
// Elixir / Erlang
|
|
28
|
+
{ name: 'mix', signals: ['mix.lock'], command: 'mix deps.get' },
|
|
29
|
+
{ name: 'rebar3', signals: ['rebar.lock'], command: 'rebar3 deps' },
|
|
30
|
+
// Dart / Flutter
|
|
31
|
+
{ name: 'dart', signals: ['pubspec.lock'], command: 'dart pub get' },
|
|
32
|
+
// Java / Kotlin / Scala
|
|
33
|
+
{ name: 'maven', signals: ['pom.xml'], command: 'mvn install' },
|
|
34
|
+
{ name: 'gradle', signals: ['gradlew'], command: './gradlew build' },
|
|
35
|
+
{ name: 'gradle', signals: ['build.gradle', 'build.gradle.kts'], command: 'gradle build' },
|
|
36
|
+
{ name: 'sbt', signals: ['build.sbt'], command: 'sbt compile' },
|
|
37
|
+
// .NET (C# / F# / VB)
|
|
38
|
+
{ name: 'dotnet', signals: ['*.sln', '*.csproj', '*.fsproj', '*.vbproj'], command: 'dotnet restore', glob: true },
|
|
39
|
+
// Swift
|
|
40
|
+
{ name: 'swift', signals: ['Package.swift'], command: 'swift package resolve' },
|
|
41
|
+
// Haskell
|
|
42
|
+
{ name: 'stack', signals: ['stack.yaml'], command: 'stack build' },
|
|
43
|
+
{ name: 'cabal', signals: ['cabal.project'], command: 'cabal install --only-dependencies' },
|
|
44
|
+
{ name: 'cabal', signals: ['*.cabal'], command: 'cabal install --only-dependencies', glob: true },
|
|
45
|
+
// Clojure
|
|
46
|
+
{ name: 'clojure', signals: ['deps.edn'], command: 'clojure -P' },
|
|
47
|
+
{ name: 'leiningen', signals: ['project.clj'], command: 'lein deps' },
|
|
48
|
+
// OCaml
|
|
49
|
+
{ name: 'dune', signals: ['dune-project'], command: 'dune build' },
|
|
50
|
+
// Julia
|
|
51
|
+
{ name: 'julia', signals: ['Manifest.toml'], command: "julia --project -e 'using Pkg; Pkg.instantiate()'" },
|
|
52
|
+
// Nim
|
|
53
|
+
{ name: 'nimble', signals: ['*.nimble'], command: 'nimble install', glob: true },
|
|
54
|
+
// Crystal
|
|
55
|
+
{ name: 'shards', signals: ['shard.yml'], command: 'shards install' },
|
|
56
|
+
// Perl
|
|
57
|
+
{ name: 'cpanm', signals: ['cpanfile'], command: 'cpanm --installdeps .' },
|
|
58
|
+
// Zig
|
|
59
|
+
{ name: 'zig', signals: ['build.zig.zon'], command: 'zig build' },
|
|
60
|
+
// C / C++
|
|
61
|
+
{ name: 'vcpkg', signals: ['vcpkg.json'], command: 'vcpkg install' },
|
|
62
|
+
{ name: 'conan', signals: ['conanfile.py', 'conanfile.txt'], command: 'conan install .' },
|
|
63
|
+
// Nix
|
|
64
|
+
{ name: 'nix', signals: ['flake.nix'], command: 'nix develop' },
|
|
65
|
+
{ name: 'nix-shell', signals: ['shell.nix'], command: 'nix-shell' },
|
|
66
|
+
// Terraform / OpenTofu
|
|
67
|
+
{ name: 'terraform', signals: ['terraform.lock.hcl'], command: 'terraform init' },
|
|
68
|
+
];
|
|
69
|
+
export async function detectPackageManager(repoRoot) {
|
|
70
|
+
for (const entry of ENTRIES) {
|
|
71
|
+
const matched = entry.glob
|
|
72
|
+
? await matchesGlob(repoRoot, entry.signals)
|
|
73
|
+
: await matchesExact(repoRoot, entry.signals);
|
|
74
|
+
if (matched) {
|
|
75
|
+
return { name: entry.name, installCommand: entry.command };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
async function matchesExact(repoRoot, signals) {
|
|
81
|
+
for (const signal of signals) {
|
|
82
|
+
try {
|
|
83
|
+
await access(join(repoRoot, signal));
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// file not found, try next signal
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
async function matchesGlob(repoRoot, patterns) {
|
|
93
|
+
let files;
|
|
94
|
+
try {
|
|
95
|
+
files = await readdir(repoRoot);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const regexes = patterns.map(patternToRegex);
|
|
101
|
+
return files.some((file) => regexes.some((re) => re.test(file)));
|
|
102
|
+
}
|
|
103
|
+
function patternToRegex(pattern) {
|
|
104
|
+
const escaped = pattern
|
|
105
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
106
|
+
.replace(/\*/g, '[^/]*');
|
|
107
|
+
return new RegExp(`^${escaped}$`);
|
|
108
|
+
}
|
package/dist/pr.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { type PathConflictChoice } from './conflict.js';
|
|
2
|
+
import { type InstallPromptDependencies } from './install-prompt.js';
|
|
2
3
|
export type { PathConflictChoice };
|
|
3
4
|
export interface PrCommandOptions {
|
|
4
5
|
cwd: string;
|
|
6
|
+
json?: boolean;
|
|
5
7
|
number: string;
|
|
6
8
|
stderr: (chunk: string) => void;
|
|
7
9
|
stdout: (chunk: string) => void;
|
|
8
10
|
}
|
|
9
|
-
export interface PrCommandDependencies {
|
|
11
|
+
export interface PrCommandDependencies extends InstallPromptDependencies {
|
|
10
12
|
promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
|
|
11
13
|
}
|
|
12
14
|
export declare function parsePrInput(input: string): string | null;
|