@zenobius/pi-worktrees 0.4.0-next.13 → 0.4.0-next.16

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.
@@ -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>;
@@ -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,13 @@ 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)
32
34
  }, {
33
35
  $id: "WorktreeSettingsConfig",
34
36
  additionalProperties: false
@@ -73,7 +75,7 @@ function globToRegExp(pattern) {
73
75
  const doubleStarReplaced = escaped.replace(/\*\*/g, "::DOUBLE_STAR::");
74
76
  const singleStarReplaced = doubleStarReplaced.replace(/\*/g, "[^/]*");
75
77
  const regexBody = singleStarReplaced.replace(/::DOUBLE_STAR::/g, ".*");
76
- return new RegExp(`^${regexBody}$`, "i");
78
+ return new RegExp(regexBody, "i");
77
79
  }
78
80
  function globMatch(input, pattern) {
79
81
  return globToRegExp(pattern).test(input);
@@ -340,11 +342,17 @@ async function cmdCd(args, ctx, deps) {
340
342
  }
341
343
 
342
344
  // src/cmds/cmdCreate.ts
343
- import { join as join3 } from "path";
345
+ import { basename as basename3, join as join3 } from "path";
344
346
 
345
347
  // src/cmds/shared.ts
346
348
  import { appendFileSync as appendFileSync2, writeFileSync } from "fs";
347
349
  import { spawn } from "child_process";
350
+ function sanitizePathPart(value) {
351
+ return value.replace(/[^a-zA-Z0-9._-]/g, "-");
352
+ }
353
+ function resolveLogfilePath(template, values) {
354
+ return template.replace(/\{\{sessionId\}\}|\{sessionId\}/g, values.sessionId).replace(/\{\{name\}\}|\{name\}/g, values.name).replace(/\{\{timestamp\}\}|\{timestamp\}/g, values.timestamp);
355
+ }
348
356
  var ANSI = {
349
357
  reset: "\x1B[0m",
350
358
  gray: "\x1B[90m",
@@ -411,8 +419,8 @@ function getDisplayLines(text, maxLines) {
411
419
  }
412
420
  return lines.slice(-maxLines);
413
421
  }
414
- function formatCommandList(commands, states, outputs, commandDisplay, logPath, displayOutputMaxLines = 5) {
415
- const lines = ["onCreate steps:"];
422
+ function formatCommandList(commands, states, outputs, commandDisplay, hookName, logPath, displayOutputMaxLines = 5) {
423
+ const lines = [`${hookName} steps:`];
416
424
  for (const [index, command] of commands.entries()) {
417
425
  const state = states[index];
418
426
  lines.push(formatCommandLine(command, state, commandDisplay));
@@ -483,18 +491,18 @@ function runCommand(command, cwd, onOutput) {
483
491
  });
484
492
  });
485
493
  }
486
- async function runOnCreateHook(createdCtx, settings, notify, options) {
487
- if (!settings.onCreate) {
494
+ async function runHook(createdCtx, hookValue, hookName, notify, options) {
495
+ if (!hookValue) {
488
496
  return { success: true, executed: [] };
489
497
  }
490
- const commandTemplates = Array.isArray(settings.onCreate) ? settings.onCreate : [settings.onCreate];
498
+ const commandTemplates = Array.isArray(hookValue) ? hookValue : [hookValue];
491
499
  const commands = commandTemplates.map((template) => expandTemplate(template, createdCtx));
492
500
  const executed = [];
493
501
  const commandStates = commands.map(() => "pending");
494
502
  const commandOutputs = commands.map(() => ({ stdout: "", stderr: "" }));
495
503
  if (options?.logPath) {
496
504
  writeFileSync(options.logPath, [
497
- `# pi-worktree onCreate log`,
505
+ `# pi-worktree ${hookName} log`,
498
506
  `# worktree: ${createdCtx.path}`,
499
507
  `# branch: ${createdCtx.branch}`,
500
508
  ""
@@ -510,13 +518,13 @@ async function runOnCreateHook(createdCtx, settings, notify, options) {
510
518
  successColor: options?.cmdDisplaySuccessColor ?? "success",
511
519
  errorColor: options?.cmdDisplayErrorColor ?? "error"
512
520
  };
513
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, undefined, displayOutputMaxLines), "info");
521
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, undefined, displayOutputMaxLines), "info");
514
522
  for (const [index, command] of commands.entries()) {
515
523
  commandStates[index] = "running";
516
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, undefined, displayOutputMaxLines), "info");
524
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, undefined, displayOutputMaxLines), "info");
517
525
  const result = await runCommand(command, createdCtx.path, (stream, chunk) => {
518
526
  commandOutputs[index][stream] += chunk;
519
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, undefined, displayOutputMaxLines), "info");
527
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, undefined, displayOutputMaxLines), "info");
520
528
  });
521
529
  if (options?.logPath) {
522
530
  appendCommandLog(options.logPath, command, result);
@@ -524,8 +532,8 @@ async function runOnCreateHook(createdCtx, settings, notify, options) {
524
532
  executed.push(command);
525
533
  if (!result.success) {
526
534
  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 ? `
535
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, options?.logPath, displayOutputMaxLines), "error");
536
+ notify(`${hookName} failed (exit ${result.code}): ${result.stderr.slice(0, 200)}${options?.logPath ? `
529
537
  log: ${options.logPath}` : ""}`, "error");
530
538
  return {
531
539
  success: false,
@@ -538,11 +546,14 @@ log: ${options.logPath}` : ""}`, "error");
538
546
  };
539
547
  }
540
548
  commandStates[index] = "success";
541
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, undefined, displayOutputMaxLines), "info");
549
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, undefined, displayOutputMaxLines), "info");
542
550
  }
543
- notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, options?.logPath, displayOutputMaxLines), "info");
551
+ notify(formatCommandList(commands, commandStates, commandOutputs, commandDisplay, hookName, options?.logPath, displayOutputMaxLines), "info");
544
552
  return { success: true, executed };
545
553
  }
554
+ async function runOnCreateHook(createdCtx, settings, notify, options) {
555
+ return runHook(createdCtx, settings.onCreate, "onCreate", notify, options);
556
+ }
546
557
 
547
558
  // src/services/config/config.ts
548
559
  import { createConfigService } from "@zenobius/pi-extension-config";
@@ -950,12 +961,6 @@ var DefaultWorktreeSettings = {
950
961
  var DefaultLogfileTemplate = DEFAULT_LOGFILE_TEMPLATE;
951
962
 
952
963
  // src/cmds/cmdCreate.ts
953
- function sanitizePathPart(value) {
954
- return value.replace(/[^a-zA-Z0-9._-]/g, "-");
955
- }
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);
958
- }
959
964
  async function cmdCreate(args, ctx, deps) {
960
965
  const featureName = args.trim();
961
966
  if (!featureName) {
@@ -969,9 +974,53 @@ async function cmdCreate(args, ctx, deps) {
969
974
  const current = deps.configService.current(ctx);
970
975
  const worktreePath = join3(current.parentDir, featureName);
971
976
  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");
977
+ const existingWorktree = listWorktrees(ctx.cwd).find((worktree) => worktree.path === worktreePath || basename3(worktree.path) === featureName || worktree.branch === branchName);
978
+ if (existingWorktree) {
979
+ if (!ctx.hasUI) {
980
+ ctx.ui.notify(`Worktree already exists at: ${worktreePath}`, "error");
981
+ return;
982
+ }
983
+ const confirmMessage = current.onSwitch ? `Path: ${existingWorktree.path}
984
+ Branch: ${existingWorktree.branch}
985
+
986
+ Switch to this worktree and run onSwitch?` : `Path: ${existingWorktree.path}
987
+ Branch: ${existingWorktree.branch}
988
+
989
+ Switch to this worktree?`;
990
+ const shouldSwitch = await ctx.ui.confirm("Worktree already exists", confirmMessage);
991
+ if (!shouldSwitch) {
992
+ ctx.ui.notify("Cancelled", "info");
993
+ return;
994
+ }
995
+ const existingCtx = {
996
+ path: existingWorktree.path,
997
+ name: basename3(existingWorktree.path),
998
+ branch: existingWorktree.branch,
999
+ ...current
1000
+ };
1001
+ const sessionId2 = sanitizePathPart(ctx.sessionManager?.getSessionId?.() || "session");
1002
+ const safeName2 = sanitizePathPart(existingCtx.name);
1003
+ const timestamp2 = new Date().toISOString().replace(/[:.]/g, "-");
1004
+ const logPath2 = resolveLogfilePath(current.logfile ?? DefaultLogfileTemplate, {
1005
+ sessionId: sessionId2,
1006
+ name: safeName2,
1007
+ timestamp: timestamp2
1008
+ });
1009
+ const result = await runHook(existingCtx, current.onSwitch, "onSwitch", ctx.ui.notify.bind(ctx.ui), {
1010
+ logPath: logPath2,
1011
+ displayOutputMaxLines: current.onCreateDisplayOutputMaxLines,
1012
+ cmdDisplayPending: current.onCreateCmdDisplayPending,
1013
+ cmdDisplaySuccess: current.onCreateCmdDisplaySuccess,
1014
+ cmdDisplayError: current.onCreateCmdDisplayError,
1015
+ cmdDisplayPendingColor: current.onCreateCmdDisplayPendingColor,
1016
+ cmdDisplaySuccessColor: current.onCreateCmdDisplaySuccessColor,
1017
+ cmdDisplayErrorColor: current.onCreateCmdDisplayErrorColor
1018
+ });
1019
+ if (!result.success) {
1020
+ ctx.ui.notify("onSwitch failed", "error");
1021
+ return;
1022
+ }
1023
+ ctx.ui.notify(`Worktree path: ${existingWorktree.path}`, "info");
975
1024
  return;
976
1025
  }
977
1026
  try {
@@ -980,10 +1029,14 @@ async function cmdCreate(args, ctx, deps) {
980
1029
  return;
981
1030
  } catch {}
982
1031
  ensureExcluded(ctx.cwd, current.parentDir);
983
- ctx.ui.notify(`Creating worktree: ${featureName}`, "info");
1032
+ const stopBusy = deps.statusService.busy(ctx, `Creating worktree: ${featureName}...`);
984
1033
  try {
985
1034
  git(["worktree", "add", "-b", branchName, worktreePath], current.mainWorktree);
1035
+ stopBusy();
1036
+ deps.statusService.positive(ctx, `Created: ${featureName}`);
986
1037
  } catch (err) {
1038
+ stopBusy();
1039
+ deps.statusService.critical(ctx, `Failed to create worktree`);
987
1040
  ctx.ui.notify(`Failed to create worktree: ${err.message}`, "error");
988
1041
  return;
989
1042
  }
@@ -1100,6 +1153,12 @@ ${finalConfig}`, "info");
1100
1153
  }
1101
1154
 
1102
1155
  // src/cmds/cmdList.ts
1156
+ import { basename as basename4 } from "path";
1157
+ function formatWorktreeOption(worktree) {
1158
+ const markers = [worktree.isMain ? "[main]" : "", worktree.isCurrent ? "[current]" : ""].filter(Boolean).join(" ");
1159
+ return `${worktree.branch}${markers ? " " + markers : ""}
1160
+ ${worktree.path}`;
1161
+ }
1103
1162
  var cmdList = async (_args, ctx, deps) => {
1104
1163
  if (!isGitRepo(ctx.cwd)) {
1105
1164
  ctx.ui.notify("Not in a git repository", "error");
@@ -1110,17 +1169,18 @@ var cmdList = async (_args, ctx, deps) => {
1110
1169
  ctx.ui.notify("No worktrees found", "info");
1111
1170
  return;
1112
1171
  }
1113
- const lines = worktrees.map((worktree) => {
1114
- const markers = [worktree.isMain ? "[main]" : "", worktree.isCurrent ? "[current]" : ""].filter(Boolean).join(" ");
1115
- return `${worktree.branch}${markers ? " " + markers : ""}
1172
+ if (!ctx.hasUI) {
1173
+ const lines = worktrees.map((worktree) => {
1174
+ const markers = [worktree.isMain ? "[main]" : "", worktree.isCurrent ? "[current]" : ""].filter(Boolean).join(" ");
1175
+ return `${worktree.branch}${markers ? " " + markers : ""}
1116
1176
  ${worktree.path}`;
1117
- });
1118
- const configured = Array.from(deps.configService.worktrees.entries()).map(([pattern, settings]) => {
1119
- return `${pattern}
1177
+ });
1178
+ const configured = Array.from(deps.configService.worktrees.entries()).map(([pattern, settings]) => {
1179
+ return `${pattern}
1120
1180
  ${settings.worktreeRoot ?? settings.parentDir}
1121
1181
  ${settings.onCreate}`;
1122
- });
1123
- ctx.ui.notify(`Worktrees:
1182
+ });
1183
+ ctx.ui.notify(`Worktrees:
1124
1184
 
1125
1185
  ${lines.join(`
1126
1186
 
@@ -1131,10 +1191,69 @@ Configured:
1131
1191
  ${configured.join(`
1132
1192
 
1133
1193
  `)}`, "info");
1194
+ return;
1195
+ }
1196
+ const options = worktrees.map(formatWorktreeOption);
1197
+ const byOption = new Map(options.map((option, index) => [option, worktrees[index]]));
1198
+ const selected = await ctx.ui.select("Select worktree to switch to", options);
1199
+ if (selected === undefined) {
1200
+ ctx.ui.notify("Cancelled", "info");
1201
+ return;
1202
+ }
1203
+ const target = byOption.get(selected);
1204
+ if (!target) {
1205
+ ctx.ui.notify("Invalid selection", "error");
1206
+ return;
1207
+ }
1208
+ const current = deps.configService.current({ cwd: target.path });
1209
+ if (!current.onSwitch) {
1210
+ ctx.ui.notify(`No onSwitch configured for: ${target.path}`, "info");
1211
+ return;
1212
+ }
1213
+ const sessionId = sanitizePathPart(ctx.sessionManager?.getSessionId?.() || "session");
1214
+ const safeName = sanitizePathPart(basename4(target.path));
1215
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1216
+ const logPath = resolveLogfilePath(current.logfile ?? DefaultLogfileTemplate, {
1217
+ sessionId,
1218
+ name: safeName,
1219
+ timestamp
1220
+ });
1221
+ const createdCtx = {
1222
+ path: target.path,
1223
+ name: basename4(target.path),
1224
+ branch: target.branch,
1225
+ project: current.project,
1226
+ mainWorktree: current.mainWorktree
1227
+ };
1228
+ const stopBusy = deps.statusService.busy(ctx, `Running onSwitch for ${target.branch}...`);
1229
+ try {
1230
+ const result = await runHook(createdCtx, current.onSwitch, "onSwitch", ctx.ui.notify.bind(ctx.ui), {
1231
+ logPath,
1232
+ displayOutputMaxLines: current.onCreateDisplayOutputMaxLines,
1233
+ cmdDisplayPending: current.onCreateCmdDisplayPending,
1234
+ cmdDisplaySuccess: current.onCreateCmdDisplaySuccess,
1235
+ cmdDisplayError: current.onCreateCmdDisplayError,
1236
+ cmdDisplayPendingColor: current.onCreateCmdDisplayPendingColor,
1237
+ cmdDisplaySuccessColor: current.onCreateCmdDisplaySuccessColor,
1238
+ cmdDisplayErrorColor: current.onCreateCmdDisplayErrorColor
1239
+ });
1240
+ if (!result.success) {
1241
+ stopBusy();
1242
+ deps.statusService.critical(ctx, `onSwitch failed`);
1243
+ ctx.ui.notify(`onSwitch failed`, "error");
1244
+ return;
1245
+ }
1246
+ stopBusy();
1247
+ deps.statusService.positive(ctx, `onSwitch complete: ${target.branch}`);
1248
+ } catch (err) {
1249
+ stopBusy();
1250
+ deps.statusService.critical(ctx, `onSwitch failed`);
1251
+ ctx.ui.notify(`onSwitch failed: ${err.message}`, "error");
1252
+ }
1134
1253
  };
1135
1254
 
1136
1255
  // src/cmds/cmdPrune.ts
1137
- async function cmdPrune(_args, ctx) {
1256
+ async function cmdPrune(_args, ctx, deps) {
1138
1257
  if (!isGitRepo(ctx.cwd)) {
1139
1258
  ctx.ui.notify("Not in a git repository", "error");
1140
1259
  return;
@@ -1157,18 +1276,23 @@ ${dryRun}`);
1157
1276
  ctx.ui.notify("Cancelled", "info");
1158
1277
  return;
1159
1278
  }
1279
+ const stopBusy = deps.statusService.busy(ctx, "Pruning stale worktrees...");
1160
1280
  try {
1161
1281
  git(["worktree", "prune"], ctx.cwd);
1282
+ stopBusy();
1283
+ deps.statusService.positive(ctx, "Pruned stale references");
1162
1284
  ctx.ui.notify("\u2713 Stale worktree references pruned", "info");
1163
1285
  } catch (err) {
1286
+ stopBusy();
1287
+ deps.statusService.critical(ctx, "Failed to prune");
1164
1288
  ctx.ui.notify(`Failed to prune: ${err.message}`, "error");
1165
1289
  }
1166
1290
  }
1167
1291
 
1168
1292
  // src/cmds/cmdRemove.ts
1169
- import { basename as basename3, join as join4 } from "path";
1293
+ import { basename as basename5, join as join4 } from "path";
1170
1294
  function findTarget(worktrees, worktreeName, parentDir) {
1171
- return worktrees.find((worktree) => basename3(worktree.path) === worktreeName || worktree.path === worktreeName || worktree.path === join4(parentDir, worktreeName));
1295
+ return worktrees.find((worktree) => basename5(worktree.path) === worktreeName || worktree.path === worktreeName || worktree.path === join4(parentDir, worktreeName));
1172
1296
  }
1173
1297
  function isProtectedWorktree(worktree) {
1174
1298
  return worktree.isMain || worktree.isCurrent;
@@ -1179,7 +1303,7 @@ async function pickWorktreeInteractively(ctx, worktrees) {
1179
1303
  ctx.ui.notify("No removable worktrees found", "info");
1180
1304
  return;
1181
1305
  }
1182
- const options = candidates.map((worktree) => `${basename3(worktree.path)} (${worktree.branch})
1306
+ const options = candidates.map((worktree) => `${basename5(worktree.path)} (${worktree.branch})
1183
1307
  ${worktree.path}`);
1184
1308
  const byOption = new Map(options.map((option, index) => [option, candidates[index]]));
1185
1309
  const selected = await ctx.ui.select("Select worktree to remove", options);
@@ -1189,7 +1313,7 @@ async function pickWorktreeInteractively(ctx, worktrees) {
1189
1313
  }
1190
1314
  return byOption.get(selected);
1191
1315
  }
1192
- async function removeWorktreeWithConfirm(ctx, cwd, target) {
1316
+ async function removeWorktreeWithConfirm(ctx, cwd, target, status, runBeforeRemove) {
1193
1317
  const confirmed = await ctx.ui.confirm("Remove worktree?", `This will remove:
1194
1318
  Path: ${target.path}
1195
1319
  Branch: ${target.branch}
@@ -1199,19 +1323,34 @@ The branch will NOT be deleted.`);
1199
1323
  ctx.ui.notify("Cancelled", "info");
1200
1324
  return;
1201
1325
  }
1326
+ if (runBeforeRemove) {
1327
+ const canContinue = await runBeforeRemove();
1328
+ if (!canContinue) {
1329
+ return;
1330
+ }
1331
+ }
1332
+ const stopBusy = status.busy(ctx, "Removing worktree...");
1202
1333
  try {
1203
1334
  git(["worktree", "remove", target.path], cwd);
1335
+ stopBusy();
1336
+ status.positive(ctx, `Removed: ${target.path}`);
1204
1337
  ctx.ui.notify(`\u2713 Worktree removed: ${target.path}`, "info");
1205
1338
  } catch {
1339
+ stopBusy();
1206
1340
  const forceConfirmed = await ctx.ui.confirm("Force remove?", "Worktree has uncommitted changes. Force remove anyway?");
1207
1341
  if (!forceConfirmed) {
1208
1342
  ctx.ui.notify("Cancelled", "info");
1209
1343
  return;
1210
1344
  }
1345
+ const stopForceBusy = status.busy(ctx, "Force removing worktree...");
1211
1346
  try {
1212
1347
  git(["worktree", "remove", "--force", target.path], cwd);
1348
+ stopForceBusy();
1349
+ status.positive(ctx, `Force removed: ${target.path}`);
1213
1350
  ctx.ui.notify(`\u2713 Worktree force removed: ${target.path}`, "info");
1214
1351
  } catch (forceErr) {
1352
+ stopForceBusy();
1353
+ status.critical(ctx, `Failed to remove`);
1215
1354
  ctx.ui.notify(`Failed to remove: ${forceErr.message}`, "error");
1216
1355
  }
1217
1356
  }
@@ -1249,7 +1388,38 @@ async function cmdRemove(args, ctx, deps) {
1249
1388
  return;
1250
1389
  }
1251
1390
  }
1252
- await removeWorktreeWithConfirm(ctx, ctx.cwd, target);
1391
+ const current = deps.configService.current({ cwd: target.path });
1392
+ await removeWorktreeWithConfirm(ctx, ctx.cwd, target, deps.statusService, async () => {
1393
+ if (!current.onBeforeRemove) {
1394
+ return true;
1395
+ }
1396
+ const hookCtx = {
1397
+ path: target.path,
1398
+ name: basename5(target.path),
1399
+ branch: target.branch,
1400
+ project: current.project,
1401
+ mainWorktree: current.mainWorktree
1402
+ };
1403
+ const sessionId = sanitizePathPart(ctx.sessionManager?.getSessionId?.() || "session");
1404
+ const safeName = sanitizePathPart(hookCtx.name);
1405
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1406
+ const logPath = resolveLogfilePath(current.logfile ?? DefaultLogfileTemplate, {
1407
+ sessionId,
1408
+ name: safeName,
1409
+ timestamp
1410
+ });
1411
+ const result = await runHook(hookCtx, current.onBeforeRemove, "onBeforeRemove", ctx.ui.notify.bind(ctx.ui), {
1412
+ logPath,
1413
+ displayOutputMaxLines: current.onCreateDisplayOutputMaxLines,
1414
+ cmdDisplayPending: current.onCreateCmdDisplayPending,
1415
+ cmdDisplaySuccess: current.onCreateCmdDisplaySuccess,
1416
+ cmdDisplayError: current.onCreateCmdDisplayError,
1417
+ cmdDisplayPendingColor: current.onCreateCmdDisplayPendingColor,
1418
+ cmdDisplaySuccessColor: current.onCreateCmdDisplaySuccessColor,
1419
+ cmdDisplayErrorColor: current.onCreateCmdDisplayErrorColor
1420
+ });
1421
+ return result.success;
1422
+ });
1253
1423
  }
1254
1424
 
1255
1425
  // src/cmds/cmdSettings.ts
@@ -1444,6 +1614,74 @@ function createCompletionFactory(commands) {
1444
1614
  };
1445
1615
  }
1446
1616
 
1617
+ // src/ui/status.ts
1618
+ class StatusIndicator {
1619
+ statusKey;
1620
+ busyStyle;
1621
+ busyFrames;
1622
+ progressStyle = "bars";
1623
+ progressFrames;
1624
+ constructor(statusKey, options = {
1625
+ busy: "dots",
1626
+ progress: "bars"
1627
+ }) {
1628
+ this.statusKey = statusKey;
1629
+ this.busyStyle = options.busy || "dots";
1630
+ this.busyFrames = StatusIndicator.busyStyles[this.busyStyle];
1631
+ this.progressStyle = options.progress || "bars";
1632
+ this.progressFrames = StatusIndicator.progressStyles[this.progressStyle];
1633
+ }
1634
+ busy(ctx, message) {
1635
+ if (typeof ctx.ui.setStatus !== "function") {
1636
+ return () => {};
1637
+ }
1638
+ let i = 0;
1639
+ ctx.ui.setStatus(this.statusKey, `${this.busyFrames[i]} ${message}`);
1640
+ const timer = globalThis.setInterval(() => {
1641
+ i = (i + 1) % this.busyFrames.length;
1642
+ ctx.ui.setStatus?.(this.statusKey, `${this.busyFrames[i]} ${message}`);
1643
+ }, 100);
1644
+ return () => {
1645
+ globalThis.clearInterval(timer);
1646
+ ctx.ui.setStatus?.(this.statusKey, undefined);
1647
+ };
1648
+ }
1649
+ cautious(ctx, message) {
1650
+ ctx.ui.setStatus?.(this.statusKey, `\u26A0\uFE0F ${message}`);
1651
+ }
1652
+ critical(ctx, message) {
1653
+ ctx.ui.setStatus?.(this.statusKey, `\u274C ${message}`);
1654
+ }
1655
+ positive(ctx, message) {
1656
+ ctx.ui.setStatus?.(this.statusKey, `\u2705 ${message}`);
1657
+ }
1658
+ informative(ctx, message) {
1659
+ ctx.ui.setStatus?.(this.statusKey, `\u2139\uFE0F ${message}`);
1660
+ }
1661
+ progress(ctx, message, percent) {
1662
+ const progressBar = this.progressFrames(percent);
1663
+ ctx.ui.setStatus?.(this.statusKey, `${progressBar} ${message}`);
1664
+ }
1665
+ static busyStyles = {
1666
+ dots: ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]
1667
+ };
1668
+ static progressStyles = {
1669
+ bars: (percent) => {
1670
+ const clampedPercent = Math.max(0, Math.min(100, percent));
1671
+ const progressBarLength = 20;
1672
+ const filledLength = Math.round(clampedPercent / 100 * progressBarLength);
1673
+ const emptyLength = progressBarLength - filledLength;
1674
+ return "\u2588".repeat(filledLength) + "\u2591".repeat(emptyLength);
1675
+ },
1676
+ pie: (percent) => {
1677
+ const clampedPercent = Math.max(0, Math.min(100, percent));
1678
+ const pieFrames = ["\u25CB", "\u25D4", "\u25D1", "\u25D5", "\u25CF"];
1679
+ const frameIndex = Math.floor(clampedPercent / 100 * (pieFrames.length - 1));
1680
+ return pieFrames[frameIndex];
1681
+ }
1682
+ };
1683
+ }
1684
+
1447
1685
  // src/index.ts
1448
1686
  var HELP_TEXT = `
1449
1687
  /worktree - Git worktree management
@@ -1452,19 +1690,21 @@ Commands:
1452
1690
  /worktree init Configure worktree settings interactively
1453
1691
  /worktree settings [key] [val] Get/set individual settings
1454
1692
  /worktree create <feature-name> Create new worktree with branch
1455
- /worktree list List all worktrees
1456
- /worktree remove <name> Remove a worktree
1693
+ /worktree list List worktrees and run onSwitch for a selection
1694
+ /worktree remove <name> Remove a worktree (runs onBeforeRemove if set)
1457
1695
  /worktree status Show current worktree info
1458
1696
  /worktree cd <name> Print path to worktree
1459
1697
  /worktree prune Clean up stale references
1460
1698
  /worktree templates Show template variables preview
1461
1699
 
1462
- Configuration (~/.pi/agent/pi-worktrees-settings.json):
1700
+ Configuration (~/.pi/agent/pi-worktrees.config.json):
1463
1701
  {
1464
1702
  "worktrees": {
1465
1703
  "github.com/org/repo": {
1466
1704
  "worktreeRoot": "~/work/org",
1467
- "onCreate": ["mise install", "bun install"]
1705
+ "onCreate": ["mise install", "bun install"],
1706
+ "onSwitch": "mise run dev:resume",
1707
+ "onBeforeRemove": "bun test"
1468
1708
  },
1469
1709
  "github.com/org/*": {
1470
1710
  "worktreeRoot": "~/work/org-other",
@@ -1490,7 +1730,8 @@ Pattern matching: exact URL > most-specific glob > fallback (worktree)
1490
1730
  Matching strategies: fail-on-tie | first-wins | last-wins
1491
1731
 
1492
1732
  Config note: parentDir is deprecated and supported as an alias for worktreeRoot.
1493
- Template vars: {{path}}, {{name}}, {{branch}}, {{project}}, {{mainWorktree}}
1733
+ Hook vars: {{path}}, {{name}}, {{branch}}, {{project}}, {{mainWorktree}}
1734
+ Hooks: onCreate (new), onSwitch (existing), onBeforeRemove (pre-delete, non-zero blocks)
1494
1735
  Logfile vars: {sessionId} / {{sessionId}}, {name} / {{name}}, {timestamp} / {{timestamp}}
1495
1736
  `.trim();
1496
1737
  var commands = {
@@ -1511,29 +1752,7 @@ var commands = {
1511
1752
  };
1512
1753
  var PiWorktreeExtension = async function(pi) {
1513
1754
  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
- });
1755
+ const statusService = new StatusIndicator("pi-worktree");
1537
1756
  const getSubcommandCompletions = createCompletionFactory(commands);
1538
1757
  pi.registerCommand("worktree", {
1539
1758
  description: "Git worktree management for isolated workspaces",
@@ -1552,7 +1771,8 @@ var PiWorktreeExtension = async function(pi) {
1552
1771
  const settings = configService.current(ctx);
1553
1772
  await command(rest.join(" "), ctx, {
1554
1773
  settings,
1555
- configService
1774
+ configService,
1775
+ statusService
1556
1776
  });
1557
1777
  } catch (error) {
1558
1778
  const message = error instanceof Error ? error.message : String(error);
@@ -4,6 +4,8 @@ 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;
7
9
  }>;
8
10
  current: (ctx: {
9
11
  cwd: string;
@@ -23,6 +25,8 @@ export declare function createPiWorktreeConfigService(): Promise<{
23
25
  matchedPattern: string | null;
24
26
  onCreate?: string | string[] | undefined;
25
27
  worktreeRoot?: string | undefined;
28
+ onSwitch?: string | string[] | undefined;
29
+ onBeforeRemove?: string | string[] | undefined;
26
30
  };
27
31
  save: (data: PiWorktreeConfig) => Promise<void>;
28
32
  config: {
@@ -31,6 +35,8 @@ export declare function createPiWorktreeConfigService(): Promise<{
31
35
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
32
36
  parentDir: import("typebox").TOptional<import("typebox").TString>;
33
37
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
38
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
39
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
34
40
  }>>>;
35
41
  matchingStrategy: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TLiteral<"fail-on-tie">, import("typebox").TLiteral<"first-wins">, import("typebox").TLiteral<"last-wins">]>>;
36
42
  logfile: import("typebox").TOptional<import("typebox").TString>;
@@ -45,6 +51,8 @@ export declare function createPiWorktreeConfigService(): Promise<{
45
51
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
46
52
  parentDir: import("typebox").TOptional<import("typebox").TString>;
47
53
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
54
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
55
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
48
56
  }>> | undefined;
49
57
  logfile?: string | undefined;
50
58
  onCreateDisplayOutputMaxLines?: number | undefined;
@@ -65,6 +73,8 @@ export declare function createPiWorktreeConfigService(): Promise<{
65
73
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
66
74
  parentDir: import("typebox").TOptional<import("typebox").TString>;
67
75
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
76
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
77
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
68
78
  }>>>;
69
79
  matchingStrategy: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TLiteral<"fail-on-tie">, import("typebox").TLiteral<"first-wins">, import("typebox").TLiteral<"last-wins">]>>;
70
80
  logfile: import("typebox").TOptional<import("typebox").TString>;
@@ -79,6 +89,8 @@ export declare function createPiWorktreeConfigService(): Promise<{
79
89
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
80
90
  parentDir: import("typebox").TOptional<import("typebox").TString>;
81
91
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
92
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
93
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
82
94
  }>> | undefined;
83
95
  logfile?: string | undefined;
84
96
  onCreateDisplayOutputMaxLines?: number | undefined;
@@ -3,6 +3,8 @@ 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>]>>;
6
8
  }>;
7
9
  declare const MatchingStrategySchema: import("typebox").TUnion<[import("typebox").TLiteral<"fail-on-tie">, import("typebox").TLiteral<"first-wins">, import("typebox").TLiteral<"last-wins">]>;
8
10
  declare const MatchStrategyResultSchema: import("typebox").TUnion<[import("typebox").TLiteral<"exact">, import("typebox").TLiteral<"unmatched">]>;
@@ -11,6 +13,8 @@ export declare const PiWorktreeConfigSchema: import("typebox").TObject<{
11
13
  worktreeRoot: import("typebox").TOptional<import("typebox").TString>;
12
14
  parentDir: import("typebox").TOptional<import("typebox").TString>;
13
15
  onCreate: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
16
+ onSwitch: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
17
+ onBeforeRemove: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TString, import("typebox").TArray<import("typebox").TString>]>>;
14
18
  }>>>;
15
19
  matchingStrategy: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TLiteral<"fail-on-tie">, import("typebox").TLiteral<"first-wins">, import("typebox").TLiteral<"last-wins">]>>;
16
20
  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.13",
3
+ "version": "0.4.0-next.16",
4
4
  "description": "Worktrees extension for Pi Coding Agent",
5
5
  "author": {
6
6
  "name": "Zenobius",