wave-code 0.19.8 → 1.0.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.
Files changed (37) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/components/ChatInterface.js +1 -1
  3. package/dist/components/HelpView.js +6 -0
  4. package/dist/components/InputBox.d.ts +2 -0
  5. package/dist/components/InputBox.js +11 -2
  6. package/dist/components/RewindCommand.js +4 -2
  7. package/dist/components/TaskList.js +28 -12
  8. package/dist/hooks/useInputManager.d.ts +1 -0
  9. package/dist/hooks/useInputManager.js +23 -20
  10. package/dist/index.js +1 -1
  11. package/dist/managers/inputHandlers.js +5 -8
  12. package/dist/managers/inputReducer.d.ts +10 -20
  13. package/dist/managers/inputReducer.js +309 -173
  14. package/dist/print-cli.js +36 -10
  15. package/dist/stdio/agentBridge.d.ts +6 -0
  16. package/dist/stdio/agentBridge.js +141 -1
  17. package/dist/stdio/protocol.d.ts +1 -1
  18. package/dist/utils/rewindCheckpoints.d.ts +8 -0
  19. package/dist/utils/rewindCheckpoints.js +15 -0
  20. package/dist/utils/worktree.d.ts +12 -2
  21. package/dist/utils/worktree.js +61 -25
  22. package/package.json +2 -2
  23. package/src/cli.tsx +1 -1
  24. package/src/components/ChatInterface.tsx +2 -0
  25. package/src/components/HelpView.tsx +6 -0
  26. package/src/components/InputBox.tsx +19 -0
  27. package/src/components/RewindCommand.tsx +4 -2
  28. package/src/components/TaskList.tsx +30 -12
  29. package/src/hooks/useInputManager.ts +27 -21
  30. package/src/index.ts +1 -1
  31. package/src/managers/inputHandlers.ts +6 -10
  32. package/src/managers/inputReducer.ts +381 -208
  33. package/src/print-cli.ts +48 -11
  34. package/src/stdio/agentBridge.ts +210 -0
  35. package/src/stdio/protocol.ts +6 -1
  36. package/src/utils/rewindCheckpoints.ts +15 -0
  37. package/src/utils/worktree.ts +99 -34
package/src/print-cli.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  hasUncommittedChanges,
5
5
  hasNewCommits,
6
6
  getDefaultRemoteBranch,
7
+ validateWorktreeRemovalPath,
7
8
  type WorktreeSession,
8
9
  } from "wave-agent-sdk";
9
10
  import { displayUsageSummary } from "./utils/usageSummary.js";
@@ -183,18 +184,18 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
183
184
  // Display timing information
184
185
  displayTimingInfo(startTime, showStats);
185
186
 
186
- // Destroy agent and exit after sendMessage completes
187
- await agent.destroy();
188
-
189
- // Handle worktree cleanup for print mode
187
+ // Trigger WorktreeRemove hook (before destroy it needs a live agent) and
188
+ // decide whether the worktree is clean enough to remove
189
+ let cleanWorktree = false;
190
190
  if (worktreeSession) {
191
191
  const cwd = workdir || worktreeSession.path;
192
192
  const baseBranch = getDefaultRemoteBranch(cwd);
193
193
  const hasChanges = hasUncommittedChanges(cwd);
194
194
  const hasCommits = hasNewCommits(cwd, baseBranch);
195
+ cleanWorktree = !hasChanges && !hasCommits;
195
196
 
196
- if (!hasChanges && !hasCommits) {
197
- removeWorktree(worktreeSession);
197
+ if (cleanWorktree) {
198
+ await agent.triggerWorktreeRemoveHook(worktreeSession.path);
198
199
  } else {
199
200
  process.stdout.write(
200
201
  `\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
@@ -202,6 +203,25 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
202
203
  }
203
204
  }
204
205
 
206
+ // Destroy agent and exit after sendMessage completes
207
+ await agent.destroy();
208
+
209
+ // Handle worktree cleanup for print mode (git removal stays after destroy)
210
+ if (worktreeSession && cleanWorktree) {
211
+ try {
212
+ validateWorktreeRemovalPath(
213
+ worktreeSession.path,
214
+ worktreeSession.repoRoot,
215
+ );
216
+ await removeWorktree(worktreeSession);
217
+ } catch (error) {
218
+ // Never block print-mode exit on worktree cleanup failures
219
+ process.stdout.write(
220
+ `\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`,
221
+ );
222
+ }
223
+ }
224
+
205
225
  process.exit(0);
206
226
  } catch (error) {
207
227
  console.error("Failed to send message:", error);
@@ -220,23 +240,40 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
220
240
  // Display timing information even on error
221
241
  displayTimingInfo(startTime, showStats);
222
242
 
223
- await agent.destroy();
224
-
225
- // Handle worktree cleanup for print mode even on error
243
+ // Trigger WorktreeRemove hook (before destroy) when the worktree is clean
244
+ let cleanWorktree = false;
226
245
  if (worktreeSession) {
227
246
  const cwd = workdir || worktreeSession.path;
228
247
  const baseBranch = getDefaultRemoteBranch(cwd);
229
248
  const hasChanges = hasUncommittedChanges(cwd);
230
249
  const hasCommits = hasNewCommits(cwd, baseBranch);
250
+ cleanWorktree = !hasChanges && !hasCommits;
231
251
 
232
- if (!hasChanges && !hasCommits) {
233
- removeWorktree(worktreeSession);
252
+ if (cleanWorktree) {
253
+ await agent.triggerWorktreeRemoveHook(worktreeSession.path);
234
254
  } else {
235
255
  process.stdout.write(
236
256
  `\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
237
257
  );
238
258
  }
239
259
  }
260
+
261
+ await agent.destroy();
262
+
263
+ // Handle worktree cleanup for print mode even on error
264
+ if (worktreeSession && cleanWorktree) {
265
+ try {
266
+ validateWorktreeRemovalPath(
267
+ worktreeSession.path,
268
+ worktreeSession.repoRoot,
269
+ );
270
+ await removeWorktree(worktreeSession);
271
+ } catch (error) {
272
+ process.stdout.write(
273
+ `\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`,
274
+ );
275
+ }
276
+ }
240
277
  }
241
278
  process.exit(1);
242
279
  }
@@ -34,9 +34,13 @@ import {
34
34
  type Scope,
35
35
  listSessions,
36
36
  searchFiles,
37
+ generateRandomName,
38
+ getDefaultRemoteBranch,
39
+ getMessageContent,
37
40
  PromptHistoryManager,
38
41
  AuthService,
39
42
  PluginCore,
43
+ validateWorktreeRemovalPath,
40
44
  type SlashCommand,
41
45
  } from "wave-agent-sdk";
42
46
  import {
@@ -44,7 +48,10 @@ import {
44
48
  INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
45
49
  METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
46
50
  } from "./protocol.js";
51
+ import { execFileSync } from "node:child_process";
52
+ import { createWorktree, removeWorktree } from "../utils/worktree.js";
47
53
  import { logger } from "../utils/logger.js";
54
+ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
48
55
 
49
56
  export type NotificationEmitter = (
50
57
  method: string,
@@ -72,6 +79,8 @@ interface InitializeParams {
72
79
  disallowedTools?: string[];
73
80
  pluginDirs?: string[];
74
81
  mcpServers?: Record<string, McpServerConfig>;
82
+ worktreeName?: string;
83
+ isNewWorktree?: boolean;
75
84
  }
76
85
 
77
86
  interface UpdateConfigParams {
@@ -163,6 +172,8 @@ export class AgentBridge {
163
172
  return this.clearMessages(sessionId);
164
173
  case "rewindToMessage":
165
174
  return this.rewindToMessage(p.messageId as string, sessionId);
175
+ case "listRewindCheckpoints":
176
+ return this.listRewindCheckpoints(sessionId);
166
177
  case "deleteQueuedMessage":
167
178
  return this.deleteQueuedMessage(p.index as number, sessionId);
168
179
  case "updateQueuedMessage":
@@ -250,6 +261,19 @@ export class AgentBridge {
250
261
  p.workdir as string | undefined,
251
262
  sessionId,
252
263
  );
264
+ case "getProjectSettings":
265
+ return this.getProjectSettings(
266
+ p.workdir as string | undefined,
267
+ sessionId,
268
+ );
269
+ case "setBuiltinPluginEnabled":
270
+ return this.setBuiltinPluginEnabled(
271
+ p.pluginId as string,
272
+ p.enabled as boolean,
273
+ p.scope as Scope | undefined,
274
+ p.workdir as string | undefined,
275
+ sessionId,
276
+ );
253
277
  case "updatePlugin":
254
278
  return this.updatePlugin(
255
279
  p.pluginId as string,
@@ -298,6 +322,22 @@ export class AgentBridge {
298
322
  case "stopWorkflowRun":
299
323
  return this.stopWorkflowRun(p.runId as string, sessionId);
300
324
 
325
+ // ── Git / worktree (global — no session required) ──
326
+ case "listGitBranches":
327
+ return this.listGitBranches(p.workdir as string | undefined);
328
+ case "createWorktree":
329
+ return this.createWorktreeSession(
330
+ p as unknown as {
331
+ workdir: string;
332
+ baseBranch?: string;
333
+ name?: string;
334
+ },
335
+ );
336
+ case "removeWorktree":
337
+ return this.removeWorktreeSession(
338
+ p as unknown as { path: string; branch: string; repoRoot: string },
339
+ );
340
+
301
341
  default:
302
342
  throw new RpcError(
303
343
  PROTOCOL_METHOD_NOT_FOUND,
@@ -349,6 +389,8 @@ export class AgentBridge {
349
389
  disallowedTools: params.disallowedTools,
350
390
  plugins: params.pluginDirs?.map((path) => ({ type: "local", path })),
351
391
  mcpServers: params.mcpServers,
392
+ worktreeName: params.worktreeName,
393
+ isNewWorktree: params.isNewWorktree,
352
394
  canUseTool: (context: ToolPermissionContext) =>
353
395
  this.canUseTool(context, ctx),
354
396
  };
@@ -400,6 +442,128 @@ export class AgentBridge {
400
442
  return { sessions };
401
443
  }
402
444
 
445
+ // ── Git / worktree ────────────────────────────────────────────
446
+
447
+ private listGitBranches(workdir?: string): {
448
+ branches: string[];
449
+ current: string | null;
450
+ } {
451
+ if (!workdir) {
452
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
453
+ }
454
+ const gitOpts = {
455
+ cwd: workdir,
456
+ encoding: "utf8" as const,
457
+ stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
458
+ };
459
+ let branchesRaw: string;
460
+ try {
461
+ branchesRaw = execFileSync(
462
+ "git",
463
+ ["for-each-ref", "--format=%(refname:short)", "refs/heads"],
464
+ gitOpts,
465
+ ).trim();
466
+ } catch {
467
+ throw new RpcError(
468
+ PROTOCOL_INTERNAL_ERROR,
469
+ `Not a git repository (or git unavailable): ${workdir}`,
470
+ );
471
+ }
472
+ const branches = branchesRaw
473
+ ? branchesRaw
474
+ .split("\n")
475
+ .map((b) => b.trim())
476
+ .filter(Boolean)
477
+ : [];
478
+ let current: string | null = null;
479
+ try {
480
+ const head = execFileSync(
481
+ "git",
482
+ ["rev-parse", "--abbrev-ref", "HEAD"],
483
+ gitOpts,
484
+ ).trim();
485
+ // Detached HEAD prints "HEAD" — treat as no current branch.
486
+ current = head && head !== "HEAD" ? head : null;
487
+ } catch {
488
+ current = null;
489
+ }
490
+ return { branches, current };
491
+ }
492
+
493
+ private async createWorktreeSession(params: {
494
+ workdir: string;
495
+ baseBranch?: string;
496
+ name?: string;
497
+ }): Promise<{
498
+ name: string;
499
+ path: string;
500
+ branch: string;
501
+ repoRoot: string;
502
+ baseBranch: string;
503
+ isNew: boolean;
504
+ }> {
505
+ if (!params.workdir) {
506
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
507
+ }
508
+ const name = params.name?.trim() || generateRandomName();
509
+ try {
510
+ const session = await createWorktree(name, params.workdir, {
511
+ baseBranch: params.baseBranch,
512
+ });
513
+ return {
514
+ name: session.name,
515
+ path: session.path,
516
+ branch: session.branch,
517
+ repoRoot: session.repoRoot,
518
+ baseBranch: params.baseBranch ?? getDefaultRemoteBranch(params.workdir),
519
+ isNew: session.isNew,
520
+ };
521
+ } catch (e) {
522
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, (e as Error).message);
523
+ }
524
+ }
525
+
526
+ private async removeWorktreeSession(params: {
527
+ path: string;
528
+ branch: string;
529
+ repoRoot: string;
530
+ }): Promise<{ ok: true }> {
531
+ // Align with Claude Code v2.1.216+: refuse to remove a worktree whose path
532
+ // is a symlink or resolves outside the repo root. Already-removed (missing)
533
+ // paths pass validation so removal stays idempotent.
534
+ try {
535
+ validateWorktreeRemovalPath(params.path, params.repoRoot);
536
+ } catch (e) {
537
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, (e as Error).message);
538
+ }
539
+
540
+ // Trigger the WorktreeRemove hook (before git removal, non-blocking) using
541
+ // the session that runs in this worktree, if it is still registered.
542
+ for (const entry of this.sessions.values()) {
543
+ if (entry.agent.workingDirectory === params.path) {
544
+ try {
545
+ await entry.agent.triggerWorktreeRemoveHook(params.path);
546
+ } catch (e) {
547
+ logger.warn("WorktreeRemove hooks execution failed:", e);
548
+ }
549
+ break;
550
+ }
551
+ }
552
+
553
+ // removeWorktree is best-effort/idempotent: already-removed worktrees or
554
+ // branches only log, never throw.
555
+ await removeWorktree({
556
+ name: "",
557
+ path: params.path,
558
+ branch: params.branch,
559
+ repoRoot: params.repoRoot,
560
+ hasUncommittedChanges: false,
561
+ hasNewCommits: false,
562
+ isNew: false,
563
+ });
564
+ return { ok: true };
565
+ }
566
+
403
567
  private getSessionInfo(sessionId?: string): {
404
568
  sessionId: string;
405
569
  workingDirectory: string;
@@ -450,6 +614,9 @@ export class AgentBridge {
450
614
  path: p,
451
615
  })),
452
616
  mcpServers: entry.storedConfig.mcpServers,
617
+ // Keep worktree context (permission safety) across recreation, but never
618
+ // re-fire WorktreeCreate — the hook ran at initial creation.
619
+ worktreeName: entry.storedConfig.worktreeName,
453
620
  canUseTool: (context: ToolPermissionContext) =>
454
621
  this.canUseTool(context, ctx),
455
622
  };
@@ -535,6 +702,20 @@ export class AgentBridge {
535
702
  return { inputContent: textBlock?.content || "" };
536
703
  }
537
704
 
705
+ private async listRewindCheckpoints(sessionId?: string): Promise<{
706
+ checkpoints: Array<{ id: string; content: string }>;
707
+ }> {
708
+ const entry = this.requireSession(sessionId);
709
+ const { messages } = await entry.agent.getFullMessageThread();
710
+ const checkpoints = messages
711
+ .filter((m) => isUserCheckpointMessage(m) && m.id)
712
+ .map((m) => ({
713
+ id: m.id as string,
714
+ content: getMessageContent(m).replace(/\s+/g, " ").trim(),
715
+ }));
716
+ return { checkpoints };
717
+ }
718
+
538
719
  private deleteQueuedMessage(index: number, sessionId?: string): null {
539
720
  const entry = this.requireSession(sessionId);
540
721
  entry.agent.removeQueuedMessage(index);
@@ -837,6 +1018,35 @@ export class AgentBridge {
837
1018
  );
838
1019
  }
839
1020
 
1021
+ private async getProjectSettings(
1022
+ workdir?: string,
1023
+ sessionId?: string,
1024
+ ): Promise<{ enabledPlugins: Record<string, boolean> }> {
1025
+ return {
1026
+ enabledPlugins: this.getPluginCore(
1027
+ workdir,
1028
+ sessionId,
1029
+ ).getMergedEnabledPlugins(),
1030
+ };
1031
+ }
1032
+
1033
+ private async setBuiltinPluginEnabled(
1034
+ pluginId: string,
1035
+ enabled: boolean,
1036
+ scope: Scope | undefined,
1037
+ workdir?: string,
1038
+ sessionId?: string,
1039
+ ): Promise<{ enabledPlugins: Record<string, boolean> }> {
1040
+ const core = this.getPluginCore(workdir, sessionId);
1041
+ const targetScope = scope ?? "project";
1042
+ if (enabled) {
1043
+ await core.enablePlugin(pluginId, targetScope);
1044
+ } else {
1045
+ await core.disablePlugin(pluginId, targetScope);
1046
+ }
1047
+ return { enabledPlugins: core.getMergedEnabledPlugins() };
1048
+ }
1049
+
840
1050
  private async updatePlugin(
841
1051
  pluginId: string,
842
1052
  workdir?: string,
@@ -57,6 +57,7 @@ export type RequestMethod =
57
57
  | "abortMessage"
58
58
  | "clearMessages"
59
59
  | "rewindToMessage"
60
+ | "listRewindCheckpoints"
60
61
  | "deleteQueuedMessage"
61
62
  | "updateQueuedMessage"
62
63
  | "deleteQueuedMessageById"
@@ -91,7 +92,11 @@ export type RequestMethod =
91
92
  | "getBackgroundTaskOutput"
92
93
  | "stopBackgroundTask"
93
94
  | "getWorkflowRuns"
94
- | "stopWorkflowRun";
95
+ | "stopWorkflowRun"
96
+ // Git / worktree (global — no session required)
97
+ | "listGitBranches"
98
+ | "createWorktree"
99
+ | "removeWorktree";
95
100
 
96
101
  // ── Client → Server notification methods ────────────────────────
97
102
 
@@ -0,0 +1,15 @@
1
+ import type { Message } from "wave-agent-sdk";
2
+
3
+ /**
4
+ * 判断一条 user 消息能否作为 /rewind 检查点。
5
+ * 后台任务通知(task_notification)与 hook 注入的消息(source: "hook")
6
+ * 都是系统生成、用户不可见的,不能作为回滚点。
7
+ * CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
8
+ */
9
+ export function isUserCheckpointMessage(m: Message): boolean {
10
+ if (m.role !== "user" || m.isMeta) return false;
11
+ if (m.blocks.some((b) => b.type === "task_notification")) return false;
12
+ if (m.blocks.some((b) => b.type === "text" && b.source === "hook"))
13
+ return false;
14
+ return true;
15
+ }
@@ -1,7 +1,17 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
2
3
  import * as path from "node:path";
3
4
  import * as fs from "node:fs";
4
- import { getDefaultRemoteBranch, getGitMainRepoRoot } from "wave-agent-sdk";
5
+ import {
6
+ getDefaultRemoteBranch,
7
+ getGitMainRepoRoot,
8
+ performPostCreationSetup,
9
+ } from "wave-agent-sdk";
10
+
11
+ // Never use execFileSync here: the shared `wave --stdio` process handles all
12
+ // desktop sessions, so a synchronous git call (especially a multi-second
13
+ // recursive worktree delete or a network fetch) freezes every session.
14
+ const execFileAsync = promisify(execFile);
5
15
 
6
16
  export interface WorktreeSession {
7
17
  name: string;
@@ -13,24 +23,71 @@ export interface WorktreeSession {
13
23
  isNew: boolean;
14
24
  }
15
25
 
26
+ // --- Worktree name validation ------------------------------------------------
27
+
28
+ const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/;
29
+ const MAX_WORKTREE_SLUG_LENGTH = 64;
30
+
31
+ /**
32
+ * Validate a worktree name before any side effects. Names are slash-separated
33
+ * slugs: every segment must be non-empty and contain only letters, digits,
34
+ * dots, underscores, and dashes. The total length is capped at 64 characters
35
+ * and "." / ".." segments are rejected (path traversal protection).
36
+ * @throws {Error} When the name is not a valid slug
37
+ */
38
+ export function validateWorktreeSlug(name: string): void {
39
+ if (name.length > MAX_WORKTREE_SLUG_LENGTH) {
40
+ throw new Error(
41
+ `Invalid worktree name: "${name}" must be ${MAX_WORKTREE_SLUG_LENGTH} characters or fewer (got ${name.length})`,
42
+ );
43
+ }
44
+ for (const segment of name.split("/")) {
45
+ if (segment === "." || segment === "..") {
46
+ throw new Error(
47
+ `Invalid worktree name: "${name}" must not contain "." or ".." path segments`,
48
+ );
49
+ }
50
+ if (segment.length === 0 || !VALID_WORKTREE_SLUG_SEGMENT.test(segment)) {
51
+ throw new Error(
52
+ `Invalid worktree name: "${name}" each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`,
53
+ );
54
+ }
55
+ }
56
+ }
57
+
16
58
  /**
17
59
  * Create a new git worktree
18
60
  * @param name Worktree name
19
61
  * @param cwd Current working directory
20
62
  * @param options Optional creation options
21
63
  * @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
64
+ * @param options.baseBranch Explicit base branch (overrides baseRef)
22
65
  * @returns Worktree session details
23
66
  */
24
- export function createWorktree(
67
+ export async function createWorktree(
68
+ name: string,
69
+ cwd: string,
70
+ options?: { baseRef?: "fresh" | "head"; baseBranch?: string },
71
+ ): Promise<WorktreeSession> {
72
+ validateWorktreeSlug(name);
73
+ const session = await createWorktreeInternal(name, cwd, options);
74
+ if (session.isNew) {
75
+ await performPostCreationSetup(session.path, session.repoRoot);
76
+ }
77
+ return session;
78
+ }
79
+
80
+ async function createWorktreeInternal(
25
81
  name: string,
26
82
  cwd: string,
27
- options?: { baseRef?: "fresh" | "head" },
28
- ): WorktreeSession {
83
+ options?: { baseRef?: "fresh" | "head"; baseBranch?: string },
84
+ ): Promise<WorktreeSession> {
29
85
  const repoRoot = getGitMainRepoRoot(cwd);
30
86
  const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
31
87
  const branchName = `worktree-${name}`;
32
88
  const useHead = options?.baseRef === "head";
33
- const baseBranch = useHead ? "HEAD" : getDefaultRemoteBranch(cwd);
89
+ const resolvedBaseBranch =
90
+ options?.baseBranch ?? (useHead ? "HEAD" : getDefaultRemoteBranch(cwd));
34
91
 
35
92
  // Ensure parent directory exists
36
93
  const parentDir = path.dirname(worktreePath);
@@ -54,12 +111,11 @@ export function createWorktree(
54
111
 
55
112
  try {
56
113
  // Create worktree and branch
57
- execFileSync(
114
+ await execFileAsync(
58
115
  "git",
59
- ["worktree", "add", "-b", branchName, worktreePath, baseBranch],
116
+ ["worktree", "add", "-b", branchName, worktreePath, resolvedBaseBranch],
60
117
  {
61
118
  cwd: repoRoot,
62
- stdio: ["ignore", "pipe", "pipe"],
63
119
  },
64
120
  );
65
121
 
@@ -73,14 +129,18 @@ export function createWorktree(
73
129
  isNew: true,
74
130
  };
75
131
  } catch (error: unknown) {
76
- const stderr = (error as { stderr?: Buffer }).stderr?.toString() || "";
132
+ const stderr =
133
+ (error as { stderr?: Buffer | string }).stderr?.toString() || "";
77
134
  if (stderr.includes("already exists")) {
78
135
  // If branch already exists, try to add worktree without -b
79
136
  try {
80
- execFileSync("git", ["worktree", "add", worktreePath, branchName], {
81
- cwd: repoRoot,
82
- stdio: ["ignore", "pipe", "pipe"],
83
- });
137
+ await execFileAsync(
138
+ "git",
139
+ ["worktree", "add", worktreePath, branchName],
140
+ {
141
+ cwd: repoRoot,
142
+ },
143
+ );
84
144
  return {
85
145
  name,
86
146
  path: worktreePath,
@@ -102,18 +162,23 @@ export function createWorktree(
102
162
  stderr.includes("unknown revision"))
103
163
  ) {
104
164
  // Base branch not fetched yet — try fetching then retrying
105
- const branchNameOnly = baseBranch.split("/").pop()!;
165
+ const branchNameOnly = resolvedBaseBranch.split("/").pop()!;
106
166
  try {
107
- execFileSync("git", ["fetch", "origin", branchNameOnly], {
167
+ await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
108
168
  cwd: repoRoot,
109
- stdio: ["ignore", "pipe", "pipe"],
110
169
  });
111
- execFileSync(
170
+ await execFileAsync(
112
171
  "git",
113
- ["worktree", "add", "-b", branchName, worktreePath, baseBranch],
172
+ [
173
+ "worktree",
174
+ "add",
175
+ "-b",
176
+ branchName,
177
+ worktreePath,
178
+ resolvedBaseBranch,
179
+ ],
114
180
  {
115
181
  cwd: repoRoot,
116
- stdio: ["ignore", "pipe", "pipe"],
117
182
  },
118
183
  );
119
184
  return {
@@ -128,12 +193,11 @@ export function createWorktree(
128
193
  } catch {
129
194
  // Fetch or retry failed — fall back to HEAD
130
195
  try {
131
- execFileSync(
196
+ await execFileAsync(
132
197
  "git",
133
198
  ["worktree", "add", "-b", branchName, worktreePath, "HEAD"],
134
199
  {
135
200
  cwd: repoRoot,
136
- stdio: ["ignore", "pipe", "pipe"],
137
201
  },
138
202
  );
139
203
  return {
@@ -162,37 +226,39 @@ export function createWorktree(
162
226
  * Remove a git worktree and its associated branch
163
227
  * @param session Worktree session details
164
228
  */
165
- export function removeWorktree(session: WorktreeSession): void {
229
+ export async function removeWorktree(session: WorktreeSession): Promise<void> {
166
230
  const repoRoot = session.repoRoot;
167
231
 
168
232
  try {
169
233
  // Get current branch in worktree before removing it
170
234
  let currentBranch: string | undefined;
171
235
  try {
172
- currentBranch = execFileSync(
236
+ const { stdout } = await execFileAsync(
173
237
  "git",
174
238
  ["rev-parse", "--abbrev-ref", "HEAD"],
175
239
  {
176
240
  cwd: session.path,
177
241
  encoding: "utf8",
178
- stdio: ["ignore", "pipe", "ignore"],
179
242
  },
180
- ).trim();
243
+ );
244
+ currentBranch = stdout.trim();
181
245
  } catch {
182
246
  // Ignore errors getting current branch
183
247
  }
184
248
 
185
249
  // Remove worktree
186
- execFileSync("git", ["worktree", "remove", "--force", session.path], {
187
- cwd: repoRoot,
188
- stdio: ["ignore", "pipe", "pipe"],
189
- });
250
+ await execFileAsync(
251
+ "git",
252
+ ["worktree", "remove", "--force", session.path],
253
+ {
254
+ cwd: repoRoot,
255
+ },
256
+ );
190
257
 
191
258
  // Delete original branch
192
259
  try {
193
- execFileSync("git", ["branch", "-D", session.branch], {
260
+ await execFileAsync("git", ["branch", "-D", session.branch], {
194
261
  cwd: repoRoot,
195
- stdio: ["ignore", "pipe", "pipe"],
196
262
  });
197
263
  } catch {
198
264
  // Ignore errors deleting original branch
@@ -213,9 +279,8 @@ export function removeWorktree(session: WorktreeSession): void {
213
279
  currentBranch !== "master"
214
280
  ) {
215
281
  try {
216
- execFileSync("git", ["branch", "-D", currentBranch], {
282
+ await execFileAsync("git", ["branch", "-D", currentBranch], {
217
283
  cwd: repoRoot,
218
- stdio: ["ignore", "pipe", "pipe"],
219
284
  });
220
285
  } catch {
221
286
  // Ignore errors deleting current branch