@zenobius/pi-worktrees 0.4.0-next.8 → 0.5.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
@@ -15,7 +15,8 @@ When you’re doing multiple feature branches, hotfixes, or experiments, `git wo
15
15
 
16
16
  `pi-worktrees` gives you a guided interface inside Pi:
17
17
 
18
- - Create feature worktrees with consistent branch naming (`feature/<name>`)
18
+ - Create branch-first worktrees (`/worktree create <branch>`) with predictable naming
19
+ - Optionally generate branch names via explicit opt-in (`/worktree create --generate ...`)
19
20
  - List and inspect active worktrees
20
21
  - Remove worktrees safely (with confirmations)
21
22
  - Prune stale worktree references
@@ -59,14 +60,15 @@ pi install npm:@zenobius/pi-worktrees
59
60
 
60
61
  ```text
61
62
  /worktree init
62
- /worktree create auth-refactor
63
+ /worktree create feature/auth-refactor
64
+ /worktree create hotfix/login-timeout --name login-timeout
63
65
  /worktree list
64
66
  ```
65
67
 
66
68
  3. Optional: jump into it from your shell using the printed path:
67
69
 
68
70
  ```text
69
- /worktree cd auth-refactor
71
+ /worktree cd feature-auth-refactor
70
72
  ```
71
73
 
72
74
  ## Quick start
@@ -75,11 +77,12 @@ In Pi:
75
77
 
76
78
  ```text
77
79
  /worktree init
78
- /worktree create auth-refactor
80
+ /worktree create feature/auth-refactor
81
+ /worktree create spike/new-parser --name parser-spike
79
82
  /worktree list
80
83
  /worktree status
81
- /worktree cd auth-refactor
82
- /worktree remove auth-refactor
84
+ /worktree cd feature-auth-refactor
85
+ /worktree remove feature-auth-refactor
83
86
  /worktree prune
84
87
  ```
85
88
 
@@ -114,7 +117,7 @@ This creates a new Zellij tab with Neovim and Pi running in the new worktree pat
114
117
  | `/worktree settings` | Show all current settings |
115
118
  | `/worktree settings <key>` | Get one setting (`worktreeRoot`, `parentDir` alias, `onCreate`) |
116
119
  | `/worktree settings <key> <value>` | Set one setting |
117
- | `/worktree create <feature-name>` | Create a new worktree + branch `feature/<feature-name>` |
120
+ | `/worktree create <branch> [--name <worktree-name>]`<br/>`/worktree create --generate [--name <worktree-name>] <prompt-or-name>` | Create a new worktree from `<branch>` (default mode) or generate one via configured `branchNameGenerator` (opt-in with `--generate`) |
118
121
  | `/worktree list` | List all worktrees (`/worktree ls` alias) |
119
122
  | `/worktree status` | Show current repo/worktree status |
120
123
  | `/worktree cd <name>` | Print matching worktree path |
@@ -133,11 +136,12 @@ Settings live in `~/.pi/agent/pi-worktrees-settings.json`.
133
136
  "worktrees": {
134
137
  "github.com/org/repo": {
135
138
  "worktreeRoot": "~/work/org/repo.worktrees",
136
- "onCreate": ["mise install", "bun install"]
139
+ "onCreate": ["mise install", "bun install"],
137
140
  },
138
141
  "github.com/org/*": {
139
142
  "worktreeRoot": "~/work/org/shared.worktrees",
140
- "onCreate": "mise setup"
143
+ "onCreate": "mise setup",
144
+ "branchNameGenerator": "pi -p \"branch name for $PI_WORKTREE_PROMPT\" --model local/model"
141
145
  }
142
146
  },
143
147
  "matchingStrategy": "fail-on-tie",
@@ -168,6 +172,7 @@ Settings live in `~/.pi/agent/pi-worktrees-settings.json`.
168
172
  | `onCreateCmdDisplayPendingColor` | `string` | `dim` | Pi theme color name for pending/running command lines. |
169
173
  | `onCreateCmdDisplaySuccessColor` | `string` | `success` | Pi theme color name for successful command lines. |
170
174
  | `onCreateCmdDisplayErrorColor` | `string` | `error` | Pi theme color name for failed command lines. |
175
+ | `worktrees[*].branchNameGenerator` | `string` | unset | Optional command used only by `/worktree create --generate ...`. Must print exactly one branch name to stdout. Receives `$PI_WORKTREE_PROMPT` env var and supports `{{prompt}}` / `{prompt}` token replacement. |
171
176
  | `worktree` (legacy) | `WorktreeSettings` | n/a | Legacy fallback shape; migrated automatically. |
172
177
 
173
178
  ### Matching model
@@ -231,6 +236,63 @@ Where new worktrees are created.
231
236
 
232
237
  > Backward compatibility: `parentDir` is still accepted as a deprecated alias for `worktreeRoot`.
233
238
  > The extension will migrate existing `parentDir` values to `worktreeRoot` automatically.
239
+ ### Create command naming contract
240
+
241
+ `/worktree create` is branch-first:
242
+
243
+ - Required first argument is the **branch name** to create.
244
+ - Default worktree folder name is `slugify(branch)`.
245
+ - Optional `--name <worktree-name>` overrides the derived folder name.
246
+
247
+ ### Optional branch generator (safe opt-in)
248
+
249
+ Generator mode is **never automatic**. You must pass `--generate` explicitly:
250
+
251
+ ```text
252
+ /worktree create --generate login-flow
253
+ /worktree create --generate --name ui-login login-flow
254
+ ```
255
+
256
+ Safety behavior:
257
+ - Branch-first remains default source of truth.
258
+ - `branchNameGenerator` is ignored unless `--generate` is present.
259
+ - Generator command runs with a strict 5s timeout.
260
+ - On timeout, non-zero exit, empty stdout, or invalid branch output: command fails and no worktree is created.
261
+ - When a generated branch is used, Pi emits a provenance message before creation.
262
+ Examples:
263
+
264
+ ```text
265
+ /worktree create feature/login
266
+ # branch: feature/login, worktree folder: feature-login
267
+
268
+ /worktree create feature/login --name ui-login
269
+ # branch: feature/login, worktree folder: ui-login
270
+ ```
271
+
272
+ ### Migration from legacy `<feature-name>` usage
273
+
274
+ Old mental model:
275
+
276
+ ```text
277
+ /worktree create login
278
+ # previously implied branch feature/login
279
+ ```
280
+
281
+ Current behavior:
282
+
283
+ ```text
284
+ /worktree create login
285
+ # branch: login, worktree folder: login
286
+ ```
287
+
288
+ To preserve old semantics explicitly:
289
+
290
+ ```text
291
+ /worktree create feature/login --name login
292
+ ```
293
+
294
+ Current releases emit a warning when legacy-style single tokens are detected without `--name`.
295
+
234
296
  ### Template variables
235
297
 
236
298
  Available in `worktreeRoot` and `onCreate` values:
@@ -290,14 +352,14 @@ This extension does not apply a separate ad-hoc deprecation mechanism.
290
352
  | v
291
353
  | [Save settings] -----------------> [Idle]
292
354
  |
293
- +--> [create <name>] --> [Validate repo/name/branch/path]
355
+ +--> [create <branch> [--name <worktree-name>]] --> [Validate repo/name/branch/path]
294
356
  | |fail
295
357
  | v
296
358
  | [Error] -------------> [Idle]
297
359
  | |
298
360
  | pass
299
361
  | v
300
- | [git worktree add -b feature/<name>]
362
+ | [git worktree add -b <branch> <worktreePath>]
301
363
  | |fail
302
364
  | v
303
365
  | [Error] -------------> [Idle]
@@ -384,8 +446,8 @@ This extension does not apply a separate ad-hoc deprecation mechanism.
384
446
  ### `Not in a git repository`
385
447
  Run commands from inside a git repo (or one of its worktrees).
386
448
 
387
- ### `Branch 'feature/<name>' already exists`
388
- Choose another feature name or delete/rename the branch.
449
+ ### `Branch '<branch>' already exists`
450
+ Choose another branch name or delete/rename the existing branch.
389
451
 
390
452
  ### Can’t remove worktree due to changes
391
453
  Use `/worktree remove <name>`, then confirm the force remove prompt.
@@ -1,2 +1,2 @@
1
- import { CmdHandler } from '../types.ts';
1
+ import type { CmdHandler } from '../types.ts';
2
2
  export declare const cmdList: CmdHandler;
@@ -1,2 +1,3 @@
1
1
  import type { ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
2
- export declare function cmdPrune(_args: string, ctx: ExtensionCommandContext): Promise<void>;
2
+ import type { CommandDeps } from '../types.ts';
3
+ export declare function cmdPrune(_args: string, ctx: ExtensionCommandContext, deps: CommandDeps): Promise<void>;
@@ -0,0 +1,21 @@
1
+ interface CreateCommandArgsBase {
2
+ worktreeName: string;
3
+ explicitName: boolean;
4
+ }
5
+ export interface CreateCommandBranchArgs extends CreateCommandArgsBase {
6
+ generate: false;
7
+ branch: string;
8
+ showLegacyWarning: boolean;
9
+ }
10
+ export interface CreateCommandGenerateArgs extends CreateCommandArgsBase {
11
+ generate: true;
12
+ generatorInput: string;
13
+ showLegacyWarning: false;
14
+ }
15
+ export type CreateCommandArgs = CreateCommandBranchArgs | CreateCommandGenerateArgs;
16
+ export interface CreateCommandArgError {
17
+ error: string;
18
+ }
19
+ export declare function slugifyBranch(branch: string): string;
20
+ export declare function parseCreateCommandArgs(args: string): CreateCommandArgs | CreateCommandArgError;
21
+ export {};
@@ -19,8 +19,11 @@ export interface OnCreateHookOptions {
19
19
  cmdDisplaySuccessColor?: string;
20
20
  cmdDisplayErrorColor?: string;
21
21
  }
22
+ export declare function sanitizePathPart(value: string): string;
23
+ export declare function resolveLogfilePath(template: string, values: Record<'sessionId' | 'name' | 'timestamp', string>): string;
22
24
  /**
23
- * Runs post-create hooks sequentially.
25
+ * Runs hook commands sequentially.
24
26
  * Stops at first failure and reports the failing command.
25
27
  */
28
+ export declare function runHook(createdCtx: WorktreeCreatedContext, hookValue: WorktreeSettingsConfig['onCreate'] | undefined, hookName: 'onCreate' | 'onSwitch' | 'onBeforeRemove', notify: (msg: string, type: 'info' | 'error' | 'warning') => void, options?: OnCreateHookOptions): Promise<OnCreateResult>;
26
29
  export declare function runOnCreateHook(createdCtx: WorktreeCreatedContext, settings: WorktreeSettingsConfig, notify: (msg: string, type: 'info' | 'error' | 'warning') => void, options?: OnCreateHookOptions): Promise<OnCreateResult>;
package/dist/index.js CHANGED
@@ -24,11 +24,14 @@ import {
24
24
  Union,
25
25
  Integer as TypeInteger
26
26
  } from "typebox";
27
- var OnCreateSchema = Union([TypeString(), TypeArray(TypeString())]);
27
+ var HookCommandsSchema = Union([TypeString(), TypeArray(TypeString())]);
28
28
  var WorktreeSettingsSchema = TypeObject({
29
29
  worktreeRoot: Optional(TypeString()),
30
30
  parentDir: Optional(TypeString()),
31
- onCreate: Optional(OnCreateSchema)
31
+ onCreate: Optional(HookCommandsSchema),
32
+ onSwitch: Optional(HookCommandsSchema),
33
+ onBeforeRemove: Optional(HookCommandsSchema),
34
+ branchNameGenerator: Optional(TypeString())
32
35
  }, {
33
36
  $id: "WorktreeSettingsConfig",
34
37
  additionalProperties: false
@@ -73,7 +76,7 @@ function globToRegExp(pattern) {
73
76
  const doubleStarReplaced = escaped.replace(/\*\*/g, "::DOUBLE_STAR::");
74
77
  const singleStarReplaced = doubleStarReplaced.replace(/\*/g, "[^/]*");
75
78
  const regexBody = singleStarReplaced.replace(/::DOUBLE_STAR::/g, ".*");
76
- return new RegExp(`^${regexBody}$`, "i");
79
+ return new RegExp(regexBody, "i");
77
80
  }
78
81
  function globMatch(input, pattern) {
79
82
  return globToRegExp(pattern).test(input);
@@ -340,11 +343,17 @@ async function cmdCd(args, ctx, deps) {
340
343
  }
341
344
 
342
345
  // src/cmds/cmdCreate.ts
343
- import { join as join3 } from "path";
346
+ import { basename as basename3, join as join3 } from "path";
344
347
 
345
348
  // src/cmds/shared.ts
346
349
  import { appendFileSync as appendFileSync2, writeFileSync } from "fs";
347
350
  import { spawn } from "child_process";
351
+ function sanitizePathPart(value) {
352
+ return value.replace(/[^a-zA-Z0-9._-]/g, "-");
353
+ }
354
+ function resolveLogfilePath(template, values) {
355
+ return template.replace(/\{\{sessionId\}\}|\{sessionId\}/g, values.sessionId).replace(/\{\{name\}\}|\{name\}/g, values.name).replace(/\{\{timestamp\}\}|\{timestamp\}/g, values.timestamp);
356
+ }
348
357
  var ANSI = {
349
358
  reset: "\x1B[0m",
350
359
  gray: "\x1B[90m",
@@ -411,8 +420,8 @@ function getDisplayLines(text, maxLines) {
411
420
  }
412
421
  return lines.slice(-maxLines);
413
422
  }
414
- function formatCommandList(commands, states, outputs, commandDisplay, logPath, displayOutputMaxLines = 5) {
415
- const lines = ["onCreate steps:"];
423
+ function formatCommandList(commands, states, outputs, commandDisplay, hookName, logPath, displayOutputMaxLines = 5) {
424
+ const lines = [`${hookName} steps:`];
416
425
  for (const [index, command] of commands.entries()) {
417
426
  const state = states[index];
418
427
  lines.push(formatCommandLine(command, state, commandDisplay));
@@ -483,18 +492,18 @@ function runCommand(command, cwd, onOutput) {
483
492
  });
484
493
  });
485
494
  }
486
- async function runOnCreateHook(createdCtx, settings, notify, options) {
487
- if (!settings.onCreate) {
495
+ async function runHook(createdCtx, hookValue, hookName, notify, options) {
496
+ if (!hookValue) {
488
497
  return { success: true, executed: [] };
489
498
  }
490
- const commandTemplates = Array.isArray(settings.onCreate) ? settings.onCreate : [settings.onCreate];
499
+ const commandTemplates = Array.isArray(hookValue) ? hookValue : [hookValue];
491
500
  const commands = commandTemplates.map((template) => expandTemplate(template, createdCtx));
492
501
  const executed = [];
493
502
  const commandStates = commands.map(() => "pending");
494
503
  const commandOutputs = commands.map(() => ({ stdout: "", stderr: "" }));
495
504
  if (options?.logPath) {
496
505
  writeFileSync(options.logPath, [
497
- `# pi-worktree onCreate log`,
506
+ `# pi-worktree ${hookName} log`,
498
507
  `# worktree: ${createdCtx.path}`,
499
508
  `# branch: ${createdCtx.branch}`,
500
509
  ""
@@ -510,13 +519,13 @@ async function runOnCreateHook(createdCtx, settings, notify, options) {
510
519
  successColor: options?.cmdDisplaySuccessColor ?? "success",
511
520
  errorColor: options?.cmdDisplayErrorColor ?? "error"
512
521
  };
513
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, undefined, displayOutputMaxLines), "info");
522
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, undefined, displayOutputMaxLines), "info");
514
523
  for (const [index, command] of commands.entries()) {
515
524
  commandStates[index] = "running";
516
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, undefined, displayOutputMaxLines), "info");
525
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, undefined, displayOutputMaxLines), "info");
517
526
  const result = await runCommand(command, createdCtx.path, (stream, chunk) => {
518
527
  commandOutputs[index][stream] += chunk;
519
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, undefined, displayOutputMaxLines), "info");
528
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, undefined, displayOutputMaxLines), "info");
520
529
  });
521
530
  if (options?.logPath) {
522
531
  appendCommandLog(options.logPath, command, result);
@@ -524,8 +533,8 @@ async function runOnCreateHook(createdCtx, settings, notify, options) {
524
533
  executed.push(command);
525
534
  if (!result.success) {
526
535
  commandStates[index] = "failed";
527
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, options?.logPath, displayOutputMaxLines), "error");
528
- notify(`onCreate failed (exit ${result.code}): ${result.stderr.slice(0, 200)}${options?.logPath ? `
536
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, options?.logPath, displayOutputMaxLines), "error");
537
+ notify(`${hookName} failed (exit ${result.code}): ${result.stderr.slice(0, 200)}${options?.logPath ? `
529
538
  log: ${options.logPath}` : ""}`, "error");
530
539
  return {
531
540
  success: false,
@@ -538,11 +547,14 @@ log: ${options.logPath}` : ""}`, "error");
538
547
  };
539
548
  }
540
549
  commandStates[index] = "success";
541
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, undefined, displayOutputMaxLines), "info");
550
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, undefined, displayOutputMaxLines), "info");
542
551
  }
543
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, options?.logPath, displayOutputMaxLines), "info");
552
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, options?.logPath, displayOutputMaxLines), "info");
544
553
  return { success: true, executed };
545
554
  }
555
+ async function runOnCreateHook(createdCtx, settings, notify, options) {
556
+ return runHook(createdCtx, settings.onCreate, "onCreate", notify, options);
557
+ }
546
558
 
547
559
  // src/services/config/config.ts
548
560
  import { createConfigService } from "@zenobius/pi-extension-config";
@@ -949,29 +961,297 @@ var DefaultWorktreeSettings = {
949
961
  };
950
962
  var DefaultLogfileTemplate = DEFAULT_LOGFILE_TEMPLATE;
951
963
 
952
- // src/cmds/cmdCreate.ts
953
- function sanitizePathPart(value) {
954
- return value.replace(/[^a-zA-Z0-9._-]/g, "-");
964
+ // src/cmds/createArgs.ts
965
+ var SIMPLE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
966
+ var BRANCH_FIRST_USAGE = "/worktree create <branch> [--name <worktree-name>]";
967
+ var GENERATE_USAGE = "/worktree create --generate [--name <worktree-name>] <prompt-or-name>";
968
+ var CREATE_USAGE = `Usage: ${BRANCH_FIRST_USAGE} OR ${GENERATE_USAGE}`;
969
+ function slugifyBranch(branch) {
970
+ return branch.toLowerCase().replace(/[\s/_.]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
955
971
  }
956
- function resolveLogfilePath(template, values) {
957
- return template.replace(/\{\{sessionId\}\}|\{sessionId\}/g, values.sessionId).replace(/\{\{name\}\}|\{name\}/g, values.name).replace(/\{\{timestamp\}\}|\{timestamp\}/g, values.timestamp);
972
+ function isValidWorktreeName(name) {
973
+ return SIMPLE_NAME_PATTERN.test(name);
974
+ }
975
+ function isLegacyStyleToken(token) {
976
+ if (!token) {
977
+ return false;
978
+ }
979
+ if (token.includes("/")) {
980
+ return false;
981
+ }
982
+ return SIMPLE_NAME_PATTERN.test(token);
983
+ }
984
+ function parseCreateCommandArgs(args) {
985
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
986
+ if (tokens.length === 0) {
987
+ return { error: CREATE_USAGE };
988
+ }
989
+ let explicitName;
990
+ let useGenerator = false;
991
+ const positional = [];
992
+ for (let index = 0;index < tokens.length; index += 1) {
993
+ const token = tokens[index];
994
+ if (token === "--name") {
995
+ if (explicitName) {
996
+ return { error: "Duplicate --name option. Provide it only once." };
997
+ }
998
+ const value = tokens[index + 1];
999
+ if (!value || value.startsWith("--")) {
1000
+ return {
1001
+ error: `Missing value for --name. ${CREATE_USAGE}`
1002
+ };
1003
+ }
1004
+ explicitName = value;
1005
+ index += 1;
1006
+ continue;
1007
+ }
1008
+ if (token === "--generate") {
1009
+ if (useGenerator) {
1010
+ return { error: "Duplicate --generate option. Provide it only once." };
1011
+ }
1012
+ useGenerator = true;
1013
+ continue;
1014
+ }
1015
+ if (token.startsWith("--")) {
1016
+ return {
1017
+ error: `Unknown argument: ${token}. ${CREATE_USAGE}`
1018
+ };
1019
+ }
1020
+ positional.push(token);
1021
+ }
1022
+ if (positional.length !== 1) {
1023
+ return {
1024
+ error: `Expected exactly one ${useGenerator ? "<prompt-or-name>" : "<branch>"}. ${CREATE_USAGE}`
1025
+ };
1026
+ }
1027
+ if (explicitName && !isValidWorktreeName(explicitName)) {
1028
+ return {
1029
+ error: "Invalid worktree name for --name. Use only letters, numbers, '.', '_' or '-' (no '/')."
1030
+ };
1031
+ }
1032
+ const sourceToken = positional[0];
1033
+ const derivedName = slugifyBranch(sourceToken);
1034
+ if (!explicitName && !derivedName) {
1035
+ return {
1036
+ error: "Derived worktree name is empty after slugify. Use a source with letters/numbers or pass --name <worktree-name>."
1037
+ };
1038
+ }
1039
+ if (useGenerator) {
1040
+ return {
1041
+ generate: true,
1042
+ generatorInput: sourceToken,
1043
+ worktreeName: explicitName ?? derivedName,
1044
+ explicitName: Boolean(explicitName),
1045
+ showLegacyWarning: false
1046
+ };
1047
+ }
1048
+ return {
1049
+ generate: false,
1050
+ branch: sourceToken,
1051
+ worktreeName: explicitName ?? derivedName,
1052
+ explicitName: Boolean(explicitName),
1053
+ showLegacyWarning: !explicitName && isLegacyStyleToken(sourceToken)
1054
+ };
1055
+ }
1056
+
1057
+ // src/services/branchNameGenerator.ts
1058
+ import { spawn as spawn2 } from "child_process";
1059
+ var BRANCH_NAME_GENERATOR_TIMEOUT_MS = 5000;
1060
+ function shellQuote(value) {
1061
+ return `'${value.replace(/'/g, `'\\''`)}'`;
958
1062
  }
1063
+ function renderCommand(template, input) {
1064
+ const quotedInput = shellQuote(input);
1065
+ return template.replace(/\{\{prompt\}\}|\{prompt\}/g, quotedInput);
1066
+ }
1067
+ async function validateBranchName(branchName, cwd) {
1068
+ const checker = spawn2("git", ["check-ref-format", "--branch", branchName], {
1069
+ cwd,
1070
+ shell: false,
1071
+ stdio: "ignore"
1072
+ });
1073
+ return new Promise((resolve2) => {
1074
+ checker.on("close", (code) => resolve2(code === 0));
1075
+ checker.on("error", () => resolve2(false));
1076
+ });
1077
+ }
1078
+ async function generateBranchName(params) {
1079
+ const timeoutMs = params.timeoutMs ?? BRANCH_NAME_GENERATOR_TIMEOUT_MS;
1080
+ if (!params.commandTemplate?.trim()) {
1081
+ return {
1082
+ ok: false,
1083
+ code: "missing-config",
1084
+ message: "No branchNameGenerator configured for this repository. Set worktrees.<pattern>.branchNameGenerator or run '/worktree create <branch>' without --generate."
1085
+ };
1086
+ }
1087
+ const command = renderCommand(params.commandTemplate, params.input);
1088
+ const result = await new Promise((resolve2) => {
1089
+ const child = spawn2(command, {
1090
+ cwd: params.cwd,
1091
+ shell: true,
1092
+ stdio: ["ignore", "pipe", "pipe"],
1093
+ env: {
1094
+ ...process.env,
1095
+ PI_WORKTREE_PROMPT: params.input
1096
+ }
1097
+ });
1098
+ let stdout = "";
1099
+ let stderr = "";
1100
+ let done = false;
1101
+ const timer = globalThis.setTimeout(() => {
1102
+ if (done) {
1103
+ return;
1104
+ }
1105
+ done = true;
1106
+ child.kill("SIGKILL");
1107
+ resolve2({ kind: "timeout" });
1108
+ }, timeoutMs);
1109
+ child.stdout?.on("data", (chunk) => {
1110
+ stdout += chunk.toString();
1111
+ });
1112
+ child.stderr?.on("data", (chunk) => {
1113
+ stderr += chunk.toString();
1114
+ });
1115
+ child.on("error", (error) => {
1116
+ if (done) {
1117
+ return;
1118
+ }
1119
+ done = true;
1120
+ globalThis.clearTimeout(timer);
1121
+ resolve2({ kind: "spawn-error", error: error.message });
1122
+ });
1123
+ child.on("close", (code) => {
1124
+ if (done) {
1125
+ return;
1126
+ }
1127
+ done = true;
1128
+ globalThis.clearTimeout(timer);
1129
+ resolve2({ kind: "success", stdout, stderr, code: code ?? 1 });
1130
+ });
1131
+ });
1132
+ if (result.kind === "timeout") {
1133
+ return {
1134
+ ok: false,
1135
+ code: "timeout",
1136
+ message: `branchNameGenerator timed out after ${timeoutMs}ms. Make it faster or run '/worktree create <branch>' manually.`
1137
+ };
1138
+ }
1139
+ if (result.kind === "spawn-error") {
1140
+ return {
1141
+ ok: false,
1142
+ code: "spawn-error",
1143
+ message: `Failed to run branchNameGenerator command: ${result.error}`
1144
+ };
1145
+ }
1146
+ if (result.code !== 0) {
1147
+ const stderr = result.stderr.trim();
1148
+ return {
1149
+ ok: false,
1150
+ code: "non-zero-exit",
1151
+ message: `branchNameGenerator exited with code ${result.code}.${stderr ? ` stderr: ${stderr}` : ""}`
1152
+ };
1153
+ }
1154
+ const branchName = result.stdout.trim();
1155
+ if (!branchName) {
1156
+ return {
1157
+ ok: false,
1158
+ code: "empty-output",
1159
+ message: "branchNameGenerator produced empty output. Ensure the command prints exactly one branch name to stdout."
1160
+ };
1161
+ }
1162
+ const valid = await validateBranchName(branchName, params.cwd);
1163
+ if (!valid) {
1164
+ return {
1165
+ ok: false,
1166
+ code: "invalid-output",
1167
+ message: `branchNameGenerator output is not a valid branch name: '${branchName}'. Fix the command output or run '/worktree create <branch>' manually.`
1168
+ };
1169
+ }
1170
+ return {
1171
+ ok: true,
1172
+ branchName,
1173
+ command: params.commandTemplate
1174
+ };
1175
+ }
1176
+
1177
+ // src/cmds/cmdCreate.ts
959
1178
  async function cmdCreate(args, ctx, deps) {
960
- const featureName = args.trim();
961
- if (!featureName) {
962
- ctx.ui.notify("Usage: /worktree create <feature-name>", "error");
1179
+ const parsed = parseCreateCommandArgs(args);
1180
+ if ("error" in parsed) {
1181
+ ctx.ui.notify(parsed.error, "error");
963
1182
  return;
964
1183
  }
1184
+ const worktreeName = parsed.worktreeName;
965
1185
  if (!isGitRepo(ctx.cwd)) {
966
1186
  ctx.ui.notify("Not in a git repository", "error");
967
1187
  return;
968
1188
  }
969
1189
  const current = deps.configService.current(ctx);
970
- const worktreePath = join3(current.parentDir, featureName);
971
- const branchName = `feature/${featureName}`;
972
- const existing = listWorktrees(ctx.cwd);
973
- if (existing.some((worktree) => worktree.path === worktreePath)) {
974
- ctx.ui.notify(`Worktree already exists at: ${worktreePath}`, "error");
1190
+ let branchName = parsed.generate ? "" : parsed.branch;
1191
+ if (parsed.generate) {
1192
+ const generated = await generateBranchName({
1193
+ commandTemplate: current.branchNameGenerator,
1194
+ input: parsed.generatorInput,
1195
+ cwd: ctx.cwd
1196
+ });
1197
+ if (!generated.ok) {
1198
+ ctx.ui.notify(generated.message, "error");
1199
+ return;
1200
+ }
1201
+ branchName = generated.branchName;
1202
+ ctx.ui.notify(`Using generated branch '${branchName}' from branchNameGenerator (input: '${parsed.generatorInput}').`, "info");
1203
+ }
1204
+ if (!parsed.generate && parsed.showLegacyWarning) {
1205
+ ctx.ui.notify(`Legacy create style detected: '/worktree create <feature-name>' is deprecated. '${branchName}' is now treated as the branch name. If you want old semantics, run '/worktree create feature/${branchName}' (optionally '--name ${branchName}').`, "warning");
1206
+ }
1207
+ const worktreePath = join3(current.parentDir, worktreeName);
1208
+ const existingWorktree = listWorktrees(ctx.cwd).find((worktree) => worktree.path === worktreePath || basename3(worktree.path) === worktreeName || worktree.branch === branchName);
1209
+ if (existingWorktree) {
1210
+ if (!ctx.hasUI) {
1211
+ ctx.ui.notify(`Worktree already exists at: ${worktreePath}`, "error");
1212
+ return;
1213
+ }
1214
+ const confirmMessage = current.onSwitch ? `Path: ${existingWorktree.path}
1215
+ Branch: ${existingWorktree.branch}
1216
+
1217
+ Switch to this worktree and run onSwitch?` : `Path: ${existingWorktree.path}
1218
+ Branch: ${existingWorktree.branch}
1219
+
1220
+ Switch to this worktree?`;
1221
+ const shouldSwitch = await ctx.ui.confirm("Worktree already exists", confirmMessage);
1222
+ if (!shouldSwitch) {
1223
+ ctx.ui.notify("Cancelled", "info");
1224
+ return;
1225
+ }
1226
+ const existingCtx = {
1227
+ path: existingWorktree.path,
1228
+ name: basename3(existingWorktree.path),
1229
+ branch: existingWorktree.branch,
1230
+ ...current
1231
+ };
1232
+ const sessionId2 = sanitizePathPart(ctx.sessionManager?.getSessionId?.() || "session");
1233
+ const safeName2 = sanitizePathPart(existingCtx.name);
1234
+ const timestamp2 = new Date().toISOString().replace(/[:.]/g, "-");
1235
+ const logPath2 = resolveLogfilePath(current.logfile ?? DefaultLogfileTemplate, {
1236
+ sessionId: sessionId2,
1237
+ name: safeName2,
1238
+ timestamp: timestamp2
1239
+ });
1240
+ const result = await runHook(existingCtx, current.onSwitch, "onSwitch", ctx.ui.notify.bind(ctx.ui), {
1241
+ logPath: logPath2,
1242
+ displayOutputMaxLines: current.onCreateDisplayOutputMaxLines,
1243
+ cmdDisplayPending: current.onCreateCmdDisplayPending,
1244
+ cmdDisplaySuccess: current.onCreateCmdDisplaySuccess,
1245
+ cmdDisplayError: current.onCreateCmdDisplayError,
1246
+ cmdDisplayPendingColor: current.onCreateCmdDisplayPendingColor,
1247
+ cmdDisplaySuccessColor: current.onCreateCmdDisplaySuccessColor,
1248
+ cmdDisplayErrorColor: current.onCreateCmdDisplayErrorColor
1249
+ });
1250
+ if (!result.success) {
1251
+ ctx.ui.notify("onSwitch failed", "error");
1252
+ return;
1253
+ }
1254
+ ctx.ui.notify(`Worktree path: ${existingWorktree.path}`, "info");
975
1255
  return;
976
1256
  }
977
1257
  try {
@@ -980,21 +1260,25 @@ async function cmdCreate(args, ctx, deps) {
980
1260
  return;
981
1261
  } catch {}
982
1262
  ensureExcluded(ctx.cwd, current.parentDir);
983
- ctx.ui.notify(`Creating worktree: ${featureName}`, "info");
1263
+ const stopBusy = deps.statusService.busy(ctx, `Creating worktree: ${worktreeName}...`);
984
1264
  try {
985
1265
  git(["worktree", "add", "-b", branchName, worktreePath], current.mainWorktree);
1266
+ stopBusy();
1267
+ deps.statusService.positive(ctx, `Created: ${worktreeName}`);
986
1268
  } catch (err) {
1269
+ stopBusy();
1270
+ deps.statusService.critical(ctx, `Failed to create worktree`);
987
1271
  ctx.ui.notify(`Failed to create worktree: ${err.message}`, "error");
988
1272
  return;
989
1273
  }
990
1274
  const createdCtx = {
991
1275
  path: worktreePath,
992
- name: featureName,
1276
+ name: worktreeName,
993
1277
  branch: branchName,
994
1278
  ...current
995
1279
  };
996
1280
  const sessionId = sanitizePathPart(ctx.sessionManager?.getSessionId?.() || "session");
997
- const safeName = sanitizePathPart(featureName);
1281
+ const safeName = sanitizePathPart(worktreeName);
998
1282
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
999
1283
  const logPath = resolveLogfilePath(current.logfile ?? DefaultLogfileTemplate, {
1000
1284
  sessionId,
@@ -1100,6 +1384,12 @@ ${finalConfig}`, "info");
1100
1384
  }
1101
1385
 
1102
1386
  // src/cmds/cmdList.ts
1387
+ import { basename as basename4 } from "path";
1388
+ function formatWorktreeOption(worktree) {
1389
+ const markers = [worktree.isMain ? "[main]" : "", worktree.isCurrent ? "[current]" : ""].filter(Boolean).join(" ");
1390
+ return `${worktree.branch}${markers ? " " + markers : ""}
1391
+ ${worktree.path}`;
1392
+ }
1103
1393
  var cmdList = async (_args, ctx, deps) => {
1104
1394
  if (!isGitRepo(ctx.cwd)) {
1105
1395
  ctx.ui.notify("Not in a git repository", "error");
@@ -1110,17 +1400,18 @@ var cmdList = async (_args, ctx, deps) => {
1110
1400
  ctx.ui.notify("No worktrees found", "info");
1111
1401
  return;
1112
1402
  }
1113
- const lines = worktrees.map((worktree) => {
1114
- const markers = [worktree.isMain ? "[main]" : "", worktree.isCurrent ? "[current]" : ""].filter(Boolean).join(" ");
1115
- return `${worktree.branch}${markers ? " " + markers : ""}
1403
+ if (!ctx.hasUI) {
1404
+ const lines = worktrees.map((worktree) => {
1405
+ const markers = [worktree.isMain ? "[main]" : "", worktree.isCurrent ? "[current]" : ""].filter(Boolean).join(" ");
1406
+ return `${worktree.branch}${markers ? " " + markers : ""}
1116
1407
  ${worktree.path}`;
1117
- });
1118
- const configured = Array.from(deps.configService.worktrees.entries()).map(([pattern, settings]) => {
1119
- return `${pattern}
1408
+ });
1409
+ const configured = Array.from(deps.configService.worktrees.entries()).map(([pattern, settings]) => {
1410
+ return `${pattern}
1120
1411
  ${settings.worktreeRoot ?? settings.parentDir}
1121
1412
  ${settings.onCreate}`;
1122
- });
1123
- ctx.ui.notify(`Worktrees:
1413
+ });
1414
+ ctx.ui.notify(`Worktrees:
1124
1415
 
1125
1416
  ${lines.join(`
1126
1417
 
@@ -1131,10 +1422,69 @@ Configured:
1131
1422
  ${configured.join(`
1132
1423
 
1133
1424
  `)}`, "info");
1425
+ return;
1426
+ }
1427
+ const options = worktrees.map(formatWorktreeOption);
1428
+ const byOption = new Map(options.map((option, index) => [option, worktrees[index]]));
1429
+ const selected = await ctx.ui.select("Select worktree to switch to", options);
1430
+ if (selected === undefined) {
1431
+ ctx.ui.notify("Cancelled", "info");
1432
+ return;
1433
+ }
1434
+ const target = byOption.get(selected);
1435
+ if (!target) {
1436
+ ctx.ui.notify("Invalid selection", "error");
1437
+ return;
1438
+ }
1439
+ const current = deps.configService.current({ cwd: target.path });
1440
+ if (!current.onSwitch) {
1441
+ ctx.ui.notify(`No onSwitch configured for: ${target.path}`, "info");
1442
+ return;
1443
+ }
1444
+ const sessionId = sanitizePathPart(ctx.sessionManager?.getSessionId?.() || "session");
1445
+ const safeName = sanitizePathPart(basename4(target.path));
1446
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1447
+ const logPath = resolveLogfilePath(current.logfile ?? DefaultLogfileTemplate, {
1448
+ sessionId,
1449
+ name: safeName,
1450
+ timestamp
1451
+ });
1452
+ const createdCtx = {
1453
+ path: target.path,
1454
+ name: basename4(target.path),
1455
+ branch: target.branch,
1456
+ project: current.project,
1457
+ mainWorktree: current.mainWorktree
1458
+ };
1459
+ const stopBusy = deps.statusService.busy(ctx, `Running onSwitch for ${target.branch}...`);
1460
+ try {
1461
+ const result = await runHook(createdCtx, current.onSwitch, "onSwitch", ctx.ui.notify.bind(ctx.ui), {
1462
+ logPath,
1463
+ displayOutputMaxLines: current.onCreateDisplayOutputMaxLines,
1464
+ cmdDisplayPending: current.onCreateCmdDisplayPending,
1465
+ cmdDisplaySuccess: current.onCreateCmdDisplaySuccess,
1466
+ cmdDisplayError: current.onCreateCmdDisplayError,
1467
+ cmdDisplayPendingColor: current.onCreateCmdDisplayPendingColor,
1468
+ cmdDisplaySuccessColor: current.onCreateCmdDisplaySuccessColor,
1469
+ cmdDisplayErrorColor: current.onCreateCmdDisplayErrorColor
1470
+ });
1471
+ if (!result.success) {
1472
+ stopBusy();
1473
+ deps.statusService.critical(ctx, `onSwitch failed`);
1474
+ ctx.ui.notify(`onSwitch failed`, "error");
1475
+ return;
1476
+ }
1477
+ stopBusy();
1478
+ deps.statusService.positive(ctx, `onSwitch complete: ${target.branch}`);
1479
+ } catch (err) {
1480
+ stopBusy();
1481
+ deps.statusService.critical(ctx, `onSwitch failed`);
1482
+ ctx.ui.notify(`onSwitch failed: ${err.message}`, "error");
1483
+ }
1134
1484
  };
1135
1485
 
1136
1486
  // src/cmds/cmdPrune.ts
1137
- async function cmdPrune(_args, ctx) {
1487
+ async function cmdPrune(_args, ctx, deps) {
1138
1488
  if (!isGitRepo(ctx.cwd)) {
1139
1489
  ctx.ui.notify("Not in a git repository", "error");
1140
1490
  return;
@@ -1157,18 +1507,23 @@ ${dryRun}`);
1157
1507
  ctx.ui.notify("Cancelled", "info");
1158
1508
  return;
1159
1509
  }
1510
+ const stopBusy = deps.statusService.busy(ctx, "Pruning stale worktrees...");
1160
1511
  try {
1161
1512
  git(["worktree", "prune"], ctx.cwd);
1513
+ stopBusy();
1514
+ deps.statusService.positive(ctx, "Pruned stale references");
1162
1515
  ctx.ui.notify("\u2713 Stale worktree references pruned", "info");
1163
1516
  } catch (err) {
1517
+ stopBusy();
1518
+ deps.statusService.critical(ctx, "Failed to prune");
1164
1519
  ctx.ui.notify(`Failed to prune: ${err.message}`, "error");
1165
1520
  }
1166
1521
  }
1167
1522
 
1168
1523
  // src/cmds/cmdRemove.ts
1169
- import { basename as basename3, join as join4 } from "path";
1524
+ import { basename as basename5, join as join4 } from "path";
1170
1525
  function findTarget(worktrees, worktreeName, parentDir) {
1171
- return worktrees.find((worktree) => basename3(worktree.path) === worktreeName || worktree.path === worktreeName || worktree.path === join4(parentDir, worktreeName));
1526
+ return worktrees.find((worktree) => basename5(worktree.path) === worktreeName || worktree.path === worktreeName || worktree.path === join4(parentDir, worktreeName));
1172
1527
  }
1173
1528
  function isProtectedWorktree(worktree) {
1174
1529
  return worktree.isMain || worktree.isCurrent;
@@ -1179,7 +1534,7 @@ async function pickWorktreeInteractively(ctx, worktrees) {
1179
1534
  ctx.ui.notify("No removable worktrees found", "info");
1180
1535
  return;
1181
1536
  }
1182
- const options = candidates.map((worktree) => `${basename3(worktree.path)} (${worktree.branch})
1537
+ const options = candidates.map((worktree) => `${basename5(worktree.path)} (${worktree.branch})
1183
1538
  ${worktree.path}`);
1184
1539
  const byOption = new Map(options.map((option, index) => [option, candidates[index]]));
1185
1540
  const selected = await ctx.ui.select("Select worktree to remove", options);
@@ -1189,7 +1544,7 @@ async function pickWorktreeInteractively(ctx, worktrees) {
1189
1544
  }
1190
1545
  return byOption.get(selected);
1191
1546
  }
1192
- async function removeWorktreeWithConfirm(ctx, cwd, target) {
1547
+ async function removeWorktreeWithConfirm(ctx, cwd, target, status, runBeforeRemove) {
1193
1548
  const confirmed = await ctx.ui.confirm("Remove worktree?", `This will remove:
1194
1549
  Path: ${target.path}
1195
1550
  Branch: ${target.branch}
@@ -1199,19 +1554,34 @@ The branch will NOT be deleted.`);
1199
1554
  ctx.ui.notify("Cancelled", "info");
1200
1555
  return;
1201
1556
  }
1557
+ if (runBeforeRemove) {
1558
+ const canContinue = await runBeforeRemove();
1559
+ if (!canContinue) {
1560
+ return;
1561
+ }
1562
+ }
1563
+ const stopBusy = status.busy(ctx, "Removing worktree...");
1202
1564
  try {
1203
1565
  git(["worktree", "remove", target.path], cwd);
1566
+ stopBusy();
1567
+ status.positive(ctx, `Removed: ${target.path}`);
1204
1568
  ctx.ui.notify(`\u2713 Worktree removed: ${target.path}`, "info");
1205
1569
  } catch {
1570
+ stopBusy();
1206
1571
  const forceConfirmed = await ctx.ui.confirm("Force remove?", "Worktree has uncommitted changes. Force remove anyway?");
1207
1572
  if (!forceConfirmed) {
1208
1573
  ctx.ui.notify("Cancelled", "info");
1209
1574
  return;
1210
1575
  }
1576
+ const stopForceBusy = status.busy(ctx, "Force removing worktree...");
1211
1577
  try {
1212
1578
  git(["worktree", "remove", "--force", target.path], cwd);
1579
+ stopForceBusy();
1580
+ status.positive(ctx, `Force removed: ${target.path}`);
1213
1581
  ctx.ui.notify(`\u2713 Worktree force removed: ${target.path}`, "info");
1214
1582
  } catch (forceErr) {
1583
+ stopForceBusy();
1584
+ status.critical(ctx, `Failed to remove`);
1215
1585
  ctx.ui.notify(`Failed to remove: ${forceErr.message}`, "error");
1216
1586
  }
1217
1587
  }
@@ -1249,7 +1619,38 @@ async function cmdRemove(args, ctx, deps) {
1249
1619
  return;
1250
1620
  }
1251
1621
  }
1252
- await removeWorktreeWithConfirm(ctx, ctx.cwd, target);
1622
+ const current = deps.configService.current({ cwd: target.path });
1623
+ await removeWorktreeWithConfirm(ctx, ctx.cwd, target, deps.statusService, async () => {
1624
+ if (!current.onBeforeRemove) {
1625
+ return true;
1626
+ }
1627
+ const hookCtx = {
1628
+ path: target.path,
1629
+ name: basename5(target.path),
1630
+ branch: target.branch,
1631
+ project: current.project,
1632
+ mainWorktree: current.mainWorktree
1633
+ };
1634
+ const sessionId = sanitizePathPart(ctx.sessionManager?.getSessionId?.() || "session");
1635
+ const safeName = sanitizePathPart(hookCtx.name);
1636
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1637
+ const logPath = resolveLogfilePath(current.logfile ?? DefaultLogfileTemplate, {
1638
+ sessionId,
1639
+ name: safeName,
1640
+ timestamp
1641
+ });
1642
+ const result = await runHook(hookCtx, current.onBeforeRemove, "onBeforeRemove", ctx.ui.notify.bind(ctx.ui), {
1643
+ logPath,
1644
+ displayOutputMaxLines: current.onCreateDisplayOutputMaxLines,
1645
+ cmdDisplayPending: current.onCreateCmdDisplayPending,
1646
+ cmdDisplaySuccess: current.onCreateCmdDisplaySuccess,
1647
+ cmdDisplayError: current.onCreateCmdDisplayError,
1648
+ cmdDisplayPendingColor: current.onCreateCmdDisplayPendingColor,
1649
+ cmdDisplaySuccessColor: current.onCreateCmdDisplaySuccessColor,
1650
+ cmdDisplayErrorColor: current.onCreateCmdDisplayErrorColor
1651
+ });
1652
+ return result.success;
1653
+ });
1253
1654
  }
1254
1655
 
1255
1656
  // src/cmds/cmdSettings.ts
@@ -1444,6 +1845,74 @@ function createCompletionFactory(commands) {
1444
1845
  };
1445
1846
  }
1446
1847
 
1848
+ // src/ui/status.ts
1849
+ class StatusIndicator {
1850
+ statusKey;
1851
+ busyStyle;
1852
+ busyFrames;
1853
+ progressStyle = "bars";
1854
+ progressFrames;
1855
+ constructor(statusKey, options = {
1856
+ busy: "dots",
1857
+ progress: "bars"
1858
+ }) {
1859
+ this.statusKey = statusKey;
1860
+ this.busyStyle = options.busy || "dots";
1861
+ this.busyFrames = StatusIndicator.busyStyles[this.busyStyle];
1862
+ this.progressStyle = options.progress || "bars";
1863
+ this.progressFrames = StatusIndicator.progressStyles[this.progressStyle];
1864
+ }
1865
+ busy(ctx, message) {
1866
+ if (typeof ctx.ui.setStatus !== "function") {
1867
+ return () => {};
1868
+ }
1869
+ let i = 0;
1870
+ ctx.ui.setStatus(this.statusKey, `${this.busyFrames[i]} ${message}`);
1871
+ const timer = globalThis.setInterval(() => {
1872
+ i = (i + 1) % this.busyFrames.length;
1873
+ ctx.ui.setStatus?.(this.statusKey, `${this.busyFrames[i]} ${message}`);
1874
+ }, 100);
1875
+ return () => {
1876
+ globalThis.clearInterval(timer);
1877
+ ctx.ui.setStatus?.(this.statusKey, undefined);
1878
+ };
1879
+ }
1880
+ cautious(ctx, message) {
1881
+ ctx.ui.setStatus?.(this.statusKey, `\u26A0\uFE0F ${message}`);
1882
+ }
1883
+ critical(ctx, message) {
1884
+ ctx.ui.setStatus?.(this.statusKey, `\u274C ${message}`);
1885
+ }
1886
+ positive(ctx, message) {
1887
+ ctx.ui.setStatus?.(this.statusKey, `\u2705 ${message}`);
1888
+ }
1889
+ informative(ctx, message) {
1890
+ ctx.ui.setStatus?.(this.statusKey, `\u2139\uFE0F ${message}`);
1891
+ }
1892
+ progress(ctx, message, percent) {
1893
+ const progressBar = this.progressFrames(percent);
1894
+ ctx.ui.setStatus?.(this.statusKey, `${progressBar} ${message}`);
1895
+ }
1896
+ static busyStyles = {
1897
+ dots: ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]
1898
+ };
1899
+ static progressStyles = {
1900
+ bars: (percent) => {
1901
+ const clampedPercent = Math.max(0, Math.min(100, percent));
1902
+ const progressBarLength = 20;
1903
+ const filledLength = Math.round(clampedPercent / 100 * progressBarLength);
1904
+ const emptyLength = progressBarLength - filledLength;
1905
+ return "\u2588".repeat(filledLength) + "\u2591".repeat(emptyLength);
1906
+ },
1907
+ pie: (percent) => {
1908
+ const clampedPercent = Math.max(0, Math.min(100, percent));
1909
+ const pieFrames = ["\u25CB", "\u25D4", "\u25D1", "\u25D5", "\u25CF"];
1910
+ const frameIndex = Math.floor(clampedPercent / 100 * (pieFrames.length - 1));
1911
+ return pieFrames[frameIndex];
1912
+ }
1913
+ };
1914
+ }
1915
+
1447
1916
  // src/index.ts
1448
1917
  var HELP_TEXT = `
1449
1918
  /worktree - Git worktree management
@@ -1451,20 +1920,24 @@ var HELP_TEXT = `
1451
1920
  Commands:
1452
1921
  /worktree init Configure worktree settings interactively
1453
1922
  /worktree settings [key] [val] Get/set individual settings
1454
- /worktree create <feature-name> Create new worktree with branch
1455
- /worktree list List all worktrees
1456
- /worktree remove <name> Remove a worktree
1923
+ /worktree create <branch> [--name <worktree-name>] Create new worktree from branch
1924
+ /worktree create --generate [--name <worktree-name>] <prompt-or-name> Generate branch via config command
1925
+ /worktree list List worktrees and run onSwitch for a selection
1926
+ /worktree remove <name> Remove a worktree (runs onBeforeRemove if set)
1457
1927
  /worktree status Show current worktree info
1458
1928
  /worktree cd <name> Print path to worktree
1459
1929
  /worktree prune Clean up stale references
1460
1930
  /worktree templates Show template variables preview
1461
1931
 
1462
- Configuration (~/.pi/agent/pi-worktrees-settings.json):
1932
+ Configuration (~/.pi/agent/pi-worktrees.config.json):
1463
1933
  {
1464
1934
  "worktrees": {
1465
1935
  "github.com/org/repo": {
1466
1936
  "worktreeRoot": "~/work/org",
1467
- "onCreate": ["mise install", "bun install"]
1937
+ "onCreate": ["mise install", "bun install"],
1938
+ "onSwitch": "mise run dev:resume",
1939
+ "onBeforeRemove": "bun test",
1940
+ "branchNameGenerator": "pi -p 'branch name for $PI_WORKTREE_PROMPT' --model local/model",
1468
1941
  },
1469
1942
  "github.com/org/*": {
1470
1943
  "worktreeRoot": "~/work/org-other",
@@ -1490,7 +1963,13 @@ Pattern matching: exact URL > most-specific glob > fallback (worktree)
1490
1963
  Matching strategies: fail-on-tie | first-wins | last-wins
1491
1964
 
1492
1965
  Config note: parentDir is deprecated and supported as an alias for worktreeRoot.
1493
- Template vars: {{path}}, {{name}}, {{branch}}, {{project}}, {{mainWorktree}}
1966
+ Naming note: default worktree name is slugify(branch); explicit '--name' takes precedence.
1967
+ Generator note: '--generate' is explicit opt-in and requires branchNameGenerator config.
1968
+ Generated branch output must be valid and is never used unless --generate is present.
1969
+ Migration note: legacy '/worktree create <feature-name>' is deprecated and now treats token as branch.
1970
+ Use '/worktree create feature/<name> --name <name>' to preserve old semantics.
1971
+ Hook vars: {{path}}, {{name}}, {{branch}}, {{project}}, {{mainWorktree}}
1972
+ Hooks: onCreate (new), onSwitch (existing), onBeforeRemove (pre-delete, non-zero blocks)
1494
1973
  Logfile vars: {sessionId} / {{sessionId}}, {name} / {{name}}, {timestamp} / {{timestamp}}
1495
1974
  `.trim();
1496
1975
  var commands = {
@@ -1511,29 +1990,7 @@ var commands = {
1511
1990
  };
1512
1991
  var PiWorktreeExtension = async function(pi) {
1513
1992
  const configService = await createPiWorktreeConfigService();
1514
- const queue = [];
1515
- configService.events.on("MigrationFailed", () => {
1516
- queue.push({ type: "error", msg: "MigrationFailed" });
1517
- });
1518
- configService.events.on("MigrationApplied", () => {
1519
- queue.push({ type: "info", msg: "MigrationApplied" });
1520
- });
1521
- configService.events.on("ConfigLoading", () => {
1522
- queue.push({ type: "info", msg: "ConfigLoading" });
1523
- });
1524
- configService.events.on("ConfigLoaded", () => {
1525
- queue.push({ type: "info", msg: "ConfigLoaded" });
1526
- });
1527
- pi.on("session_start", async (event, ctx) => {
1528
- await configService.ready;
1529
- while (queue.length > 0) {
1530
- const notification = queue.shift();
1531
- if (!notification) {
1532
- return;
1533
- }
1534
- ctx.ui.setStatus(`Worktrees`, notification.msg);
1535
- }
1536
- });
1993
+ const statusService = new StatusIndicator("pi-worktree");
1537
1994
  const getSubcommandCompletions = createCompletionFactory(commands);
1538
1995
  pi.registerCommand("worktree", {
1539
1996
  description: "Git worktree management for isolated workspaces",
@@ -1552,7 +2009,8 @@ var PiWorktreeExtension = async function(pi) {
1552
2009
  const settings = configService.current(ctx);
1553
2010
  await command(rest.join(" "), ctx, {
1554
2011
  settings,
1555
- configService
2012
+ configService,
2013
+ statusService
1556
2014
  });
1557
2015
  } catch (error) {
1558
2016
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,20 @@
1
+ export declare const BRANCH_NAME_GENERATOR_TIMEOUT_MS = 5000;
2
+ export type BranchNameGeneratorErrorCode = 'missing-config' | 'timeout' | 'non-zero-exit' | 'empty-output' | 'invalid-output' | 'spawn-error';
3
+ export interface GenerateBranchNameParams {
4
+ commandTemplate: string | undefined;
5
+ input: string;
6
+ cwd: string;
7
+ timeoutMs?: number;
8
+ }
9
+ export interface GenerateBranchNameSuccess {
10
+ ok: true;
11
+ branchName: string;
12
+ command: string;
13
+ }
14
+ export interface GenerateBranchNameFailure {
15
+ ok: false;
16
+ code: BranchNameGeneratorErrorCode;
17
+ message: string;
18
+ }
19
+ export type GenerateBranchNameResult = GenerateBranchNameSuccess | GenerateBranchNameFailure;
20
+ export declare function generateBranchName(params: GenerateBranchNameParams): Promise<GenerateBranchNameResult>;
@@ -4,6 +4,9 @@ export declare function createPiWorktreeConfigService(): Promise<{
4
4
  parentDir?: string | undefined;
5
5
  onCreate?: string | string[] | undefined;
6
6
  worktreeRoot?: string | undefined;
7
+ onSwitch?: string | string[] | undefined;
8
+ onBeforeRemove?: string | string[] | undefined;
9
+ branchNameGenerator?: string | undefined;
7
10
  }>;
8
11
  current: (ctx: {
9
12
  cwd: string;
@@ -23,6 +26,9 @@ export declare function createPiWorktreeConfigService(): Promise<{
23
26
  matchedPattern: string | null;
24
27
  onCreate?: string | string[] | undefined;
25
28
  worktreeRoot?: string | undefined;
29
+ onSwitch?: string | string[] | undefined;
30
+ onBeforeRemove?: string | string[] | undefined;
31
+ branchNameGenerator?: string | undefined;
26
32
  };
27
33
  save: (data: PiWorktreeConfig) => Promise<void>;
28
34
  config: {
@@ -31,6 +37,9 @@ export declare function createPiWorktreeConfigService(): Promise<{
31
37
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
32
38
  parentDir: import("typebox").TOptional<import("typebox").TString>;
33
39
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
40
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
41
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
42
+ branchNameGenerator: import("typebox").TOptional<import("typebox").TString>;
34
43
  }>>>;
35
44
  matchingStrategy: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TLiteral<"fail-on-tie">, import("typebox").TLiteral<"first-wins">, import("typebox").TLiteral<"last-wins">]>>;
36
45
  logfile: import("typebox").TOptional<import("typebox").TString>;
@@ -45,6 +54,9 @@ export declare function createPiWorktreeConfigService(): Promise<{
45
54
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
46
55
  parentDir: import("typebox").TOptional<import("typebox").TString>;
47
56
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
57
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
58
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
59
+ branchNameGenerator: import("typebox").TOptional<import("typebox").TString>;
48
60
  }>> | undefined;
49
61
  logfile?: string | undefined;
50
62
  onCreateDisplayOutputMaxLines?: number | undefined;
@@ -65,6 +77,9 @@ export declare function createPiWorktreeConfigService(): Promise<{
65
77
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
66
78
  parentDir: import("typebox").TOptional<import("typebox").TString>;
67
79
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
80
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
81
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
82
+ branchNameGenerator: import("typebox").TOptional<import("typebox").TString>;
68
83
  }>>>;
69
84
  matchingStrategy: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TLiteral<"fail-on-tie">, import("typebox").TLiteral<"first-wins">, import("typebox").TLiteral<"last-wins">]>>;
70
85
  logfile: import("typebox").TOptional<import("typebox").TString>;
@@ -79,6 +94,9 @@ export declare function createPiWorktreeConfigService(): Promise<{
79
94
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
80
95
  parentDir: import("typebox").TOptional<import("typebox").TString>;
81
96
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
97
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
98
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
99
+ branchNameGenerator: import("typebox").TOptional<import("typebox").TString>;
82
100
  }>> | undefined;
83
101
  logfile?: string | undefined;
84
102
  onCreateDisplayOutputMaxLines?: number | undefined;
@@ -3,6 +3,9 @@ declare const WorktreeSettingsSchema: import("typebox").TObject<{
3
3
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
4
4
  parentDir: import("typebox").TOptional<import("typebox").TString>;
5
5
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
6
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
7
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
8
+ branchNameGenerator: import("typebox").TOptional<import("typebox").TString>;
6
9
  }>;
7
10
  declare const MatchingStrategySchema: import("typebox").TUnion<[import("typebox").TLiteral<"fail-on-tie">, import("typebox").TLiteral<"first-wins">, import("typebox").TLiteral<"last-wins">]>;
8
11
  declare const MatchStrategyResultSchema: import("typebox").TUnion<[import("typebox").TLiteral<"exact">, import("typebox").TLiteral<"unmatched">]>;
@@ -11,6 +14,9 @@ export declare const PiWorktreeConfigSchema: import("typebox").TObject<{
11
14
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
12
15
  parentDir: import("typebox").TOptional<import("typebox").TString>;
13
16
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
17
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
18
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
19
+ branchNameGenerator: import("typebox").TOptional<import("typebox").TString>;
14
20
  }>>>;
15
21
  matchingStrategy: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TLiteral<"fail-on-tie">, import("typebox").TLiteral<"first-wins">, import("typebox").TLiteral<"last-wins">]>>;
16
22
  logfile: import("typebox").TOptional<import("typebox").TString>;
package/dist/types.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
2
2
  import type { PiWorktreeConfigService } from './services/config/config.ts';
3
3
  import { WorktreeSettingsConfig } from './services/config/schema.ts';
4
+ import { StatusIndicator } from './ui/status.ts';
4
5
  export interface WorktreeCreatedContext {
5
6
  path: string;
6
7
  name: string;
@@ -11,5 +12,6 @@ export interface WorktreeCreatedContext {
11
12
  export interface CommandDeps {
12
13
  settings: WorktreeSettingsConfig;
13
14
  configService: PiWorktreeConfigService;
15
+ statusService: StatusIndicator;
14
16
  }
15
17
  export type CmdHandler = (...args: [string, ExtensionCommandContext, CommandDeps]) => Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
2
+ type StatusOptions = {
3
+ busy?: keyof typeof StatusIndicator.busyStyles;
4
+ progress?: keyof typeof StatusIndicator.progressStyles;
5
+ };
6
+ export declare class StatusIndicator {
7
+ statusKey: string;
8
+ busyStyle: keyof typeof StatusIndicator.busyStyles;
9
+ private busyFrames;
10
+ private progressStyle;
11
+ private progressFrames;
12
+ constructor(statusKey: string, options?: StatusOptions);
13
+ busy(ctx: ExtensionCommandContext, message: string): () => void;
14
+ cautious(ctx: ExtensionCommandContext, message: string): void;
15
+ critical(ctx: ExtensionCommandContext, message: string): void;
16
+ positive(ctx: ExtensionCommandContext, message: string): void;
17
+ informative(ctx: ExtensionCommandContext, message: string): void;
18
+ progress(ctx: ExtensionCommandContext, message: string, percent: number): void;
19
+ static busyStyles: {
20
+ dots: string[];
21
+ };
22
+ static progressStyles: {
23
+ bars: (percent: number) => string;
24
+ pie: (percent: number) => string;
25
+ };
26
+ }
27
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenobius/pi-worktrees",
3
- "version": "0.4.0-next.8",
3
+ "version": "0.5.0",
4
4
  "description": "Worktrees extension for Pi Coding Agent",
5
5
  "author": {
6
6
  "name": "Zenobius",