@solaqua/gji 0.2.3 → 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 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
- hint: worktree.path,
153
- label: worktree.branch ?? '(detached)',
154
- value: worktree.path,
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
@@ -2,6 +2,7 @@ export interface WorktreeHealth {
2
2
  ahead: number;
3
3
  behind: number;
4
4
  hasUpstream: boolean;
5
+ upstreamGone: boolean;
5
6
  status: 'clean' | 'dirty';
6
7
  }
7
8
  export declare function runGit(cwd: string, args: string[]): Promise<string>;
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,9 +1,10 @@
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
- import { detectRepository, listWorktrees } from './repo.js';
7
+ import { detectRepository, listWorktrees, sortByCurrentFirst } from './repo.js';
7
8
  import { writeShellOutput } from './shell-handoff.js';
8
9
  const GO_OUTPUT_FILE_ENV = 'GJI_GO_OUTPUT_FILE';
9
10
  export function createGoCommand(dependencies = {}) {
@@ -17,7 +18,7 @@ export function createGoCommand(dependencies = {}) {
17
18
  options.stderr('gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n');
18
19
  return 1;
19
20
  }
20
- const prompted = options.branch ? null : await prompt(worktrees);
21
+ const prompted = options.branch ? null : await prompt(sortByCurrentFirst(worktrees));
21
22
  const resolvedPath = options.branch
22
23
  ? worktrees.find((entry) => entry.branch === options.branch)?.path
23
24
  : prompted ?? undefined;
@@ -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: worktree.path,
48
- label: worktree.branch ?? '(detached)',
49
- hint: worktree.path,
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/ls.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { listWorktrees } from './repo.js';
2
2
  import { comparePaths } from './paths.js';
3
3
  export async function runLsCommand(options) {
4
- const worktrees = sortWorktreesByPath(await listWorktrees(options.cwd));
4
+ const worktrees = sortWorktrees(await listWorktrees(options.cwd));
5
5
  if (options.json) {
6
6
  options.stdout(`${JSON.stringify(worktrees, null, 2)}\n`);
7
7
  return 0;
@@ -12,15 +12,22 @@ export async function runLsCommand(options) {
12
12
  export function formatWorktreeTable(worktrees) {
13
13
  const rows = worktrees.map((worktree) => ({
14
14
  branch: worktree.branch ?? '(detached)',
15
+ isCurrent: worktree.isCurrent,
15
16
  path: worktree.path,
16
17
  }));
17
18
  const branchWidth = Math.max('BRANCH'.length, ...rows.map((row) => row.branch.length));
18
- const lines = ['BRANCH'.padEnd(branchWidth, ' ') + ' PATH'];
19
+ const lines = [' ' + 'BRANCH'.padEnd(branchWidth, ' ') + ' PATH'];
19
20
  for (const row of rows) {
20
- lines.push(`${row.branch.padEnd(branchWidth, ' ')} ${row.path}`);
21
+ lines.push(`${row.isCurrent ? '*' : ' '} ${row.branch.padEnd(branchWidth, ' ')} ${row.path}`);
21
22
  }
22
23
  return lines.join('\n');
23
24
  }
24
- function sortWorktreesByPath(worktrees) {
25
- return [...worktrees].sort((left, right) => comparePaths(left.path, right.path));
25
+ function sortWorktrees(worktrees) {
26
+ return [...worktrees].sort((left, right) => {
27
+ if (left.isCurrent && !right.isCurrent)
28
+ return -1;
29
+ if (!left.isCurrent && right.isCurrent)
30
+ return 1;
31
+ return comparePaths(left.path, right.path);
32
+ });
26
33
  }
package/dist/new.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface NewCommandOptions {
6
6
  cwd: string;
7
7
  detached?: boolean;
8
8
  dryRun?: boolean;
9
+ force?: boolean;
9
10
  json?: boolean;
10
11
  stderr: (chunk: string) => void;
11
12
  stdout: (chunk: string) => void;
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.json || isHeadless()) {
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
- const choice = await prompt(worktreePath);
66
- if (choice === 'reuse') {
67
- await writeOutput(worktreePath, options.stdout);
68
- return 0;
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) => value.trim().length === 0 ? 'Branch name must not be empty.' : undefined,
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 worktreePath = resolveWorktreePath(repository.repoRoot, branchName);
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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { WorktreeEntry } from './repo.js';
1
+ import { type WorktreeEntry } from './repo.js';
2
2
  export interface RemoveCommandOptions {
3
3
  branch?: string;
4
4
  cwd: string;
package/dist/remove.js CHANGED
@@ -3,6 +3,7 @@ import { confirm, isCancel, select } from '@clack/prompts';
3
3
  import { loadEffectiveConfig } from './config.js';
4
4
  import { extractHooks, runHook } from './hooks.js';
5
5
  import { isHeadless } from './headless.js';
6
+ import { sortByCurrentFirst } from './repo.js';
6
7
  import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
7
8
  import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree } from './worktree-prompts.js';
8
9
  import { writeShellOutput } from './shell-handoff.js';
@@ -28,7 +29,7 @@ export function createRemoveCommand(dependencies = {}) {
28
29
  }
29
30
  return 1;
30
31
  }
31
- const selection = options.branch ?? (await promptForWorktree(linkedWorktrees));
32
+ const selection = options.branch ?? (await promptForWorktree(sortByCurrentFirst(linkedWorktrees)));
32
33
  if (!selection) {
33
34
  options.stderr('Aborted\n');
34
35
  return 1;
@@ -62,7 +63,7 @@ export function createRemoveCommand(dependencies = {}) {
62
63
  }
63
64
  return 0;
64
65
  }
65
- const config = await loadEffectiveConfig(repository.repoRoot);
66
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
66
67
  const hooks = extractHooks(config);
67
68
  await runHook(hooks.beforeRemove, worktree.path, { branch: worktree.branch ?? undefined, path: worktree.path, repo: basename(repository.repoRoot) }, options.stderr);
68
69
  try {
@@ -119,7 +120,7 @@ async function defaultPromptForWorktree(worktrees) {
119
120
  const choice = await select({
120
121
  message: 'Choose a worktree to finish',
121
122
  options: worktrees.map((worktree) => ({
122
- hint: worktree.path,
123
+ hint: worktree.isCurrent ? `${worktree.path} (current)` : worktree.path,
123
124
  label: worktree.branch ?? '(detached)',
124
125
  value: worktree.path,
125
126
  })),
package/dist/repo.d.ts CHANGED
@@ -7,8 +7,11 @@ export interface RepositoryContext {
7
7
  }
8
8
  export interface WorktreeEntry {
9
9
  branch: string | null;
10
+ isCurrent: boolean;
10
11
  path: string;
11
12
  }
12
13
  export declare function detectRepository(cwd: string): Promise<RepositoryContext>;
13
- 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;
14
16
  export declare function listWorktrees(cwd: string): Promise<WorktreeEntry[]>;
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,20 +24,78 @@ 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
- return join(dirname(repoRoot), 'worktrees', basename(repoRoot), ...segments);
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
- const output = await runGit(cwd, ['worktree', 'list', '--porcelain']);
75
+ const [output, currentRoot] = await Promise.all([
76
+ runGit(cwd, ['worktree', 'list', '--porcelain']),
77
+ runGit(cwd, ['rev-parse', '--show-toplevel']),
78
+ ]);
30
79
  const entries = output.split('\n\n').filter(Boolean);
31
80
  return entries.map((entry) => {
32
81
  const path = findPorcelainValue(entry, 'worktree');
33
82
  const branchRef = findOptionalPorcelainValue(entry, 'branch');
34
83
  return {
35
84
  branch: branchRef ? branchRef.replace('refs/heads/', '') : null,
85
+ isCurrent: path === currentRoot,
36
86
  path,
37
87
  };
38
88
  });
39
89
  }
90
+ export function sortByCurrentFirst(worktrees) {
91
+ return [...worktrees].sort((a, b) => {
92
+ if (a.isCurrent && !b.isCurrent)
93
+ return -1;
94
+ if (!a.isCurrent && b.isCurrent)
95
+ return 1;
96
+ return 0;
97
+ });
98
+ }
40
99
  function findPorcelainValue(block, key) {
41
100
  const value = findOptionalPorcelainValue(block, key);
42
101
  if (!value) {
package/dist/status.d.ts CHANGED
@@ -14,6 +14,8 @@ type UpstreamState = {
14
14
  kind: 'detached';
15
15
  } | {
16
16
  kind: 'no-upstream';
17
+ } | {
18
+ kind: 'stale';
17
19
  } | {
18
20
  kind: 'tracked';
19
21
  ahead: number;
package/dist/status.js CHANGED
@@ -4,7 +4,7 @@ import { comparePaths } from './paths.js';
4
4
  export async function runStatusCommand(options) {
5
5
  const repository = await detectRepository(options.cwd);
6
6
  const worktrees = sortWorktreesByPath(await listWorktrees(options.cwd));
7
- const rows = await Promise.all(worktrees.map(async (worktree) => buildStatusRow(worktree, repository.currentRoot)));
7
+ const rows = await Promise.all(worktrees.map(async (worktree) => buildStatusRow(worktree)));
8
8
  if (options.json) {
9
9
  options.stdout(`${JSON.stringify(formatStatusJson(repository.repoRoot, repository.currentRoot, rows), null, 2)}\n`);
10
10
  return 0;
@@ -35,11 +35,11 @@ export function formatStatusJson(repoRoot, currentRoot, rows) {
35
35
  worktrees: rows,
36
36
  };
37
37
  }
38
- async function buildStatusRow(worktree, currentRoot) {
38
+ async function buildStatusRow(worktree) {
39
39
  const health = await readWorktreeHealth(worktree.path);
40
40
  return {
41
41
  branch: worktree.branch,
42
- current: worktree.path === currentRoot,
42
+ current: worktree.isCurrent,
43
43
  path: worktree.path,
44
44
  status: health.status,
45
45
  upstream: buildUpstreamState(worktree.branch, health),
@@ -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;
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solaqua/gji",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Git worktree CLI for fast context switching.",
5
5
  "license": "MIT",
6
6
  "author": "sjquant",