@solaqua/gji 0.2.4 → 0.4.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 +4 -1
- package/dist/clean.js +11 -5
- package/dist/cli.js +3 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +80 -2
- package/dist/git.d.ts +1 -0
- package/dist/git.js +3 -0
- package/dist/go.d.ts +2 -0
- package/dist/go.js +28 -6
- package/dist/index.js +25 -1
- package/dist/init.d.ts +11 -1
- package/dist/init.js +101 -18
- package/dist/new.d.ts +1 -0
- package/dist/new.js +70 -15
- package/dist/pr.js +5 -3
- package/dist/remove.js +1 -1
- package/dist/repo.d.ts +2 -1
- package/dist/repo.js +48 -2
- package/dist/status.d.ts +2 -0
- package/dist/status.js +6 -0
- package/dist/sync.js +1 -1
- package/dist/trigger-hook.js +1 -1
- package/man/man1/gji-clean.1 +19 -0
- package/man/man1/gji-config.1 +19 -0
- package/man/man1/gji-go.1 +13 -0
- package/man/man1/gji-init.1 +13 -0
- package/man/man1/gji-ls.1 +13 -0
- package/man/man1/gji-new.1 +22 -0
- package/man/man1/gji-pr.1 +16 -0
- package/man/man1/gji-remove.1 +19 -0
- package/man/man1/gji-root.1 +13 -0
- package/man/man1/gji-status.1 +13 -0
- package/man/man1/gji-sync.1 +16 -0
- package/man/man1/gji-trigger-hook.1 +9 -0
- package/man/man1/gji.1 +67 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -73,6 +73,8 @@ Worktrees land at a deterministic path so your editor bookmarks and scripts alwa
|
|
|
73
73
|
../worktrees/<repo>/<branch>
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
Set `worktreePath` in your config to use a different base (e.g. `"~/worktrees"` → `~/worktrees/<branch>`).
|
|
77
|
+
|
|
76
78
|
## Daily workflow
|
|
77
79
|
|
|
78
80
|
```sh
|
|
@@ -132,7 +134,7 @@ path=$(gji root --print)
|
|
|
132
134
|
|
|
133
135
|
| Command | Description |
|
|
134
136
|
|---|---|
|
|
135
|
-
| `gji new [branch] [--detached] [--json]` | create branch + worktree, cd in |
|
|
137
|
+
| `gji new [branch] [--detached] [--json]` | create branch + worktree, cd in (validates branch name against Git rules) |
|
|
136
138
|
| `gji pr <ref> [--json]` | fetch PR ref, create worktree, cd in |
|
|
137
139
|
| `gji go [branch] [--print]` | jump to a worktree |
|
|
138
140
|
| `gji root [--print]` | jump to the main repo root |
|
|
@@ -157,6 +159,7 @@ No setup required. Optional config lives in:
|
|
|
157
159
|
| Key | Description |
|
|
158
160
|
|---|---|
|
|
159
161
|
| `branchPrefix` | prefix added to new branch names (e.g. `"feature/"`) |
|
|
162
|
+
| `worktreePath` | base directory for new worktrees (absolute or `~/…`); overrides the default `../worktrees/<repo>/` layout |
|
|
160
163
|
| `syncRemote` | remote for `gji sync` (default: `origin`) |
|
|
161
164
|
| `syncDefaultBranch` | branch to rebase onto (default: remote `HEAD`) |
|
|
162
165
|
| `syncFiles` | files to copy from main worktree into each new worktree |
|
package/dist/clean.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { confirm, isCancel, multiselect } from '@clack/prompts';
|
|
2
|
+
import { readWorktreeHealth } from './git.js';
|
|
2
3
|
import { isHeadless } from './headless.js';
|
|
3
4
|
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
|
|
4
5
|
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree } from './worktree-prompts.js';
|
|
@@ -146,13 +147,18 @@ function toMessage(error) {
|
|
|
146
147
|
return error instanceof Error ? error.message : String(error);
|
|
147
148
|
}
|
|
148
149
|
async function defaultPromptForWorktrees(worktrees) {
|
|
150
|
+
const healthResults = await Promise.allSettled(worktrees.map((w) => readWorktreeHealth(w.path)));
|
|
149
151
|
const choice = await multiselect({
|
|
150
152
|
message: 'Choose worktrees to clean',
|
|
151
|
-
options: worktrees.map((worktree) =>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
options: worktrees.map((worktree, i) => {
|
|
154
|
+
const health = healthResults[i].status === 'fulfilled' ? healthResults[i].value : null;
|
|
155
|
+
const isStale = health?.upstreamGone === true;
|
|
156
|
+
return {
|
|
157
|
+
hint: isStale ? `${worktree.path} (upstream gone)` : worktree.path,
|
|
158
|
+
label: worktree.branch ?? '(detached)',
|
|
159
|
+
value: worktree.path,
|
|
160
|
+
};
|
|
161
|
+
}),
|
|
156
162
|
required: true,
|
|
157
163
|
});
|
|
158
164
|
return isCancel(choice) ? null : choice;
|
package/dist/cli.js
CHANGED
|
@@ -59,6 +59,7 @@ function registerCommands(program) {
|
|
|
59
59
|
program
|
|
60
60
|
.command('new [branch]')
|
|
61
61
|
.description('create a new branch or detached linked worktree')
|
|
62
|
+
.option('-f, --force', 'remove and recreate the worktree if the target path already exists')
|
|
62
63
|
.option('--detached', 'create a detached worktree without a branch')
|
|
63
64
|
.option('--dry-run', 'show what would be created without executing any git commands or writing files')
|
|
64
65
|
.option('--json', 'emit JSON on success or error instead of human-readable output')
|
|
@@ -140,7 +141,7 @@ function attachCommandActions(program, options) {
|
|
|
140
141
|
program.commands
|
|
141
142
|
.find((command) => command.name() === 'new')
|
|
142
143
|
?.action(async (branch, commandOptions) => {
|
|
143
|
-
const exitCode = await runNewCommand({ ...options, branch, detached: commandOptions.detached, dryRun: commandOptions.dryRun, json: commandOptions.json });
|
|
144
|
+
const exitCode = await runNewCommand({ ...options, branch, detached: commandOptions.detached, dryRun: commandOptions.dryRun, force: commandOptions.force, json: commandOptions.json });
|
|
144
145
|
if (exitCode !== 0) {
|
|
145
146
|
throw commanderExit(exitCode);
|
|
146
147
|
}
|
|
@@ -151,6 +152,7 @@ function attachCommandActions(program, options) {
|
|
|
151
152
|
const exitCode = await runInitCommand({
|
|
152
153
|
cwd: options.cwd,
|
|
153
154
|
shell,
|
|
155
|
+
stderr: options.stderr,
|
|
154
156
|
stdout: options.stdout,
|
|
155
157
|
write: commandOptions.write,
|
|
156
158
|
});
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare const CONFIG_FILE_NAME = ".gji.json";
|
|
2
2
|
export declare const GLOBAL_CONFIG_DIRECTORY = ".config/gji";
|
|
3
3
|
export declare const GLOBAL_CONFIG_NAME = "config.json";
|
|
4
|
+
export declare const KNOWN_CONFIG_KEYS: ReadonlySet<string>;
|
|
4
5
|
export type GjiConfig = Record<string, unknown>;
|
|
5
6
|
export interface LoadedConfig {
|
|
6
7
|
config: GjiConfig;
|
|
@@ -9,7 +10,7 @@ export interface LoadedConfig {
|
|
|
9
10
|
}
|
|
10
11
|
export declare const DEFAULT_CONFIG: GjiConfig;
|
|
11
12
|
export declare function loadConfig(root: string): Promise<LoadedConfig>;
|
|
12
|
-
export declare function loadEffectiveConfig(root: string, home?: string): Promise<GjiConfig>;
|
|
13
|
+
export declare function loadEffectiveConfig(root: string, home?: string, onWarning?: (message: string) => void): Promise<GjiConfig>;
|
|
13
14
|
export declare function loadGlobalConfig(home?: string): Promise<LoadedConfig>;
|
|
14
15
|
export declare function saveLocalConfig(root: string, config: GjiConfig): Promise<string>;
|
|
15
16
|
export declare function updateLocalConfigKey(root: string, key: string, value: unknown): Promise<GjiConfig>;
|
|
@@ -19,3 +20,4 @@ export declare function updateGlobalConfigKey(key: string, value: unknown, home?
|
|
|
19
20
|
export declare function updateGlobalRepoConfigKey(repoRoot: string, key: string, value: unknown, home?: string): Promise<GjiConfig>;
|
|
20
21
|
export declare function GLOBAL_CONFIG_FILE_PATH(home?: string): string;
|
|
21
22
|
export declare function parseConfigValue(value: string): unknown;
|
|
23
|
+
export declare function resolveConfigString(config: GjiConfig, key: string): string | undefined;
|
package/dist/config.js
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
export const CONFIG_FILE_NAME = '.gji.json';
|
|
5
5
|
export const GLOBAL_CONFIG_DIRECTORY = '.config/gji';
|
|
6
6
|
export const GLOBAL_CONFIG_NAME = 'config.json';
|
|
7
|
+
export const KNOWN_CONFIG_KEYS = new Set([
|
|
8
|
+
'branchPrefix',
|
|
9
|
+
'hooks',
|
|
10
|
+
'installSaveTarget',
|
|
11
|
+
'shellIntegration',
|
|
12
|
+
'skipInstallPrompt',
|
|
13
|
+
'syncDefaultBranch',
|
|
14
|
+
'syncFiles',
|
|
15
|
+
'syncRemote',
|
|
16
|
+
'worktreePath',
|
|
17
|
+
]);
|
|
18
|
+
const KNOWN_GLOBAL_CONFIG_KEYS = new Set([
|
|
19
|
+
...KNOWN_CONFIG_KEYS,
|
|
20
|
+
'repos',
|
|
21
|
+
]);
|
|
7
22
|
export const DEFAULT_CONFIG = Object.freeze({});
|
|
8
23
|
export async function loadConfig(root) {
|
|
9
24
|
const path = join(root, CONFIG_FILE_NAME);
|
|
10
25
|
return loadConfigFile(path);
|
|
11
26
|
}
|
|
12
|
-
export async function loadEffectiveConfig(root, home = homedir()) {
|
|
27
|
+
export async function loadEffectiveConfig(root, home = homedir(), onWarning) {
|
|
13
28
|
const [globalConfig, localConfig] = await Promise.all([
|
|
14
29
|
loadGlobalConfig(home),
|
|
15
30
|
loadConfig(root),
|
|
@@ -23,8 +38,28 @@ export async function loadEffectiveConfig(root, home = homedir()) {
|
|
|
23
38
|
// Strip the internal `repos` registry from the global base before merging.
|
|
24
39
|
const globalBase = { ...globalConfig.config };
|
|
25
40
|
delete globalBase.repos;
|
|
41
|
+
if (onWarning) {
|
|
42
|
+
if (globalConfig.exists) {
|
|
43
|
+
warnUnknownKeys(globalBase, globalConfig.path, KNOWN_GLOBAL_CONFIG_KEYS, onWarning);
|
|
44
|
+
if (Object.keys(perRepoConfig).length > 0) {
|
|
45
|
+
warnUnknownKeys(perRepoConfig, globalConfig.path, KNOWN_CONFIG_KEYS, onWarning);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (localConfig.exists) {
|
|
49
|
+
warnUnknownKeys(localConfig.config, localConfig.path, KNOWN_CONFIG_KEYS, onWarning);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
26
52
|
// Precedence (lowest → highest): global base → per-repo global → local.
|
|
27
53
|
const merged = mergeConfig(globalBase, perRepoConfig, localConfig.config);
|
|
54
|
+
// Warn about relative worktreePath: it must be absolute or tilde-prefixed.
|
|
55
|
+
const worktreePathValue = merged.worktreePath;
|
|
56
|
+
if (onWarning &&
|
|
57
|
+
typeof worktreePathValue === 'string' &&
|
|
58
|
+
worktreePathValue.length > 0 &&
|
|
59
|
+
!worktreePathValue.startsWith('/') &&
|
|
60
|
+
!worktreePathValue.startsWith('~')) {
|
|
61
|
+
onWarning(`gji: "worktreePath" must be an absolute path or start with ~, got "${worktreePathValue}" — using default\n`);
|
|
62
|
+
}
|
|
28
63
|
// Hooks are spread across all three layers so that different hook keys from
|
|
29
64
|
// different layers both apply (e.g. global afterEnter + local afterCreate).
|
|
30
65
|
// Within each key the higher-precedence layer wins (same spread order).
|
|
@@ -87,6 +122,10 @@ export async function updateGlobalRepoConfigKey(repoRoot, key, value, home = hom
|
|
|
87
122
|
return nextConfig;
|
|
88
123
|
}
|
|
89
124
|
export function GLOBAL_CONFIG_FILE_PATH(home = homedir()) {
|
|
125
|
+
const configDir = process.env.GJI_CONFIG_DIR;
|
|
126
|
+
if (configDir) {
|
|
127
|
+
return join(resolve(configDir), GLOBAL_CONFIG_NAME);
|
|
128
|
+
}
|
|
90
129
|
return join(home, GLOBAL_CONFIG_DIRECTORY, GLOBAL_CONFIG_NAME);
|
|
91
130
|
}
|
|
92
131
|
export function parseConfigValue(value) {
|
|
@@ -97,6 +136,10 @@ export function parseConfigValue(value) {
|
|
|
97
136
|
return value;
|
|
98
137
|
}
|
|
99
138
|
}
|
|
139
|
+
export function resolveConfigString(config, key) {
|
|
140
|
+
const value = config[key];
|
|
141
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
142
|
+
}
|
|
100
143
|
async function loadConfigFile(path) {
|
|
101
144
|
try {
|
|
102
145
|
const rawConfig = await readFile(path, 'utf8');
|
|
@@ -148,3 +191,38 @@ function isMissingFileError(error) {
|
|
|
148
191
|
'code' in error &&
|
|
149
192
|
error.code === 'ENOENT');
|
|
150
193
|
}
|
|
194
|
+
function warnUnknownKeys(config, filePath, knownKeys, onWarning) {
|
|
195
|
+
for (const key of Object.keys(config)) {
|
|
196
|
+
if (!knownKeys.has(key)) {
|
|
197
|
+
const suggestion = closestKey(key, knownKeys);
|
|
198
|
+
const hint = suggestion ? ` (did you mean "${suggestion}"?)` : '';
|
|
199
|
+
onWarning(`gji: unknown config key "${key}" in ${filePath}${hint}\n`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function closestKey(unknown, knownKeys) {
|
|
204
|
+
let best = null;
|
|
205
|
+
let bestDist = Infinity;
|
|
206
|
+
for (const key of knownKeys) {
|
|
207
|
+
const dist = levenshtein(unknown, key);
|
|
208
|
+
if (dist < bestDist) {
|
|
209
|
+
bestDist = dist;
|
|
210
|
+
best = key;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return bestDist <= Math.max(2, Math.floor(unknown.length / 2)) ? best : null;
|
|
214
|
+
}
|
|
215
|
+
function levenshtein(a, b) {
|
|
216
|
+
const m = a.length;
|
|
217
|
+
const n = b.length;
|
|
218
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
219
|
+
for (let i = 1; i <= m; i++) {
|
|
220
|
+
for (let j = 1; j <= n; j++) {
|
|
221
|
+
dp[i][j] =
|
|
222
|
+
a[i - 1] === b[j - 1]
|
|
223
|
+
? dp[i - 1][j - 1]
|
|
224
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return dp[m][n];
|
|
228
|
+
}
|
package/dist/git.d.ts
CHANGED
package/dist/git.js
CHANGED
|
@@ -23,6 +23,7 @@ function parseWorktreeHealth(output) {
|
|
|
23
23
|
let ahead = 0;
|
|
24
24
|
let behind = 0;
|
|
25
25
|
let hasUpstream = false;
|
|
26
|
+
let hasAb = false;
|
|
26
27
|
let dirty = false;
|
|
27
28
|
for (const line of output.split('\n').filter(Boolean)) {
|
|
28
29
|
if (line.startsWith('# branch.upstream ')) {
|
|
@@ -34,6 +35,7 @@ function parseWorktreeHealth(output) {
|
|
|
34
35
|
if (!match) {
|
|
35
36
|
throw new Error(`Unexpected branch.ab output: '${line}'`);
|
|
36
37
|
}
|
|
38
|
+
hasAb = true;
|
|
37
39
|
ahead = Number(match[1]);
|
|
38
40
|
behind = Number(match[2]);
|
|
39
41
|
continue;
|
|
@@ -46,6 +48,7 @@ function parseWorktreeHealth(output) {
|
|
|
46
48
|
ahead,
|
|
47
49
|
behind,
|
|
48
50
|
hasUpstream,
|
|
51
|
+
upstreamGone: hasUpstream && !hasAb,
|
|
49
52
|
status: dirty ? 'dirty' : 'clean',
|
|
50
53
|
};
|
|
51
54
|
}
|
package/dist/go.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type WorktreeHealth } from './git.js';
|
|
1
2
|
import { type WorktreeEntry } from './repo.js';
|
|
2
3
|
export interface GoCommandOptions {
|
|
3
4
|
branch?: string;
|
|
@@ -11,3 +12,4 @@ export interface GoCommandDependencies {
|
|
|
11
12
|
}
|
|
12
13
|
export declare function createGoCommand(dependencies?: Partial<GoCommandDependencies>): (options: GoCommandOptions) => Promise<number>;
|
|
13
14
|
export declare const runGoCommand: (options: GoCommandOptions) => Promise<number>;
|
|
15
|
+
export declare function formatUpstreamHint(branch: string | null, health: WorktreeHealth): string | null;
|
package/dist/go.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { isCancel, select } from '@clack/prompts';
|
|
3
3
|
import { loadEffectiveConfig } from './config.js';
|
|
4
|
+
import { readWorktreeHealth } from './git.js';
|
|
4
5
|
import { isHeadless } from './headless.js';
|
|
5
6
|
import { extractHooks, runHook } from './hooks.js';
|
|
6
7
|
import { detectRepository, listWorktrees, sortByCurrentFirst } from './repo.js';
|
|
@@ -32,7 +33,7 @@ export function createGoCommand(dependencies = {}) {
|
|
|
32
33
|
return 1;
|
|
33
34
|
}
|
|
34
35
|
const chosenWorktree = worktrees.find((w) => w.path === resolvedPath);
|
|
35
|
-
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
36
|
+
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
36
37
|
const hooks = extractHooks(config);
|
|
37
38
|
await runHook(hooks.afterEnter, resolvedPath, { branch: chosenWorktree?.branch ?? undefined, path: resolvedPath, repo: basename(repository.repoRoot) }, options.stderr);
|
|
38
39
|
await writeShellOutput(GO_OUTPUT_FILE_ENV, resolvedPath, options.stdout);
|
|
@@ -41,16 +42,37 @@ export function createGoCommand(dependencies = {}) {
|
|
|
41
42
|
}
|
|
42
43
|
export const runGoCommand = createGoCommand();
|
|
43
44
|
async function promptForWorktree(worktrees) {
|
|
45
|
+
const healthResults = await Promise.allSettled(worktrees.map((w) => readWorktreeHealth(w.path)));
|
|
44
46
|
const choice = await select({
|
|
45
47
|
message: 'Choose a worktree',
|
|
46
|
-
options: worktrees.map((worktree) =>
|
|
47
|
-
value:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
options: worktrees.map((worktree, i) => {
|
|
49
|
+
const health = healthResults[i].status === 'fulfilled' ? healthResults[i].value : null;
|
|
50
|
+
const pathHint = worktree.isCurrent ? `${worktree.path} (current)` : worktree.path;
|
|
51
|
+
const upstream = health ? formatUpstreamHint(worktree.branch, health) : null;
|
|
52
|
+
return {
|
|
53
|
+
value: worktree.path,
|
|
54
|
+
label: worktree.branch ?? '(detached)',
|
|
55
|
+
hint: upstream ? `${upstream} · ${pathHint}` : pathHint,
|
|
56
|
+
};
|
|
57
|
+
}),
|
|
51
58
|
});
|
|
52
59
|
if (isCancel(choice)) {
|
|
53
60
|
return null;
|
|
54
61
|
}
|
|
55
62
|
return choice;
|
|
56
63
|
}
|
|
64
|
+
export function formatUpstreamHint(branch, health) {
|
|
65
|
+
if (branch === null)
|
|
66
|
+
return null;
|
|
67
|
+
if (!health.hasUpstream)
|
|
68
|
+
return 'no upstream';
|
|
69
|
+
if (health.upstreamGone)
|
|
70
|
+
return 'upstream gone';
|
|
71
|
+
if (health.ahead === 0 && health.behind === 0)
|
|
72
|
+
return 'up to date';
|
|
73
|
+
if (health.ahead === 0)
|
|
74
|
+
return `behind ${health.behind}`;
|
|
75
|
+
if (health.behind === 0)
|
|
76
|
+
return `ahead ${health.ahead}`;
|
|
77
|
+
return `ahead ${health.ahead}, behind ${health.behind}`;
|
|
78
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { loadGlobalConfig } from './config.js';
|
|
2
4
|
import { runCli } from './cli.js';
|
|
3
5
|
async function main() {
|
|
4
6
|
try {
|
|
5
|
-
const
|
|
7
|
+
const argv = process.argv.slice(2);
|
|
8
|
+
// Warn once (until fixed) when shell integration hasn't been set up.
|
|
9
|
+
// Only shown in interactive terminals — suppressed in pipes and after gji init --write.
|
|
10
|
+
const isMetaArg = argv[0] === 'init'
|
|
11
|
+
|| argv[0] === '--version' || argv[0] === '-V'
|
|
12
|
+
|| argv[0] === '--help' || argv[0] === '-h';
|
|
13
|
+
if (process.stderr.isTTY === true && !isMetaArg) {
|
|
14
|
+
await warnIfMissingShellIntegration();
|
|
15
|
+
}
|
|
16
|
+
const result = await runCli(argv, {
|
|
6
17
|
stderr: (chunk) => process.stderr.write(chunk),
|
|
7
18
|
stdout: (chunk) => process.stdout.write(chunk),
|
|
8
19
|
});
|
|
@@ -14,4 +25,17 @@ async function main() {
|
|
|
14
25
|
process.exitCode = 1;
|
|
15
26
|
}
|
|
16
27
|
}
|
|
28
|
+
async function warnIfMissingShellIntegration() {
|
|
29
|
+
try {
|
|
30
|
+
const { config } = await loadGlobalConfig(homedir());
|
|
31
|
+
if (!config.shellIntegration) {
|
|
32
|
+
const shellBin = (process.env.SHELL ?? '').split('/').at(-1);
|
|
33
|
+
const shellArg = shellBin && ['bash', 'zsh', 'fish'].includes(shellBin) ? ` ${shellBin}` : '';
|
|
34
|
+
process.stderr.write(`gji: shell integration not set up — run \`gji init${shellArg} --write\` to enable automatic cd.\n`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// best-effort; never block the command
|
|
39
|
+
}
|
|
40
|
+
}
|
|
17
41
|
void main();
|
package/dist/init.d.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
export type SupportedShell = 'bash' | 'fish' | 'zsh';
|
|
2
2
|
export type InstallSaveTarget = 'local' | 'global';
|
|
3
|
+
export interface SetupWizardResult {
|
|
4
|
+
branchPrefix?: string;
|
|
5
|
+
hooks?: {
|
|
6
|
+
afterCreate?: string;
|
|
7
|
+
afterEnter?: string;
|
|
8
|
+
beforeRemove?: string;
|
|
9
|
+
};
|
|
10
|
+
installSaveTarget: InstallSaveTarget;
|
|
11
|
+
worktreePath?: string;
|
|
12
|
+
}
|
|
3
13
|
export interface InitCommandOptions {
|
|
4
14
|
cwd: string;
|
|
5
15
|
home?: string;
|
|
6
|
-
|
|
16
|
+
promptForSetup?: () => Promise<SetupWizardResult | null>;
|
|
7
17
|
shell?: string;
|
|
8
18
|
stderr?: (chunk: string) => void;
|
|
9
19
|
stdout: (chunk: string) => void;
|
package/dist/init.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
import { isCancel, select } from '@clack/prompts';
|
|
5
|
-
import { loadGlobalConfig, updateGlobalConfigKey } from './config.js';
|
|
4
|
+
import { intro, isCancel, outro, select, text } from '@clack/prompts';
|
|
5
|
+
import { loadConfig, loadGlobalConfig, saveGlobalConfig, saveLocalConfig, updateGlobalConfigKey } from './config.js';
|
|
6
6
|
const START_MARKER = '# >>> gji init >>>';
|
|
7
7
|
const END_MARKER = '# <<< gji init <<<';
|
|
8
8
|
const SHELL_WRAPPED_COMMANDS = [
|
|
@@ -60,19 +60,21 @@ export async function runInitCommand(options) {
|
|
|
60
60
|
const next = upsertShellIntegration(current, script);
|
|
61
61
|
await writeFile(rcPath, next, 'utf8');
|
|
62
62
|
options.stdout(`${rcPath}\n`);
|
|
63
|
-
//
|
|
64
|
-
// Skip if already configured. When using the default interactive prompt, also
|
|
65
|
-
// require a real TTY so we don't block in piped/headless environments.
|
|
63
|
+
// Run the setup wizard on the first-ever init (not on subsequent re-runs).
|
|
66
64
|
const { config: globalConfig } = await loadGlobalConfig(home);
|
|
67
|
-
const
|
|
65
|
+
const alreadyConfigured = 'shellIntegration' in globalConfig || 'installSaveTarget' in globalConfig;
|
|
66
|
+
const hasCustomPrompt = options.promptForSetup !== undefined;
|
|
68
67
|
const canPrompt = hasCustomPrompt || process.stdout.isTTY === true;
|
|
69
|
-
if (!
|
|
70
|
-
const prompt = options.
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
await updateGlobalConfigKey('installSaveTarget',
|
|
68
|
+
if (!alreadyConfigured && canPrompt) {
|
|
69
|
+
const prompt = options.promptForSetup ?? defaultPromptForSetup;
|
|
70
|
+
const result = await prompt();
|
|
71
|
+
if (result) {
|
|
72
|
+
await updateGlobalConfigKey('installSaveTarget', result.installSaveTarget, home);
|
|
73
|
+
await saveWizardConfig(result, options.cwd, home);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
+
// Mark shell integration as installed so the first-run nudge is suppressed.
|
|
77
|
+
await updateGlobalConfigKey('shellIntegration', true, home);
|
|
76
78
|
return 0;
|
|
77
79
|
}
|
|
78
80
|
export function renderShellIntegration(shell) {
|
|
@@ -111,6 +113,32 @@ export function upsertShellIntegration(existingConfig, script) {
|
|
|
111
113
|
}
|
|
112
114
|
return ensureTrailingNewline(`${prefix}\n\n${trimmedScript}`);
|
|
113
115
|
}
|
|
116
|
+
async function saveWizardConfig(result, cwd, home) {
|
|
117
|
+
const values = {};
|
|
118
|
+
if (result.branchPrefix)
|
|
119
|
+
values.branchPrefix = result.branchPrefix;
|
|
120
|
+
if (result.worktreePath)
|
|
121
|
+
values.worktreePath = result.worktreePath;
|
|
122
|
+
const hooks = {};
|
|
123
|
+
if (result.hooks?.afterCreate)
|
|
124
|
+
hooks.afterCreate = result.hooks.afterCreate;
|
|
125
|
+
if (result.hooks?.afterEnter)
|
|
126
|
+
hooks.afterEnter = result.hooks.afterEnter;
|
|
127
|
+
if (result.hooks?.beforeRemove)
|
|
128
|
+
hooks.beforeRemove = result.hooks.beforeRemove;
|
|
129
|
+
if (Object.keys(hooks).length > 0)
|
|
130
|
+
values.hooks = hooks;
|
|
131
|
+
if (Object.keys(values).length === 0)
|
|
132
|
+
return;
|
|
133
|
+
if (result.installSaveTarget === 'local') {
|
|
134
|
+
const loaded = await loadConfig(cwd);
|
|
135
|
+
await saveLocalConfig(cwd, { ...loaded.config, ...values });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const { config: existing } = await loadGlobalConfig(home);
|
|
139
|
+
await saveGlobalConfig({ ...existing, ...values }, home);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
114
142
|
function resolveShell(requestedShell, detectedShell) {
|
|
115
143
|
const requested = normalizeShell(requestedShell);
|
|
116
144
|
if (requested) {
|
|
@@ -211,16 +239,71 @@ function indentBlock(value, spaces) {
|
|
|
211
239
|
.map((line) => line.length === 0 ? '' : `${prefix}${line}`)
|
|
212
240
|
.join('\n');
|
|
213
241
|
}
|
|
214
|
-
async function
|
|
215
|
-
|
|
216
|
-
|
|
242
|
+
async function defaultPromptForSetup() {
|
|
243
|
+
intro('gji setup');
|
|
244
|
+
const installSaveTarget = await select({
|
|
245
|
+
message: 'Where should preferences be saved?',
|
|
217
246
|
options: [
|
|
218
|
-
{ value: '
|
|
219
|
-
{ value: '
|
|
247
|
+
{ value: 'global', label: '~/.config/gji/config.json', hint: 'personal — never committed' },
|
|
248
|
+
{ value: 'local', label: '.gji.json', hint: 'repo — committed with the project' },
|
|
220
249
|
],
|
|
221
250
|
});
|
|
222
|
-
if (isCancel(
|
|
251
|
+
if (isCancel(installSaveTarget)) {
|
|
252
|
+
outro('Setup skipped.');
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const branchPrefix = await text({
|
|
256
|
+
message: 'Default branch prefix?',
|
|
257
|
+
placeholder: 'e.g. feat/ or fix/ — leave blank to skip',
|
|
258
|
+
});
|
|
259
|
+
if (isCancel(branchPrefix)) {
|
|
260
|
+
outro('Setup skipped.');
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const worktreePath = await text({
|
|
264
|
+
message: 'Worktree base path?',
|
|
265
|
+
placeholder: 'leave blank to use the default path',
|
|
266
|
+
});
|
|
267
|
+
if (isCancel(worktreePath)) {
|
|
268
|
+
outro('Setup skipped.');
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const afterCreate = await text({
|
|
272
|
+
message: 'afterCreate hook — run after creating a worktree?',
|
|
273
|
+
placeholder: 'e.g. pnpm install — leave blank to skip',
|
|
274
|
+
});
|
|
275
|
+
if (isCancel(afterCreate)) {
|
|
276
|
+
outro('Setup skipped.');
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const afterEnter = await text({
|
|
280
|
+
message: 'afterEnter hook — run after entering a worktree?',
|
|
281
|
+
placeholder: 'e.g. nvm use — leave blank to skip',
|
|
282
|
+
});
|
|
283
|
+
if (isCancel(afterEnter)) {
|
|
284
|
+
outro('Setup skipped.');
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const beforeRemove = await text({
|
|
288
|
+
message: 'beforeRemove hook — run before removing a worktree?',
|
|
289
|
+
placeholder: 'leave blank to skip',
|
|
290
|
+
});
|
|
291
|
+
if (isCancel(beforeRemove)) {
|
|
292
|
+
outro('Setup skipped.');
|
|
223
293
|
return null;
|
|
224
294
|
}
|
|
225
|
-
|
|
295
|
+
outro('Setup complete!');
|
|
296
|
+
const hooks = {};
|
|
297
|
+
if (afterCreate)
|
|
298
|
+
hooks.afterCreate = afterCreate;
|
|
299
|
+
if (afterEnter)
|
|
300
|
+
hooks.afterEnter = afterEnter;
|
|
301
|
+
if (beforeRemove)
|
|
302
|
+
hooks.beforeRemove = beforeRemove;
|
|
303
|
+
return {
|
|
304
|
+
branchPrefix: branchPrefix || undefined,
|
|
305
|
+
hooks: Object.keys(hooks).length > 0 ? hooks : undefined,
|
|
306
|
+
installSaveTarget,
|
|
307
|
+
worktreePath: worktreePath || undefined,
|
|
308
|
+
};
|
|
226
309
|
}
|
package/dist/new.d.ts
CHANGED
package/dist/new.js
CHANGED
|
@@ -3,13 +3,13 @@ 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
|
-
import { loadEffectiveConfig } from './config.js';
|
|
6
|
+
import { loadEffectiveConfig, resolveConfigString } from './config.js';
|
|
7
7
|
import { syncFiles } from './file-sync.js';
|
|
8
8
|
import { extractHooks, runHook } from './hooks.js';
|
|
9
9
|
import { isHeadless } from './headless.js';
|
|
10
10
|
import { maybeRunInstallPrompt } from './install-prompt.js';
|
|
11
11
|
import { pathExists, promptForPathConflict } from './conflict.js';
|
|
12
|
-
import { detectRepository, resolveWorktreePath } from './repo.js';
|
|
12
|
+
import { detectRepository, resolveWorktreePath, validateBranchName } from './repo.js';
|
|
13
13
|
import { writeShellOutput } from './shell-handoff.js';
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
15
15
|
const NEW_OUTPUT_FILE_ENV = 'GJI_NEW_OUTPUT_FILE';
|
|
@@ -19,7 +19,7 @@ export function createNewCommand(dependencies = {}) {
|
|
|
19
19
|
const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
|
|
20
20
|
return async function runNewCommand(options) {
|
|
21
21
|
const repository = await detectRepository(options.cwd);
|
|
22
|
-
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
22
|
+
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
23
23
|
const usesGeneratedDetachedName = options.detached && options.branch === undefined;
|
|
24
24
|
if (!options.detached && !options.branch && (options.json || isHeadless())) {
|
|
25
25
|
const message = 'branch argument is required';
|
|
@@ -43,14 +43,54 @@ export function createNewCommand(dependencies = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
return 1;
|
|
45
45
|
}
|
|
46
|
+
if (!options.detached) {
|
|
47
|
+
const branchError = validateBranchName(rawBranch);
|
|
48
|
+
if (branchError) {
|
|
49
|
+
if (options.json) {
|
|
50
|
+
options.stderr(`${JSON.stringify({ error: branchError }, null, 2)}\n`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
options.stderr(`gji new: ${branchError}\n`);
|
|
54
|
+
}
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const rawBasePath = resolveConfigString(config, 'worktreePath');
|
|
59
|
+
const configuredBasePath = rawBasePath?.startsWith('/') || rawBasePath?.startsWith('~') ? rawBasePath : undefined;
|
|
46
60
|
const worktreeName = options.detached
|
|
47
61
|
? rawBranch
|
|
48
62
|
: applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
|
|
49
63
|
const worktreePath = usesGeneratedDetachedName
|
|
50
|
-
? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName)
|
|
51
|
-
: resolveWorktreePath(repository.repoRoot, worktreeName);
|
|
64
|
+
? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName, configuredBasePath)
|
|
65
|
+
: resolveWorktreePath(repository.repoRoot, worktreeName, configuredBasePath);
|
|
52
66
|
if (!usesGeneratedDetachedName && await pathExists(worktreePath)) {
|
|
53
|
-
if (options.
|
|
67
|
+
if (options.force) {
|
|
68
|
+
if (!options.dryRun) {
|
|
69
|
+
try {
|
|
70
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repository.repoRoot });
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (!isNotRegisteredWorktreeError(err)) {
|
|
74
|
+
const msg = `could not remove existing worktree at ${worktreePath}: ${toExecMessage(err)}`;
|
|
75
|
+
if (options.json) {
|
|
76
|
+
options.stderr(`${JSON.stringify({ warning: msg }, null, 2)}\n`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
options.stderr(`Warning: ${msg}\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!options.detached) {
|
|
84
|
+
try {
|
|
85
|
+
await execFileAsync('git', ['branch', '-D', worktreeName], { cwd: repository.repoRoot });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Branch may not exist; proceed anyway.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (options.json || isHeadless()) {
|
|
54
94
|
const message = `target worktree path already exists: ${worktreePath}`;
|
|
55
95
|
if (options.json) {
|
|
56
96
|
options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
@@ -62,13 +102,15 @@ export function createNewCommand(dependencies = {}) {
|
|
|
62
102
|
}
|
|
63
103
|
return 1;
|
|
64
104
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
else {
|
|
106
|
+
const choice = await prompt(worktreePath);
|
|
107
|
+
if (choice === 'reuse') {
|
|
108
|
+
await writeOutput(worktreePath, options.stdout);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
|
|
112
|
+
return 1;
|
|
69
113
|
}
|
|
70
|
-
options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
|
|
71
|
-
return 1;
|
|
72
114
|
}
|
|
73
115
|
if (options.dryRun) {
|
|
74
116
|
if (options.json) {
|
|
@@ -162,11 +204,11 @@ function applyConfiguredBranchPrefix(branch, branchPrefix) {
|
|
|
162
204
|
}
|
|
163
205
|
return `${branchPrefix}${branch}`;
|
|
164
206
|
}
|
|
165
|
-
async function resolveUniqueDetachedWorktreePath(repoRoot, baseName) {
|
|
207
|
+
async function resolveUniqueDetachedWorktreePath(repoRoot, baseName, basePath) {
|
|
166
208
|
let attempt = 1;
|
|
167
209
|
while (true) {
|
|
168
210
|
const candidateName = attempt === 1 ? baseName : `${baseName}-${attempt}`;
|
|
169
|
-
const candidatePath = resolveWorktreePath(repoRoot, candidateName);
|
|
211
|
+
const candidatePath = resolveWorktreePath(repoRoot, candidateName, basePath);
|
|
170
212
|
if (!await pathExists(candidatePath)) {
|
|
171
213
|
return candidatePath;
|
|
172
214
|
}
|
|
@@ -178,7 +220,10 @@ async function defaultPromptForBranch(placeholder) {
|
|
|
178
220
|
defaultValue: placeholder,
|
|
179
221
|
message: 'Name the new branch',
|
|
180
222
|
placeholder,
|
|
181
|
-
validate: (value) =>
|
|
223
|
+
validate: (value) => {
|
|
224
|
+
const trimmed = value.trim();
|
|
225
|
+
return validateBranchName(trimmed) ?? undefined;
|
|
226
|
+
},
|
|
182
227
|
});
|
|
183
228
|
if (isCancel(choice)) {
|
|
184
229
|
return null;
|
|
@@ -201,3 +246,13 @@ async function localBranchExists(repoRoot, branchName) {
|
|
|
201
246
|
async function writeOutput(worktreePath, stdout) {
|
|
202
247
|
await writeShellOutput(NEW_OUTPUT_FILE_ENV, worktreePath, stdout);
|
|
203
248
|
}
|
|
249
|
+
function isNotRegisteredWorktreeError(error) {
|
|
250
|
+
const stderr = hasExecStderr(error) ? error.stderr : String(error);
|
|
251
|
+
return stderr.includes('is not a working tree') || stderr.includes('not a linked working tree');
|
|
252
|
+
}
|
|
253
|
+
function hasExecStderr(error) {
|
|
254
|
+
return error instanceof Error && 'stderr' in error && typeof error.stderr === 'string';
|
|
255
|
+
}
|
|
256
|
+
function toExecMessage(error) {
|
|
257
|
+
return hasExecStderr(error) ? error.stderr.trim() : String(error);
|
|
258
|
+
}
|
package/dist/pr.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdir } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, dirname } from 'node:path';
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import { loadEffectiveConfig } from './config.js';
|
|
5
|
+
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';
|
|
@@ -38,10 +38,12 @@ export function createPrCommand(dependencies = {}) {
|
|
|
38
38
|
return 1;
|
|
39
39
|
}
|
|
40
40
|
const repository = await detectRepository(options.cwd);
|
|
41
|
-
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
41
|
+
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
42
42
|
const branchName = `pr/${prNumber}`;
|
|
43
43
|
const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`;
|
|
44
|
-
const
|
|
44
|
+
const rawBasePath = resolveConfigString(config, 'worktreePath');
|
|
45
|
+
const configuredBasePath = rawBasePath?.startsWith('/') || rawBasePath?.startsWith('~') ? rawBasePath : undefined;
|
|
46
|
+
const worktreePath = resolveWorktreePath(repository.repoRoot, branchName, configuredBasePath);
|
|
45
47
|
if (await pathExists(worktreePath)) {
|
|
46
48
|
if (options.json || isHeadless()) {
|
|
47
49
|
const message = `target worktree path already exists: ${worktreePath}`;
|
package/dist/remove.js
CHANGED
|
@@ -63,7 +63,7 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
63
63
|
}
|
|
64
64
|
return 0;
|
|
65
65
|
}
|
|
66
|
-
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
66
|
+
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
67
67
|
const hooks = extractHooks(config);
|
|
68
68
|
await runHook(hooks.beforeRemove, worktree.path, { branch: worktree.branch ?? undefined, path: worktree.path, repo: basename(repository.repoRoot) }, options.stderr);
|
|
69
69
|
try {
|
package/dist/repo.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface WorktreeEntry {
|
|
|
11
11
|
path: string;
|
|
12
12
|
}
|
|
13
13
|
export declare function detectRepository(cwd: string): Promise<RepositoryContext>;
|
|
14
|
-
export declare function resolveWorktreePath(repoRoot: string, branch: string): string;
|
|
14
|
+
export declare function resolveWorktreePath(repoRoot: string, branch: string, basePath?: string): string;
|
|
15
|
+
export declare function validateBranchName(name: string): string | null;
|
|
15
16
|
export declare function listWorktrees(cwd: string): Promise<WorktreeEntry[]>;
|
|
16
17
|
export declare function sortByCurrentFirst(worktrees: WorktreeEntry[]): WorktreeEntry[];
|
package/dist/repo.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
2
3
|
import { runGit } from './git.js';
|
|
3
4
|
export async function detectRepository(cwd) {
|
|
4
5
|
const currentRoot = await runGit(cwd, ['rev-parse', '--show-toplevel']);
|
|
@@ -15,7 +16,7 @@ export async function detectRepository(cwd) {
|
|
|
15
16
|
repoRoot,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
|
-
export function resolveWorktreePath(repoRoot, branch) {
|
|
19
|
+
export function resolveWorktreePath(repoRoot, branch, basePath) {
|
|
19
20
|
const segments = branch.split('/').filter(Boolean);
|
|
20
21
|
if (segments.length === 0) {
|
|
21
22
|
throw new Error('Branch name must not be empty.');
|
|
@@ -23,7 +24,52 @@ export function resolveWorktreePath(repoRoot, branch) {
|
|
|
23
24
|
if (segments.some((segment) => segment === '.' || segment === '..')) {
|
|
24
25
|
throw new Error(`Branch name '${branch}' contains an invalid path segment.`);
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
const base = basePath
|
|
28
|
+
? expandTildeInPath(basePath)
|
|
29
|
+
: join(dirname(repoRoot), 'worktrees', basename(repoRoot));
|
|
30
|
+
return join(base, ...segments);
|
|
31
|
+
}
|
|
32
|
+
export function validateBranchName(name) {
|
|
33
|
+
if (name.length === 0) {
|
|
34
|
+
return 'Branch name must not be empty.';
|
|
35
|
+
}
|
|
36
|
+
if (/[\x00-\x1f\x7f ~^:?*[\\\s]/.test(name)) {
|
|
37
|
+
return `Branch name '${name}' contains an invalid character.`;
|
|
38
|
+
}
|
|
39
|
+
if (name.startsWith('-')) {
|
|
40
|
+
return `Branch name '${name}' must not start with a dash.`;
|
|
41
|
+
}
|
|
42
|
+
if (name.startsWith('/') || name.endsWith('/') || name.includes('//')) {
|
|
43
|
+
return `Branch name '${name}' has invalid slash placement.`;
|
|
44
|
+
}
|
|
45
|
+
if (name.includes('..')) {
|
|
46
|
+
return `Branch name '${name}' must not contain '..'.`;
|
|
47
|
+
}
|
|
48
|
+
if (name.endsWith('.')) {
|
|
49
|
+
return `Branch name '${name}' must not end with '.'.`;
|
|
50
|
+
}
|
|
51
|
+
if (name.includes('@{')) {
|
|
52
|
+
return `Branch name '${name}' must not contain '@{'.`;
|
|
53
|
+
}
|
|
54
|
+
if (name === '@') {
|
|
55
|
+
return "Branch name cannot be '@'.";
|
|
56
|
+
}
|
|
57
|
+
for (const segment of name.split('/')) {
|
|
58
|
+
if (segment.startsWith('.')) {
|
|
59
|
+
return `Branch name '${name}' contains a path component starting with '.'.`;
|
|
60
|
+
}
|
|
61
|
+
if (segment.endsWith('.lock')) {
|
|
62
|
+
return `Branch name '${name}' contains a path component ending with '.lock'.`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function expandTildeInPath(p) {
|
|
68
|
+
if (p === '~')
|
|
69
|
+
return homedir();
|
|
70
|
+
if (p.startsWith('~/'))
|
|
71
|
+
return join(homedir(), p.slice(2));
|
|
72
|
+
return p;
|
|
27
73
|
}
|
|
28
74
|
export async function listWorktrees(cwd) {
|
|
29
75
|
const [output, currentRoot] = await Promise.all([
|
package/dist/status.d.ts
CHANGED
package/dist/status.js
CHANGED
|
@@ -58,6 +58,9 @@ function buildUpstreamState(branch, health) {
|
|
|
58
58
|
if (!health.hasUpstream) {
|
|
59
59
|
return { kind: 'no-upstream' };
|
|
60
60
|
}
|
|
61
|
+
if (health.upstreamGone) {
|
|
62
|
+
return { kind: 'stale' };
|
|
63
|
+
}
|
|
61
64
|
return {
|
|
62
65
|
ahead: health.ahead,
|
|
63
66
|
behind: health.behind,
|
|
@@ -71,6 +74,9 @@ function formatUpstreamState(upstream) {
|
|
|
71
74
|
if (upstream.kind === 'no-upstream') {
|
|
72
75
|
return 'no-upstream';
|
|
73
76
|
}
|
|
77
|
+
if (upstream.kind === 'stale') {
|
|
78
|
+
return 'gone';
|
|
79
|
+
}
|
|
74
80
|
if (upstream.ahead === 0 && upstream.behind === 0) {
|
|
75
81
|
return 'up to date';
|
|
76
82
|
}
|
package/dist/sync.js
CHANGED
|
@@ -4,7 +4,7 @@ import { comparePaths } from './paths.js';
|
|
|
4
4
|
import { detectRepository, listWorktrees } from './repo.js';
|
|
5
5
|
export async function runSyncCommand(options) {
|
|
6
6
|
const repository = await detectRepository(options.cwd);
|
|
7
|
-
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
7
|
+
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
8
8
|
const worktrees = await listWorktrees(options.cwd);
|
|
9
9
|
const remote = resolveConfiguredString(config.syncRemote) ?? 'origin';
|
|
10
10
|
let defaultBranch;
|
package/dist/trigger-hook.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function runTriggerHookCommand(options) {
|
|
|
12
12
|
}
|
|
13
13
|
const hookName = options.hook;
|
|
14
14
|
const repository = await detectRepository(options.cwd);
|
|
15
|
-
const config = await loadEffectiveConfig(repository.repoRoot);
|
|
15
|
+
const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
|
|
16
16
|
const hooks = extractHooks(config);
|
|
17
17
|
// Find the branch for the current worktree (undefined for detached HEAD).
|
|
18
18
|
const worktrees = await listWorktrees(options.cwd);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.TH GJI\-CLEAN 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-clean \- interactively prune linked worktrees
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji clean [\fIoptions\fR] [options]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
interactively prune linked worktrees
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-f, \-\-force
|
|
11
|
+
bypass prompts, force\-remove dirty worktrees, and force\-delete unmerged branches
|
|
12
|
+
.TP
|
|
13
|
+
.B \-\-dry\-run
|
|
14
|
+
show what would be deleted without removing anything
|
|
15
|
+
.TP
|
|
16
|
+
.B \-\-json
|
|
17
|
+
emit JSON on success or error instead of human\-readable output
|
|
18
|
+
.SH "SEE ALSO"
|
|
19
|
+
.BR gji (1)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.TH GJI\-CONFIG 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-config \- manage global config defaults
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji config [options] [command]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
manage global config defaults
|
|
8
|
+
.SH SUBCOMMANDS
|
|
9
|
+
.TP
|
|
10
|
+
.B get
|
|
11
|
+
print the global config or a single key
|
|
12
|
+
.TP
|
|
13
|
+
.B set
|
|
14
|
+
set a global config value
|
|
15
|
+
.TP
|
|
16
|
+
.B unset
|
|
17
|
+
remove a global config value
|
|
18
|
+
.SH "SEE ALSO"
|
|
19
|
+
.BR gji (1)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.TH GJI\-GO 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-go \- print or select a worktree path
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji go [\fIoptions\fR] [options] [branch]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
print or select a worktree path
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-\-print
|
|
11
|
+
print the resolved worktree path explicitly
|
|
12
|
+
.SH "SEE ALSO"
|
|
13
|
+
.BR gji (1)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.TH GJI\-INIT 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-init \- print or install shell integration
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji init [\fIoptions\fR] [options] [shell]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
print or install shell integration
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-\-write
|
|
11
|
+
write the integration to the shell config file
|
|
12
|
+
.SH "SEE ALSO"
|
|
13
|
+
.BR gji (1)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.TH GJI\-LS 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-ls \- list active worktrees
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji ls [\fIoptions\fR] [options]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
list active worktrees
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-\-json
|
|
11
|
+
print active worktrees as JSON
|
|
12
|
+
.SH "SEE ALSO"
|
|
13
|
+
.BR gji (1)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.TH GJI\-NEW 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-new \- create a new branch or detached linked worktree
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji new [\fIoptions\fR] [options] [branch]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
create a new branch or detached linked worktree
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-f, \-\-force
|
|
11
|
+
remove and recreate the worktree if the target path already exists
|
|
12
|
+
.TP
|
|
13
|
+
.B \-\-detached
|
|
14
|
+
create a detached worktree without a branch
|
|
15
|
+
.TP
|
|
16
|
+
.B \-\-dry\-run
|
|
17
|
+
show what would be created without executing any git commands or writing files
|
|
18
|
+
.TP
|
|
19
|
+
.B \-\-json
|
|
20
|
+
emit JSON on success or error instead of human\-readable output
|
|
21
|
+
.SH "SEE ALSO"
|
|
22
|
+
.BR gji (1)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.TH GJI\-PR 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-pr \- fetch a pull request by number, #number, or URL into a linked worktree
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji pr [\fIoptions\fR] [options] <ref>
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
fetch a pull request by number, #number, or URL into a linked worktree
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-\-dry\-run
|
|
11
|
+
show what would be created without executing any git commands or writing files
|
|
12
|
+
.TP
|
|
13
|
+
.B \-\-json
|
|
14
|
+
emit JSON on success or error instead of human\-readable output
|
|
15
|
+
.SH "SEE ALSO"
|
|
16
|
+
.BR gji (1)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.TH GJI\-REMOVE 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-remove \- remove a linked worktree and delete its branch when present
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji remove [\fIoptions\fR] [options] [branch]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
remove a linked worktree and delete its branch when present
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-f, \-\-force
|
|
11
|
+
bypass prompts, force\-remove a dirty worktree, and force\-delete an unmerged branch
|
|
12
|
+
.TP
|
|
13
|
+
.B \-\-dry\-run
|
|
14
|
+
show what would be deleted without removing anything
|
|
15
|
+
.TP
|
|
16
|
+
.B \-\-json
|
|
17
|
+
emit JSON on success or error instead of human\-readable output
|
|
18
|
+
.SH "SEE ALSO"
|
|
19
|
+
.BR gji (1)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.TH GJI\-ROOT 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-root \- print the main repository root path
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji root [\fIoptions\fR] [options]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
print the main repository root path
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-\-print
|
|
11
|
+
print the resolved repository root path explicitly
|
|
12
|
+
.SH "SEE ALSO"
|
|
13
|
+
.BR gji (1)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.TH GJI\-STATUS 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-status \- summarize repository and worktree health
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji status [\fIoptions\fR] [options]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
summarize repository and worktree health
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-\-json
|
|
11
|
+
print repository and worktree health as JSON
|
|
12
|
+
.SH "SEE ALSO"
|
|
13
|
+
.BR gji (1)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.TH GJI\-SYNC 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-sync \- fetch and update one or all worktrees
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji sync [\fIoptions\fR] [options]
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
fetch and update one or all worktrees
|
|
8
|
+
.SH OPTIONS
|
|
9
|
+
.TP
|
|
10
|
+
.B \-\-all
|
|
11
|
+
sync every worktree in the repository
|
|
12
|
+
.TP
|
|
13
|
+
.B \-\-json
|
|
14
|
+
emit JSON on success or error instead of human\-readable output
|
|
15
|
+
.SH "SEE ALSO"
|
|
16
|
+
.BR gji (1)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
.TH GJI\-TRIGGER\-HOOK 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji\-trigger\-hook \- run a named hook (afterCreate, afterEnter, beforeRemove) in the current worktree
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji trigger\-hook [options] <hook>
|
|
6
|
+
.SH DESCRIPTION
|
|
7
|
+
run a named hook (afterCreate, afterEnter, beforeRemove) in the current worktree
|
|
8
|
+
.SH "SEE ALSO"
|
|
9
|
+
.BR gji (1)
|
package/man/man1/gji.1
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
.TH GJI 1 "April 2026" "gji 0.4.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
gji \- Context switching without the mess.
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B gji
|
|
6
|
+
[\fIoptions\fR] <\fIcommand\fR> [\fIargs\fR]
|
|
7
|
+
.SH DESCRIPTION
|
|
8
|
+
Context switching without the mess.
|
|
9
|
+
.PP
|
|
10
|
+
Each branch lives in its own worktree with its own node_modules and terminal,
|
|
11
|
+
eliminating the need for \fBgit stash\fR when switching contexts.
|
|
12
|
+
.SH COMMANDS
|
|
13
|
+
.TP
|
|
14
|
+
.B new [options] [branch]
|
|
15
|
+
create a new branch or detached linked worktree
|
|
16
|
+
.TP
|
|
17
|
+
.B init [options] [shell]
|
|
18
|
+
print or install shell integration
|
|
19
|
+
.TP
|
|
20
|
+
.B pr [options] <ref>
|
|
21
|
+
fetch a pull request by number, #number, or URL into a linked worktree
|
|
22
|
+
.TP
|
|
23
|
+
.B go [options] [branch]
|
|
24
|
+
print or select a worktree path
|
|
25
|
+
.TP
|
|
26
|
+
.B root [options]
|
|
27
|
+
print the main repository root path
|
|
28
|
+
.TP
|
|
29
|
+
.B status [options]
|
|
30
|
+
summarize repository and worktree health
|
|
31
|
+
.TP
|
|
32
|
+
.B sync [options]
|
|
33
|
+
fetch and update one or all worktrees
|
|
34
|
+
.TP
|
|
35
|
+
.B ls [options]
|
|
36
|
+
list active worktrees
|
|
37
|
+
.TP
|
|
38
|
+
.B clean [options]
|
|
39
|
+
interactively prune linked worktrees
|
|
40
|
+
.TP
|
|
41
|
+
.B remove [options] [branch]
|
|
42
|
+
remove a linked worktree and delete its branch when present
|
|
43
|
+
.br
|
|
44
|
+
Alias: \fBrm\fR
|
|
45
|
+
.TP
|
|
46
|
+
.B trigger\-hook [options] <hook>
|
|
47
|
+
run a named hook (afterCreate, afterEnter, beforeRemove) in the current worktree
|
|
48
|
+
.TP
|
|
49
|
+
.B config [options] [command]
|
|
50
|
+
manage global config defaults
|
|
51
|
+
.SH OPTIONS
|
|
52
|
+
.TP
|
|
53
|
+
.B \-V, \-\-version
|
|
54
|
+
output the version number
|
|
55
|
+
.SH "SEE ALSO"
|
|
56
|
+
.BR gji\-new (1),
|
|
57
|
+
.BR gji\-init (1),
|
|
58
|
+
.BR gji\-pr (1),
|
|
59
|
+
.BR gji\-go (1),
|
|
60
|
+
.BR gji\-root (1),
|
|
61
|
+
.BR gji\-status (1),
|
|
62
|
+
.BR gji\-sync (1),
|
|
63
|
+
.BR gji\-ls (1),
|
|
64
|
+
.BR gji\-clean (1),
|
|
65
|
+
.BR gji\-remove (1),
|
|
66
|
+
.BR gji\-trigger\-hook (1),
|
|
67
|
+
.BR gji\-config (1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solaqua/gji",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Git worktree CLI for fast context switching.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "sjquant",
|
|
@@ -24,9 +24,13 @@
|
|
|
24
24
|
],
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|
|
27
|
+
"man",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE"
|
|
29
30
|
],
|
|
31
|
+
"man": [
|
|
32
|
+
"man/man1/gji.1"
|
|
33
|
+
],
|
|
30
34
|
"publishConfig": {
|
|
31
35
|
"access": "public"
|
|
32
36
|
},
|
|
@@ -38,7 +42,8 @@
|
|
|
38
42
|
},
|
|
39
43
|
"scripts": {
|
|
40
44
|
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
|
|
41
|
-
"
|
|
45
|
+
"generate-man": "node scripts/generate-man.mjs",
|
|
46
|
+
"prepublishOnly": "pnpm test && pnpm build && pnpm generate-man",
|
|
42
47
|
"test": "vitest run --passWithNoTests",
|
|
43
48
|
"test:watch": "vitest"
|
|
44
49
|
},
|