@solaqua/gji 0.2.4 → 0.3.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 +2 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +79 -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/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/package.json +1 -1
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
|
}
|
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,29 @@
|
|
|
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
|
+
'skipInstallPrompt',
|
|
12
|
+
'syncDefaultBranch',
|
|
13
|
+
'syncFiles',
|
|
14
|
+
'syncRemote',
|
|
15
|
+
'worktreePath',
|
|
16
|
+
]);
|
|
17
|
+
const KNOWN_GLOBAL_CONFIG_KEYS = new Set([
|
|
18
|
+
...KNOWN_CONFIG_KEYS,
|
|
19
|
+
'repos',
|
|
20
|
+
]);
|
|
7
21
|
export const DEFAULT_CONFIG = Object.freeze({});
|
|
8
22
|
export async function loadConfig(root) {
|
|
9
23
|
const path = join(root, CONFIG_FILE_NAME);
|
|
10
24
|
return loadConfigFile(path);
|
|
11
25
|
}
|
|
12
|
-
export async function loadEffectiveConfig(root, home = homedir()) {
|
|
26
|
+
export async function loadEffectiveConfig(root, home = homedir(), onWarning) {
|
|
13
27
|
const [globalConfig, localConfig] = await Promise.all([
|
|
14
28
|
loadGlobalConfig(home),
|
|
15
29
|
loadConfig(root),
|
|
@@ -23,8 +37,28 @@ export async function loadEffectiveConfig(root, home = homedir()) {
|
|
|
23
37
|
// Strip the internal `repos` registry from the global base before merging.
|
|
24
38
|
const globalBase = { ...globalConfig.config };
|
|
25
39
|
delete globalBase.repos;
|
|
40
|
+
if (onWarning) {
|
|
41
|
+
if (globalConfig.exists) {
|
|
42
|
+
warnUnknownKeys(globalBase, globalConfig.path, KNOWN_GLOBAL_CONFIG_KEYS, onWarning);
|
|
43
|
+
if (Object.keys(perRepoConfig).length > 0) {
|
|
44
|
+
warnUnknownKeys(perRepoConfig, globalConfig.path, KNOWN_CONFIG_KEYS, onWarning);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (localConfig.exists) {
|
|
48
|
+
warnUnknownKeys(localConfig.config, localConfig.path, KNOWN_CONFIG_KEYS, onWarning);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
26
51
|
// Precedence (lowest → highest): global base → per-repo global → local.
|
|
27
52
|
const merged = mergeConfig(globalBase, perRepoConfig, localConfig.config);
|
|
53
|
+
// Warn about relative worktreePath: it must be absolute or tilde-prefixed.
|
|
54
|
+
const worktreePathValue = merged.worktreePath;
|
|
55
|
+
if (onWarning &&
|
|
56
|
+
typeof worktreePathValue === 'string' &&
|
|
57
|
+
worktreePathValue.length > 0 &&
|
|
58
|
+
!worktreePathValue.startsWith('/') &&
|
|
59
|
+
!worktreePathValue.startsWith('~')) {
|
|
60
|
+
onWarning(`gji: "worktreePath" must be an absolute path or start with ~, got "${worktreePathValue}" — using default\n`);
|
|
61
|
+
}
|
|
28
62
|
// Hooks are spread across all three layers so that different hook keys from
|
|
29
63
|
// different layers both apply (e.g. global afterEnter + local afterCreate).
|
|
30
64
|
// Within each key the higher-precedence layer wins (same spread order).
|
|
@@ -87,6 +121,10 @@ export async function updateGlobalRepoConfigKey(repoRoot, key, value, home = hom
|
|
|
87
121
|
return nextConfig;
|
|
88
122
|
}
|
|
89
123
|
export function GLOBAL_CONFIG_FILE_PATH(home = homedir()) {
|
|
124
|
+
const configDir = process.env.GJI_CONFIG_DIR;
|
|
125
|
+
if (configDir) {
|
|
126
|
+
return join(resolve(configDir), GLOBAL_CONFIG_NAME);
|
|
127
|
+
}
|
|
90
128
|
return join(home, GLOBAL_CONFIG_DIRECTORY, GLOBAL_CONFIG_NAME);
|
|
91
129
|
}
|
|
92
130
|
export function parseConfigValue(value) {
|
|
@@ -97,6 +135,10 @@ export function parseConfigValue(value) {
|
|
|
97
135
|
return value;
|
|
98
136
|
}
|
|
99
137
|
}
|
|
138
|
+
export function resolveConfigString(config, key) {
|
|
139
|
+
const value = config[key];
|
|
140
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
141
|
+
}
|
|
100
142
|
async function loadConfigFile(path) {
|
|
101
143
|
try {
|
|
102
144
|
const rawConfig = await readFile(path, 'utf8');
|
|
@@ -148,3 +190,38 @@ function isMissingFileError(error) {
|
|
|
148
190
|
'code' in error &&
|
|
149
191
|
error.code === 'ENOENT');
|
|
150
192
|
}
|
|
193
|
+
function warnUnknownKeys(config, filePath, knownKeys, onWarning) {
|
|
194
|
+
for (const key of Object.keys(config)) {
|
|
195
|
+
if (!knownKeys.has(key)) {
|
|
196
|
+
const suggestion = closestKey(key, knownKeys);
|
|
197
|
+
const hint = suggestion ? ` (did you mean "${suggestion}"?)` : '';
|
|
198
|
+
onWarning(`gji: unknown config key "${key}" in ${filePath}${hint}\n`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function closestKey(unknown, knownKeys) {
|
|
203
|
+
let best = null;
|
|
204
|
+
let bestDist = Infinity;
|
|
205
|
+
for (const key of knownKeys) {
|
|
206
|
+
const dist = levenshtein(unknown, key);
|
|
207
|
+
if (dist < bestDist) {
|
|
208
|
+
bestDist = dist;
|
|
209
|
+
best = key;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return bestDist <= Math.max(2, Math.floor(unknown.length / 2)) ? best : null;
|
|
213
|
+
}
|
|
214
|
+
function levenshtein(a, b) {
|
|
215
|
+
const m = a.length;
|
|
216
|
+
const n = b.length;
|
|
217
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
218
|
+
for (let i = 1; i <= m; i++) {
|
|
219
|
+
for (let j = 1; j <= n; j++) {
|
|
220
|
+
dp[i][j] =
|
|
221
|
+
a[i - 1] === b[j - 1]
|
|
222
|
+
? dp[i - 1][j - 1]
|
|
223
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return dp[m][n];
|
|
227
|
+
}
|
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/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);
|