opencode-worktree 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,10 +4,15 @@ Terminal UI for managing git worktrees and launching `opencode` in the selected
4
4
 
5
5
  ## Features
6
6
 
7
- - Lists all worktrees with branch, path, and short HEAD
7
+ - Lists all worktrees with branch, path, and metadata
8
+ - Worktree metadata display: last edited time, dirty status, remote tracking
9
+ - Status indicators: `[main]` for main worktree, `[*]` for uncommitted changes, `[local]` for local-only branches
8
10
  - Create new worktrees directly from the TUI
11
+ - Post-create hooks: automatically run commands (e.g., `npm install`) after creating a worktree
12
+ - Open worktree folder in file manager
9
13
  - Unlink worktrees (remove directory, keep branch)
10
14
  - Delete worktrees and local branches (never remote)
15
+ - Multi-select delete mode for batch deletion
11
16
  - Launches `opencode` in the selected worktree
12
17
  - Refresh list on demand
13
18
 
@@ -37,11 +42,58 @@ opencode-worktree /path/to/your/repo
37
42
  ## Keybindings
38
43
 
39
44
  - `Up`/`Down` or `j`/`k`: navigate
40
- - `Enter`: open selected worktree
41
- - `d`: unlink/delete menu
45
+ - `Enter`: open selected worktree in opencode (or toggle selection in delete mode)
46
+ - `o`: open worktree folder in file manager (Finder/Explorer)
47
+ - `d`: enter multi-select delete mode (press again to confirm deletion)
42
48
  - `n`: create new worktree
49
+ - `c`: edit configuration (post-create hooks)
43
50
  - `r`: refresh list
44
- - `q` or `Esc`: quit (or cancel dialogs)
51
+ - `q` or `Esc`: quit (or cancel dialogs/modes)
52
+
53
+ ### Multi-select delete mode
54
+
55
+ 1. Press `d` to enter selection mode
56
+ 2. Navigate with arrow keys and press `Enter` to toggle worktrees for deletion
57
+ 3. Press `d` again to confirm and choose unlink/delete action
58
+ 4. Press `Esc` to cancel and return to normal mode
59
+
60
+ ## Configuration
61
+
62
+ You can configure per-repository settings by creating a `.opencode-worktree.json` file in your repository root.
63
+
64
+ ### First-time setup
65
+
66
+ When you first run `opencode-worktree` in a repository without a configuration file, you'll be prompted to configure a post-create hook. You can also skip this step and configure it later by pressing `c`.
67
+
68
+ ### Editing configuration
69
+
70
+ Press `c` at any time to edit your configuration. Currently, this allows you to set or modify the post-create hook command.
71
+
72
+ ### Post-create hooks
73
+
74
+ Run a command automatically after creating a new worktree. Useful for installing dependencies.
75
+
76
+ ```json
77
+ {
78
+ "postCreateHook": "npm install"
79
+ }
80
+ ```
81
+
82
+ The hook output is streamed to the TUI in real-time. If the hook fails, you can choose to open opencode anyway or cancel.
83
+
84
+ **Examples:**
85
+
86
+ ```json
87
+ {
88
+ "postCreateHook": "bun install"
89
+ }
90
+ ```
91
+
92
+ ```json
93
+ {
94
+ "postCreateHook": "npm install && npm run setup"
95
+ }
96
+ ```
45
97
 
46
98
  ## Update notifications
47
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worktree",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "TUI for managing git worktrees with opencode integration.",
@@ -28,7 +28,8 @@
28
28
  "dev": "bun src/cli.ts",
29
29
  "build:single": "bun run script/build.ts --single",
30
30
  "build:all": "bun run script/build.ts",
31
- "release:publish": "bun run script/publish.ts"
31
+ "release:publish": "bun run script/publish.ts",
32
+ "postinstall": "node ./script/postinstall.mjs"
32
33
  },
33
34
  "files": [
34
35
  "bin",
@@ -77,6 +77,10 @@ const installBinary = async () => {
77
77
  };
78
78
 
79
79
  try {
80
+ if (process.env.CI) {
81
+ console.log("CI detected, skipping binary download.");
82
+ process.exit(0);
83
+ }
80
84
  const binaryPath = await installBinary();
81
85
  console.log(`opencode-worktree binary installed at: ${binaryPath}`);
82
86
  } catch (error) {
package/src/config.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export type Config = {
5
+ postCreateHook?: string;
6
+ };
7
+
8
+ const CONFIG_FILENAME = ".opencode-worktree.json";
9
+
10
+ /**
11
+ * Get the path to the config file for a repo
12
+ */
13
+ export const getConfigPath = (repoRoot: string): string => {
14
+ return join(repoRoot, CONFIG_FILENAME);
15
+ };
16
+
17
+ /**
18
+ * Check if a config file exists for the repo
19
+ */
20
+ export const configExists = (repoRoot: string): boolean => {
21
+ return existsSync(getConfigPath(repoRoot));
22
+ };
23
+
24
+ /**
25
+ * Load per-repo configuration from .opencode-worktree.json in the repo root
26
+ */
27
+ export const loadRepoConfig = (repoRoot: string): Config => {
28
+ const configPath = getConfigPath(repoRoot);
29
+
30
+ if (!existsSync(configPath)) {
31
+ return {};
32
+ }
33
+
34
+ try {
35
+ const content = readFileSync(configPath, "utf8");
36
+ const parsed = JSON.parse(content);
37
+
38
+ // Validate the config structure
39
+ if (typeof parsed !== "object" || parsed === null) {
40
+ return {};
41
+ }
42
+
43
+ const config: Config = {};
44
+
45
+ if (typeof parsed.postCreateHook === "string") {
46
+ config.postCreateHook = parsed.postCreateHook;
47
+ }
48
+
49
+ return config;
50
+ } catch {
51
+ // If we can't read or parse the config, return empty
52
+ return {};
53
+ }
54
+ };
55
+
56
+ /**
57
+ * Save configuration to .opencode-worktree.json in the repo root
58
+ */
59
+ export const saveRepoConfig = (repoRoot: string, config: Config): boolean => {
60
+ const configPath = getConfigPath(repoRoot);
61
+
62
+ try {
63
+ const content = JSON.stringify(config, null, 2) + "\n";
64
+ writeFileSync(configPath, content, "utf8");
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ };
package/src/git.ts CHANGED
@@ -17,11 +17,19 @@ export const resolveRepoRoot = (cwd: string): string | null => {
17
17
  export const parseWorktreeList = (output: string): WorktreeInfo[] => {
18
18
  const lines = output.split(/\r?\n/);
19
19
  const worktrees: WorktreeInfo[] = [];
20
- let current: WorktreeInfo | null = null;
20
+ let current: Partial<WorktreeInfo> | null = null;
21
21
 
22
22
  const pushCurrent = (): void => {
23
23
  if (current?.path) {
24
- worktrees.push(current);
24
+ worktrees.push({
25
+ path: current.path,
26
+ head: current.head || "",
27
+ branch: current.branch || null,
28
+ isDetached: current.isDetached || false,
29
+ isDirty: false,
30
+ isOnRemote: false,
31
+ lastModified: null,
32
+ });
25
33
  }
26
34
  };
27
35
 
@@ -71,7 +79,10 @@ export const listWorktrees = (cwd: string): WorktreeInfo[] => {
71
79
  stdio: ["ignore", "pipe", "ignore"],
72
80
  encoding: "utf8",
73
81
  });
74
- return parseWorktreeList(output);
82
+ const worktrees = parseWorktreeList(output);
83
+
84
+ // Enrich each worktree with metadata
85
+ return worktrees.map((wt) => enrichWorktreeInfo(repoRoot, wt));
75
86
  };
76
87
 
77
88
  export type CreateWorktreeResult =
@@ -133,6 +144,68 @@ export const hasUncommittedChanges = (worktreePath: string): boolean => {
133
144
  }
134
145
  };
135
146
 
147
+ /**
148
+ * Get the last commit date for a worktree
149
+ */
150
+ export const getLastCommitDate = (worktreePath: string): Date | null => {
151
+ try {
152
+ const output = execFileSync(
153
+ "git",
154
+ ["log", "-1", "--format=%ci"],
155
+ {
156
+ cwd: worktreePath,
157
+ stdio: ["ignore", "pipe", "ignore"],
158
+ encoding: "utf8",
159
+ }
160
+ );
161
+ const dateStr = output.trim();
162
+ if (!dateStr) return null;
163
+ return new Date(dateStr);
164
+ } catch {
165
+ return null;
166
+ }
167
+ };
168
+
169
+ /**
170
+ * Check if a branch exists on remote (origin)
171
+ */
172
+ export const isBranchOnRemote = (
173
+ repoRoot: string,
174
+ branchName: string
175
+ ): boolean => {
176
+ try {
177
+ const output = execFileSync(
178
+ "git",
179
+ ["branch", "-r", "--list", `origin/${branchName}`],
180
+ {
181
+ cwd: repoRoot,
182
+ stdio: ["ignore", "pipe", "ignore"],
183
+ encoding: "utf8",
184
+ }
185
+ );
186
+ return output.trim().length > 0;
187
+ } catch {
188
+ return false;
189
+ }
190
+ };
191
+
192
+ /**
193
+ * Enrich worktree info with metadata (dirty status, remote status, last modified)
194
+ */
195
+ export const enrichWorktreeInfo = (
196
+ repoRoot: string,
197
+ worktree: WorktreeInfo
198
+ ): WorktreeInfo => {
199
+ return {
200
+ ...worktree,
201
+ isDirty: hasUncommittedChanges(worktree.path),
202
+ isOnRemote: worktree.branch
203
+ ? isBranchOnRemote(repoRoot, worktree.branch)
204
+ : false,
205
+ lastModified: getLastCommitDate(worktree.path),
206
+ };
207
+ };
208
+
136
209
  /**
137
210
  * Check if a worktree is the main worktree (the original repo clone)
138
211
  */
package/src/hooks.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export type HookResult = {
4
+ success: boolean;
5
+ exitCode: number | null;
6
+ };
7
+
8
+ export type HookCallbacks = {
9
+ onOutput: (data: string) => void;
10
+ onComplete: (result: HookResult) => void;
11
+ };
12
+
13
+ /**
14
+ * Run a post-create hook command with streaming output
15
+ * Returns a function to abort the hook if needed
16
+ */
17
+ export const runPostCreateHook = (
18
+ worktreePath: string,
19
+ command: string,
20
+ callbacks: HookCallbacks
21
+ ): (() => void) => {
22
+ const shell = process.platform === "win32" ? "cmd" : "/bin/sh";
23
+ const shellFlag = process.platform === "win32" ? "/c" : "-c";
24
+
25
+ const child = spawn(shell, [shellFlag, command], {
26
+ cwd: worktreePath,
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ env: { ...process.env },
29
+ });
30
+
31
+ // Stream stdout
32
+ child.stdout?.on("data", (data: Buffer) => {
33
+ callbacks.onOutput(data.toString());
34
+ });
35
+
36
+ // Stream stderr
37
+ child.stderr?.on("data", (data: Buffer) => {
38
+ callbacks.onOutput(data.toString());
39
+ });
40
+
41
+ // Handle completion
42
+ child.on("close", (code: number | null) => {
43
+ callbacks.onComplete({
44
+ success: code === 0,
45
+ exitCode: code,
46
+ });
47
+ });
48
+
49
+ // Handle errors
50
+ child.on("error", (err: Error) => {
51
+ callbacks.onOutput(`Error: ${err.message}\n`);
52
+ callbacks.onComplete({
53
+ success: false,
54
+ exitCode: null,
55
+ });
56
+ });
57
+
58
+ // Return abort function
59
+ return () => {
60
+ child.kill("SIGTERM");
61
+ };
62
+ };
package/src/opencode.ts CHANGED
@@ -18,3 +18,34 @@ export const launchOpenCode = (cwd: string): void => {
18
18
  process.exit(exitCode);
19
19
  });
20
20
  };
21
+
22
+ /**
23
+ * Open a path in the system file manager (Finder on macOS, xdg-open on Linux, explorer on Windows)
24
+ */
25
+ export const openInFileManager = (path: string): boolean => {
26
+ const platform = process.platform;
27
+ let command: string;
28
+ let args: string[];
29
+
30
+ if (platform === "darwin") {
31
+ command = "open";
32
+ args = [path];
33
+ } else if (platform === "win32") {
34
+ command = "explorer";
35
+ args = [path];
36
+ } else {
37
+ // Linux and others
38
+ command = "xdg-open";
39
+ args = [path];
40
+ }
41
+
42
+ try {
43
+ spawn(command, args, {
44
+ detached: true,
45
+ stdio: "ignore",
46
+ }).unref();
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ };
package/src/types.ts CHANGED
@@ -3,4 +3,8 @@ export type WorktreeInfo = {
3
3
  head: string;
4
4
  branch: string | null;
5
5
  isDetached: boolean;
6
+ // Metadata
7
+ isDirty: boolean;
8
+ isOnRemote: boolean;
9
+ lastModified: Date | null;
6
10
  };
package/src/ui.ts CHANGED
@@ -21,8 +21,10 @@ import {
21
21
  resolveRepoRoot,
22
22
  unlinkWorktree,
23
23
  } from "./git.js";
24
- import { isOpenCodeAvailable, launchOpenCode } from "./opencode.js";
24
+ import { isOpenCodeAvailable, launchOpenCode, openInFileManager } from "./opencode.js";
25
25
  import { WorktreeInfo } from "./types.js";
26
+ import { loadRepoConfig, saveRepoConfig, configExists, type Config } from "./config.js";
27
+ import { runPostCreateHook, type HookResult } from "./hooks.js";
26
28
 
27
29
  type StatusLevel = "info" | "warning" | "error" | "success";
28
30
 
@@ -72,6 +74,26 @@ class WorktreeSelector {
72
74
  private isCreatingWorktree = false;
73
75
  private worktreeOptions: SelectOption[] = [];
74
76
 
77
+ // Multi-select delete mode
78
+ private isSelectingForDelete = false;
79
+ private selectedForDelete: Set<string> = new Set(); // Set of worktree paths
80
+
81
+ // Hook execution state
82
+ private isRunningHook = false;
83
+ private hookOutputContainer: BoxRenderable | null = null;
84
+ private hookOutputText: TextRenderable | null = null;
85
+ private hookOutput: string[] = [];
86
+ private hookAbortFn: (() => void) | null = null;
87
+ private pendingWorktreePath: string | null = null;
88
+ private hookFailed = false;
89
+ private hookFailureSelect: SelectRenderable | null = null;
90
+
91
+ // Config editor state
92
+ private isEditingConfig = false;
93
+ private configContainer: BoxRenderable | null = null;
94
+ private configInput: InputRenderable | null = null;
95
+ private isFirstTimeSetup = false;
96
+
75
97
  constructor(
76
98
  private renderer: CliRenderer,
77
99
  private targetPath: string,
@@ -129,7 +151,7 @@ class WorktreeSelector {
129
151
  left: 2,
130
152
  top: 20,
131
153
  content:
132
- "↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit",
154
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit",
133
155
  fg: "#64748B",
134
156
  });
135
157
  this.renderer.root.add(this.instructions);
@@ -138,7 +160,7 @@ class WorktreeSelector {
138
160
  SelectRenderableEvents.ITEM_SELECTED,
139
161
  (_index: number, option: SelectOption) => {
140
162
  // Ignore if we're in another mode
141
- if (this.isConfirming || this.isCreatingWorktree) {
163
+ if (this.isConfirming || this.isCreatingWorktree || this.isSelectingForDelete) {
142
164
  return;
143
165
  }
144
166
  this.handleSelection(option.value as SelectionValue);
@@ -150,6 +172,11 @@ class WorktreeSelector {
150
172
  });
151
173
 
152
174
  this.selectElement.focus();
175
+
176
+ // Check for first-time setup
177
+ if (this.repoRoot && !configExists(this.repoRoot)) {
178
+ this.showFirstTimeSetup();
179
+ }
153
180
  }
154
181
 
155
182
  private getInitialStatusMessage(): string {
@@ -184,10 +211,36 @@ class WorktreeSelector {
184
211
 
185
212
  private handleKeypress(key: KeyEvent): void {
186
213
  if (key.ctrl && key.name === "c") {
214
+ // If running hook, abort it first
215
+ if (this.isRunningHook && this.hookAbortFn) {
216
+ this.hookAbortFn();
217
+ this.hookAbortFn = null;
218
+ this.setStatus("Hook aborted by user.", "warning");
219
+ this.hideHookOutput();
220
+ this.loadWorktrees(this.pendingWorktreePath || undefined);
221
+ this.selectElement.visible = true;
222
+ this.selectElement.focus();
223
+ this.instructions.content =
224
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
225
+ return;
226
+ }
187
227
  this.cleanup(true);
188
228
  return;
189
229
  }
190
230
 
231
+ // Handle hook running mode (only allow Ctrl+C which is handled above)
232
+ if (this.isRunningHook && !this.hookFailed) {
233
+ return;
234
+ }
235
+
236
+ // Handle hook failure mode - let the select handle input
237
+ if (this.isRunningHook && this.hookFailed) {
238
+ if (key.name === "escape") {
239
+ this.handleHookFailureChoice("cancel");
240
+ }
241
+ return;
242
+ }
243
+
191
244
  // Handle confirmation mode
192
245
  if (this.isConfirming) {
193
246
  if (key.name === "escape") {
@@ -203,6 +256,42 @@ class WorktreeSelector {
203
256
  return;
204
257
  }
205
258
 
259
+ // Handle config editing mode
260
+ if (this.isEditingConfig) {
261
+ if (key.name === "escape") {
262
+ this.hideConfigEditor();
263
+ return;
264
+ }
265
+ if (key.name === "return") {
266
+ const value = this.configInput?.value || "";
267
+ this.handleConfigSave(value);
268
+ return;
269
+ }
270
+ return;
271
+ }
272
+
273
+ // Handle multi-select delete mode
274
+ if (this.isSelectingForDelete) {
275
+ if (key.name === "escape") {
276
+ this.exitSelectMode();
277
+ return;
278
+ }
279
+ if (key.name === "return") {
280
+ this.toggleWorktreeSelection();
281
+ return;
282
+ }
283
+ if (key.name === "d") {
284
+ // Confirm deletion of selected worktrees
285
+ this.confirmBatchDelete();
286
+ return;
287
+ }
288
+ if (key.name === "q") {
289
+ this.exitSelectMode();
290
+ return;
291
+ }
292
+ return;
293
+ }
294
+
206
295
  if (key.name === "q" || key.name === "escape") {
207
296
  this.cleanup(true);
208
297
  return;
@@ -219,9 +308,21 @@ class WorktreeSelector {
219
308
  return;
220
309
  }
221
310
 
222
- // 'd' for delete/unlink menu
311
+ // 'd' for entering delete selection mode
223
312
  if (key.name === "d") {
224
- this.showDeleteConfirmation();
313
+ this.enterSelectMode();
314
+ return;
315
+ }
316
+
317
+ // 'o' for opening worktree path in file manager
318
+ if (key.name === "o") {
319
+ this.openWorktreeInFileManager();
320
+ return;
321
+ }
322
+
323
+ // 'c' for editing config
324
+ if (key.name === "c") {
325
+ this.showConfigEditor();
225
326
  return;
226
327
  }
227
328
  }
@@ -242,6 +343,21 @@ class WorktreeSelector {
242
343
  launchOpenCode(worktree.path);
243
344
  }
244
345
 
346
+ private openWorktreeInFileManager(): void {
347
+ const worktree = this.getSelectedWorktree();
348
+ if (!worktree) {
349
+ this.setStatus("Select a worktree to open in file manager.", "warning");
350
+ return;
351
+ }
352
+
353
+ const success = openInFileManager(worktree.path);
354
+ if (success) {
355
+ this.setStatus(`Opened ${worktree.path} in file manager.`, "success");
356
+ } else {
357
+ this.setStatus("Failed to open file manager.", "error");
358
+ }
359
+ }
360
+
245
361
  private showCreateWorktreeInput(): void {
246
362
  this.isCreatingWorktree = true;
247
363
  this.selectElement.visible = false;
@@ -296,7 +412,7 @@ class WorktreeSelector {
296
412
  this.renderer.requestRender();
297
413
  }
298
414
 
299
- private hideCreateWorktreeInput(): void {
415
+ private hideCreateWorktreeInput(selectWorktreePath?: string): void {
300
416
  this.isCreatingWorktree = false;
301
417
 
302
418
  if (this.branchInput) {
@@ -311,9 +427,9 @@ class WorktreeSelector {
311
427
 
312
428
  this.selectElement.visible = true;
313
429
  this.instructions.content =
314
- "↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit";
430
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
315
431
  this.selectElement.focus();
316
- this.loadWorktrees();
432
+ this.loadWorktrees(selectWorktreePath);
317
433
  }
318
434
 
319
435
  private handleCreateWorktree(branchName: string): void {
@@ -336,25 +452,341 @@ class WorktreeSelector {
336
452
 
337
453
  if (result.success) {
338
454
  this.setStatus(`Worktree created at ${result.path}`, "success");
339
- this.renderer.requestRender();
340
-
341
- if (this.opencodeAvailable) {
455
+
456
+ // Check for post-create hook
457
+ const config = loadRepoConfig(this.repoRoot);
458
+ if (config.postCreateHook) {
459
+ this.pendingWorktreePath = result.path;
460
+ this.runHook(result.path, config.postCreateHook);
461
+ } else {
462
+ // No hook, launch opencode directly
342
463
  this.hideCreateWorktreeInput();
343
464
  this.cleanup(false);
344
465
  launchOpenCode(result.path);
345
- } else {
346
- this.setStatus(
347
- `Worktree created but opencode is not available.`,
348
- "warning",
349
- );
350
- this.hideCreateWorktreeInput();
351
466
  }
352
467
  } else {
353
468
  this.setStatus(`Failed to create worktree: ${result.error}`, "error");
354
469
  }
355
470
  }
356
471
 
357
- private loadWorktrees(): void {
472
+ private runHook(worktreePath: string, command: string): void {
473
+ this.isRunningHook = true;
474
+ this.hookFailed = false;
475
+ this.hookOutput = [];
476
+
477
+ // Hide create input if still visible
478
+ if (this.inputContainer) {
479
+ this.renderer.root.remove(this.inputContainer.id);
480
+ this.inputContainer = null;
481
+ this.branchInput = null;
482
+ }
483
+ this.isCreatingWorktree = false;
484
+ this.selectElement.visible = false;
485
+
486
+ // Create hook output container
487
+ this.hookOutputContainer = new BoxRenderable(this.renderer, {
488
+ id: "hook-output-container",
489
+ position: "absolute",
490
+ left: 2,
491
+ top: 3,
492
+ width: 76,
493
+ height: 14,
494
+ borderStyle: "single",
495
+ borderColor: "#38BDF8",
496
+ title: `Running: ${command}`,
497
+ titleAlignment: "left",
498
+ backgroundColor: "#0F172A",
499
+ border: true,
500
+ });
501
+ this.renderer.root.add(this.hookOutputContainer);
502
+
503
+ this.hookOutputText = new TextRenderable(this.renderer, {
504
+ id: "hook-output-text",
505
+ position: "absolute",
506
+ left: 1,
507
+ top: 1,
508
+ content: "Starting...\n",
509
+ fg: "#94A3B8",
510
+ });
511
+ this.hookOutputContainer.add(this.hookOutputText);
512
+
513
+ this.instructions.content = "Hook running... (Ctrl+C to abort)";
514
+ this.setStatus(`Executing post-create hook...`, "info");
515
+ this.renderer.requestRender();
516
+
517
+ // Run the hook with streaming output
518
+ this.hookAbortFn = runPostCreateHook(worktreePath, command, {
519
+ onOutput: (data: string) => {
520
+ this.hookOutput.push(data);
521
+ this.updateHookOutput();
522
+ },
523
+ onComplete: (result: HookResult) => {
524
+ this.hookAbortFn = null;
525
+ if (result.success) {
526
+ this.onHookSuccess();
527
+ } else {
528
+ this.onHookFailure(result.exitCode);
529
+ }
530
+ },
531
+ });
532
+ }
533
+
534
+ private updateHookOutput(): void {
535
+ if (!this.hookOutputText) return;
536
+
537
+ // Join all output and take the last N lines that fit in the container
538
+ const fullOutput = this.hookOutput.join("");
539
+ const lines = fullOutput.split("\n");
540
+ const maxLines = 11; // Container height minus borders and padding
541
+ const visibleLines = lines.slice(-maxLines);
542
+
543
+ this.hookOutputText.content = visibleLines.join("\n");
544
+ this.renderer.requestRender();
545
+ }
546
+
547
+ private onHookSuccess(): void {
548
+ this.setStatus("Hook completed successfully!", "success");
549
+ this.renderer.requestRender();
550
+
551
+ // Brief delay to show success, then launch opencode
552
+ setTimeout(() => {
553
+ this.hideHookOutput();
554
+ if (this.pendingWorktreePath) {
555
+ this.cleanup(false);
556
+ launchOpenCode(this.pendingWorktreePath);
557
+ }
558
+ }, 1000);
559
+ }
560
+
561
+ private onHookFailure(exitCode: number | null): void {
562
+ this.hookFailed = true;
563
+ const exitMsg = exitCode !== null ? ` (exit code: ${exitCode})` : "";
564
+ this.setStatus(`Hook failed${exitMsg}`, "error");
565
+
566
+ // Add failure options to the container
567
+ if (this.hookOutputContainer) {
568
+ this.hookFailureSelect = new SelectRenderable(this.renderer, {
569
+ id: "hook-failure-select",
570
+ position: "absolute",
571
+ left: 1,
572
+ top: 12,
573
+ width: 72,
574
+ height: 2,
575
+ options: [
576
+ {
577
+ name: "Open in opencode anyway",
578
+ description: "Launch opencode despite hook failure",
579
+ value: "open",
580
+ },
581
+ {
582
+ name: "Cancel",
583
+ description: "Return to worktree list",
584
+ value: "cancel",
585
+ },
586
+ ],
587
+ backgroundColor: "#0F172A",
588
+ focusedBackgroundColor: "#1E293B",
589
+ selectedBackgroundColor: "#1E3A5F",
590
+ textColor: "#E2E8F0",
591
+ selectedTextColor: "#38BDF8",
592
+ descriptionColor: "#94A3B8",
593
+ selectedDescriptionColor: "#E2E8F0",
594
+ showDescription: false,
595
+ wrapSelection: true,
596
+ });
597
+ this.hookOutputContainer.add(this.hookFailureSelect);
598
+
599
+ this.hookFailureSelect.on(
600
+ SelectRenderableEvents.ITEM_SELECTED,
601
+ (_index: number, option: SelectOption) => {
602
+ this.handleHookFailureChoice(option.value as string);
603
+ }
604
+ );
605
+
606
+ this.hookFailureSelect.focus();
607
+ }
608
+
609
+ this.instructions.content = "↑/↓ select • Enter confirm";
610
+ this.renderer.requestRender();
611
+ }
612
+
613
+ private handleHookFailureChoice(choice: string): void {
614
+ if (choice === "open" && this.pendingWorktreePath) {
615
+ this.hideHookOutput();
616
+ this.cleanup(false);
617
+ launchOpenCode(this.pendingWorktreePath);
618
+ } else {
619
+ // Cancel - return to list
620
+ this.hideHookOutput();
621
+ this.loadWorktrees(this.pendingWorktreePath || undefined);
622
+ this.selectElement.visible = true;
623
+ this.selectElement.focus();
624
+ this.instructions.content =
625
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
626
+ }
627
+ }
628
+
629
+ private hideHookOutput(): void {
630
+ this.isRunningHook = false;
631
+ this.hookFailed = false;
632
+ this.hookOutput = [];
633
+ this.pendingWorktreePath = null;
634
+
635
+ if (this.hookFailureSelect) {
636
+ this.hookFailureSelect.blur();
637
+ this.hookFailureSelect = null;
638
+ }
639
+
640
+ if (this.hookOutputContainer) {
641
+ this.renderer.root.remove(this.hookOutputContainer.id);
642
+ this.hookOutputContainer = null;
643
+ this.hookOutputText = null;
644
+ }
645
+ }
646
+
647
+ // ========== Config Editor Methods ==========
648
+
649
+ private showFirstTimeSetup(): void {
650
+ this.isFirstTimeSetup = true;
651
+ this.showConfigEditor();
652
+ }
653
+
654
+ private showConfigEditor(): void {
655
+ if (!this.repoRoot) {
656
+ this.setStatus("No git repository found.", "error");
657
+ return;
658
+ }
659
+
660
+ this.isEditingConfig = true;
661
+ this.selectElement.visible = false;
662
+ this.selectElement.blur();
663
+
664
+ // Load existing config to pre-fill
665
+ const existingConfig = loadRepoConfig(this.repoRoot);
666
+
667
+ const title = this.isFirstTimeSetup
668
+ ? "First-time Setup: Configure Post-create Hook"
669
+ : "Edit Post-create Hook";
670
+
671
+ this.configContainer = new BoxRenderable(this.renderer, {
672
+ id: "config-container",
673
+ position: "absolute",
674
+ left: 2,
675
+ top: 3,
676
+ width: 76,
677
+ height: 8,
678
+ borderStyle: "single",
679
+ borderColor: "#38BDF8",
680
+ title,
681
+ titleAlignment: "center",
682
+ backgroundColor: "#0F172A",
683
+ border: true,
684
+ });
685
+ this.renderer.root.add(this.configContainer);
686
+
687
+ const helpText = new TextRenderable(this.renderer, {
688
+ id: "config-help",
689
+ position: "absolute",
690
+ left: 1,
691
+ top: 1,
692
+ content: "Command to run after creating a worktree (e.g., npm install):",
693
+ fg: "#94A3B8",
694
+ });
695
+ this.configContainer.add(helpText);
696
+
697
+ const skipHint = new TextRenderable(this.renderer, {
698
+ id: "config-skip-hint",
699
+ position: "absolute",
700
+ left: 1,
701
+ top: 4,
702
+ content: "Leave empty to skip post-create hooks.",
703
+ fg: "#64748B",
704
+ });
705
+ this.configContainer.add(skipHint);
706
+
707
+ this.configInput = new InputRenderable(this.renderer, {
708
+ id: "config-hook-input",
709
+ position: "absolute",
710
+ left: 1,
711
+ top: 3,
712
+ width: 72,
713
+ placeholder: "npm install",
714
+ value: existingConfig.postCreateHook || "",
715
+ focusedBackgroundColor: "#1E293B",
716
+ backgroundColor: "#1E293B",
717
+ });
718
+ this.configContainer.add(this.configInput);
719
+
720
+ this.instructions.content = "Enter to save • Esc to cancel";
721
+ this.setStatus(
722
+ this.isFirstTimeSetup
723
+ ? "Welcome! Configure your post-create hook for this repository."
724
+ : "Edit the post-create hook command.",
725
+ "info"
726
+ );
727
+
728
+ // Delay focus to prevent the triggering keypress from being captured
729
+ setTimeout(() => {
730
+ this.configInput?.focus();
731
+ this.renderer.requestRender();
732
+ }, 0);
733
+ }
734
+
735
+ private hideConfigEditor(): void {
736
+ this.isEditingConfig = false;
737
+ this.isFirstTimeSetup = false;
738
+
739
+ if (this.configInput) {
740
+ this.configInput.blur();
741
+ }
742
+
743
+ if (this.configContainer) {
744
+ this.renderer.root.remove(this.configContainer.id);
745
+ this.configContainer = null;
746
+ this.configInput = null;
747
+ }
748
+
749
+ this.selectElement.visible = true;
750
+ this.instructions.content =
751
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
752
+
753
+ // Delay focus to prevent the Enter keypress from triggering a selection
754
+ setTimeout(() => {
755
+ this.selectElement.focus();
756
+ this.renderer.requestRender();
757
+ }, 0);
758
+ }
759
+
760
+ private handleConfigSave(hookCommand: string): void {
761
+ if (!this.repoRoot) {
762
+ this.setStatus("No git repository found.", "error");
763
+ this.hideConfigEditor();
764
+ return;
765
+ }
766
+
767
+ const trimmed = hookCommand.trim();
768
+ const config: Config = {};
769
+
770
+ if (trimmed) {
771
+ config.postCreateHook = trimmed;
772
+ }
773
+
774
+ const success = saveRepoConfig(this.repoRoot, config);
775
+
776
+ if (success) {
777
+ if (trimmed) {
778
+ this.setStatus(`Post-create hook saved: "${trimmed}"`, "success");
779
+ } else {
780
+ this.setStatus("Post-create hook cleared.", "success");
781
+ }
782
+ } else {
783
+ this.setStatus("Failed to save config.", "error");
784
+ }
785
+
786
+ this.hideConfigEditor();
787
+ }
788
+
789
+ private loadWorktrees(selectWorktreePath?: string): void {
358
790
  this.repoRoot = resolveRepoRoot(this.targetPath);
359
791
  if (!this.repoRoot) {
360
792
  this.setStatus("No git repository found in this directory.", "error");
@@ -366,6 +798,17 @@ class WorktreeSelector {
366
798
  const worktrees = listWorktrees(this.repoRoot);
367
799
  this.selectElement.options = this.buildOptions(worktrees);
368
800
 
801
+ // Preselect a specific worktree if path is provided
802
+ if (selectWorktreePath) {
803
+ const index = this.selectElement.options.findIndex((opt: SelectOption) => {
804
+ if (opt.value === CREATE_NEW_WORKTREE_VALUE) return false;
805
+ return (opt.value as WorktreeInfo).path === selectWorktreePath;
806
+ });
807
+ if (index >= 0) {
808
+ this.selectElement.setSelectedIndex(index);
809
+ }
810
+ }
811
+
369
812
  if (worktrees.length === 0) {
370
813
  this.setStatus(
371
814
  "No worktrees detected. Select 'Create new worktree' to add one.",
@@ -394,24 +837,89 @@ class WorktreeSelector {
394
837
  };
395
838
 
396
839
  const worktreeOptions = worktrees.map((worktree) => {
397
- const shortHead = worktree.head ? worktree.head.slice(0, 7) : "unknown";
398
840
  const baseName = basename(worktree.path);
399
- const label = worktree.branch
841
+ const isMain = this.repoRoot && isMainWorktree(this.repoRoot, worktree.path);
842
+
843
+ // Build base label
844
+ let label = worktree.branch
400
845
  ? worktree.branch
401
846
  : worktree.isDetached
402
847
  ? `${baseName} (detached)`
403
848
  : baseName;
404
849
 
850
+ // Add status indicators
851
+ const indicators: string[] = [];
852
+ if (isMain) {
853
+ indicators.push("main");
854
+ }
855
+ if (worktree.isDirty) {
856
+ indicators.push("*");
857
+ }
858
+ if (!worktree.isOnRemote && worktree.branch && !isMain) {
859
+ indicators.push("local");
860
+ }
861
+
862
+ if (indicators.length > 0) {
863
+ label = `${label} [${indicators.join(" ")}]`;
864
+ }
865
+
866
+ // Add checkbox prefix in selection mode
867
+ let displayName = label;
868
+ if (this.isSelectingForDelete) {
869
+ const isSelected = this.selectedForDelete.has(worktree.path);
870
+ if (isMain) {
871
+ displayName = ` [main] ${worktree.branch || baseName}`;
872
+ } else {
873
+ displayName = isSelected ? `[x] ${label}` : `[ ] ${label}`;
874
+ }
875
+ }
876
+
877
+ // Build description with metadata
878
+ const descParts: string[] = [];
879
+
880
+ // Last modified date
881
+ if (worktree.lastModified) {
882
+ descParts.push(this.formatRelativeDate(worktree.lastModified));
883
+ }
884
+
885
+ // Path (shortened if too long)
886
+ const maxPathLen = 45;
887
+ const pathDisplay = worktree.path.length > maxPathLen
888
+ ? "..." + worktree.path.slice(-maxPathLen + 3)
889
+ : worktree.path;
890
+ descParts.push(pathDisplay);
891
+
405
892
  return {
406
- name: label,
407
- description: `${worktree.path} - ${shortHead}`,
893
+ name: displayName,
894
+ description: descParts.join(" | "),
408
895
  value: worktree,
409
896
  };
410
897
  });
411
898
 
899
+ // Don't show create option in delete selection mode
900
+ if (this.isSelectingForDelete) {
901
+ return worktreeOptions;
902
+ }
903
+
412
904
  return [createOption, ...worktreeOptions];
413
905
  }
414
906
 
907
+ private formatRelativeDate(date: Date): string {
908
+ const now = new Date();
909
+ const diffMs = now.getTime() - date.getTime();
910
+ const diffMins = Math.floor(diffMs / (1000 * 60));
911
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
912
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
913
+
914
+ if (diffMins < 1) return "just now";
915
+ if (diffMins < 60) return `${diffMins}m ago`;
916
+ if (diffHours < 24) return `${diffHours}h ago`;
917
+ if (diffDays < 7) return `${diffDays}d ago`;
918
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
919
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
920
+ return `${Math.floor(diffDays / 365)}y ago`;
921
+ }
922
+
415
923
  private setStatus(message: string, level: StatusLevel): void {
416
924
  this.statusText.content = message;
417
925
  this.statusText.fg = statusColors[level];
@@ -573,7 +1081,7 @@ class WorktreeSelector {
573
1081
 
574
1082
  this.selectElement.visible = true;
575
1083
  this.instructions.content =
576
- "↑/↓ navigate • Enter open • d delete • n new • r refresh • q quit";
1084
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
577
1085
  this.selectElement.focus();
578
1086
  this.loadWorktrees();
579
1087
  }
@@ -640,6 +1148,307 @@ class WorktreeSelector {
640
1148
  this.hideConfirmDialog();
641
1149
  }
642
1150
 
1151
+ // ========== Multi-select delete mode methods ==========
1152
+
1153
+ private enterSelectMode(): void {
1154
+ if (!this.repoRoot) {
1155
+ this.setStatus("No git repository found.", "error");
1156
+ return;
1157
+ }
1158
+
1159
+ const worktrees = listWorktrees(this.repoRoot);
1160
+ // Filter out main worktree
1161
+ const deletableWorktrees = worktrees.filter(
1162
+ (wt) => !isMainWorktree(this.repoRoot!, wt.path)
1163
+ );
1164
+
1165
+ if (deletableWorktrees.length === 0) {
1166
+ this.setStatus("No worktrees available for deletion.", "warning");
1167
+ return;
1168
+ }
1169
+
1170
+ this.isSelectingForDelete = true;
1171
+ this.selectedForDelete.clear();
1172
+
1173
+ // Rebuild options to show checkboxes
1174
+ this.selectElement.options = this.buildOptions(worktrees);
1175
+ this.instructions.content =
1176
+ "Enter toggle selection • d confirm delete • Esc cancel";
1177
+ this.setStatus("Select worktrees to delete, then press 'd' to confirm.", "info");
1178
+ this.renderer.requestRender();
1179
+ }
1180
+
1181
+ private exitSelectMode(): void {
1182
+ this.isSelectingForDelete = false;
1183
+ this.selectedForDelete.clear();
1184
+ this.loadWorktrees();
1185
+ this.instructions.content =
1186
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
1187
+ this.renderer.requestRender();
1188
+ }
1189
+
1190
+ private toggleWorktreeSelection(): void {
1191
+ const selectedIndex = this.selectElement.getSelectedIndex();
1192
+ const option = this.selectElement.options[selectedIndex];
1193
+ if (!option) return;
1194
+
1195
+ const worktree = option.value as WorktreeInfo;
1196
+ if (!worktree.path) return;
1197
+
1198
+ // Prevent selecting main worktree
1199
+ if (this.repoRoot && isMainWorktree(this.repoRoot, worktree.path)) {
1200
+ this.setStatus("Cannot delete the main worktree.", "warning");
1201
+ return;
1202
+ }
1203
+
1204
+ if (this.selectedForDelete.has(worktree.path)) {
1205
+ this.selectedForDelete.delete(worktree.path);
1206
+ } else {
1207
+ this.selectedForDelete.add(worktree.path);
1208
+ }
1209
+
1210
+ // Rebuild options to update checkboxes
1211
+ if (this.repoRoot) {
1212
+ const worktrees = listWorktrees(this.repoRoot);
1213
+ this.selectElement.options = this.buildOptions(worktrees);
1214
+ // Restore selection index
1215
+ this.selectElement.setSelectedIndex(selectedIndex);
1216
+ }
1217
+
1218
+ const count = this.selectedForDelete.size;
1219
+ this.setStatus(
1220
+ count === 0
1221
+ ? "Select worktrees to delete, then press 'd' to confirm."
1222
+ : `${count} worktree${count === 1 ? "" : "s"} selected for deletion.`,
1223
+ "info"
1224
+ );
1225
+ this.renderer.requestRender();
1226
+ }
1227
+
1228
+ private confirmBatchDelete(): void {
1229
+ if (this.selectedForDelete.size === 0) {
1230
+ this.setStatus("No worktrees selected. Use Enter to select.", "warning");
1231
+ return;
1232
+ }
1233
+
1234
+ // Get the worktree info for selected paths
1235
+ if (!this.repoRoot) return;
1236
+
1237
+ const worktrees = listWorktrees(this.repoRoot);
1238
+ const toDelete = worktrees.filter((wt) =>
1239
+ this.selectedForDelete.has(wt.path)
1240
+ );
1241
+
1242
+ // Show batch confirmation dialog
1243
+ this.showBatchDeleteConfirmation(toDelete);
1244
+ }
1245
+
1246
+ private showBatchDeleteConfirmation(worktrees: WorktreeInfo[]): void {
1247
+ this.isConfirming = true;
1248
+ this.isSelectingForDelete = false;
1249
+ this.selectElement.visible = false;
1250
+ this.selectElement.blur();
1251
+
1252
+ // Check if any have uncommitted changes
1253
+ const dirtyWorktrees = worktrees.filter((wt) =>
1254
+ hasUncommittedChanges(wt.path)
1255
+ );
1256
+ const hasDirty = dirtyWorktrees.length > 0;
1257
+
1258
+ const count = worktrees.length;
1259
+ const title = `Delete ${count} worktree${count === 1 ? "" : "s"}`;
1260
+
1261
+ this.confirmContainer = new BoxRenderable(this.renderer, {
1262
+ id: "confirm-container",
1263
+ position: "absolute",
1264
+ left: 2,
1265
+ top: 3,
1266
+ width: 76,
1267
+ height: hasDirty ? 12 : 10,
1268
+ borderStyle: "single",
1269
+ borderColor: "#F59E0B",
1270
+ title,
1271
+ titleAlignment: "center",
1272
+ backgroundColor: "#0F172A",
1273
+ border: true,
1274
+ });
1275
+ this.renderer.root.add(this.confirmContainer);
1276
+
1277
+ let yOffset = 1;
1278
+
1279
+ // Warning for dirty worktrees
1280
+ if (hasDirty) {
1281
+ const warningText = new TextRenderable(this.renderer, {
1282
+ id: "confirm-warning",
1283
+ position: "absolute",
1284
+ left: 1,
1285
+ top: yOffset,
1286
+ content: `⚠ ${dirtyWorktrees.length} worktree${dirtyWorktrees.length === 1 ? " has" : "s have"} uncommitted changes!`,
1287
+ fg: "#F59E0B",
1288
+ });
1289
+ this.confirmContainer.add(warningText);
1290
+ yOffset += 2;
1291
+ }
1292
+
1293
+ // List worktrees to be deleted
1294
+ const branchNames = worktrees
1295
+ .map((wt) => wt.branch || basename(wt.path))
1296
+ .slice(0, 3);
1297
+ const displayList =
1298
+ branchNames.join(", ") + (worktrees.length > 3 ? `, +${worktrees.length - 3} more` : "");
1299
+
1300
+ const listText = new TextRenderable(this.renderer, {
1301
+ id: "confirm-list",
1302
+ position: "absolute",
1303
+ left: 1,
1304
+ top: yOffset,
1305
+ content: `Worktrees: ${displayList}`,
1306
+ fg: "#94A3B8",
1307
+ });
1308
+ this.confirmContainer.add(listText);
1309
+ yOffset += 2;
1310
+
1311
+ // Build options
1312
+ const options: SelectOption[] = [
1313
+ {
1314
+ name: "Unlink all (default)",
1315
+ description: "Remove worktree directories, keep branches for later use",
1316
+ value: CONFIRM_UNLINK_VALUE,
1317
+ },
1318
+ {
1319
+ name: "Delete all",
1320
+ description: "Remove worktrees AND delete local branches (never remote)",
1321
+ value: CONFIRM_DELETE_VALUE,
1322
+ },
1323
+ {
1324
+ name: "Cancel",
1325
+ description: "Go back without changes",
1326
+ value: CONFIRM_CANCEL_VALUE,
1327
+ },
1328
+ ];
1329
+
1330
+ this.confirmSelect = new SelectRenderable(this.renderer, {
1331
+ id: "confirm-select",
1332
+ position: "absolute",
1333
+ left: 1,
1334
+ top: yOffset,
1335
+ width: 72,
1336
+ height: 4,
1337
+ options,
1338
+ backgroundColor: "#0F172A",
1339
+ focusedBackgroundColor: "#1E293B",
1340
+ selectedBackgroundColor: "#1E3A5F",
1341
+ textColor: "#E2E8F0",
1342
+ selectedTextColor: "#38BDF8",
1343
+ descriptionColor: "#94A3B8",
1344
+ selectedDescriptionColor: "#E2E8F0",
1345
+ showDescription: true,
1346
+ wrapSelection: true,
1347
+ });
1348
+ this.confirmContainer.add(this.confirmSelect);
1349
+
1350
+ // Store worktrees for batch deletion
1351
+ const worktreesToDelete = worktrees;
1352
+
1353
+ this.confirmSelect.on(
1354
+ SelectRenderableEvents.ITEM_SELECTED,
1355
+ (_index: number, option: SelectOption) => {
1356
+ this.handleBatchConfirmAction(
1357
+ option.value as ConfirmAction,
1358
+ worktreesToDelete,
1359
+ hasDirty
1360
+ );
1361
+ }
1362
+ );
1363
+
1364
+ this.instructions.content =
1365
+ "↑/↓ select action • Enter confirm • Esc cancel";
1366
+ this.setStatus(
1367
+ hasDirty
1368
+ ? "Warning: Some worktrees have uncommitted changes!"
1369
+ : `Ready to remove ${count} worktree${count === 1 ? "" : "s"}.`,
1370
+ hasDirty ? "warning" : "info"
1371
+ );
1372
+
1373
+ this.confirmSelect.focus();
1374
+ this.renderer.requestRender();
1375
+ }
1376
+
1377
+ private handleBatchConfirmAction(
1378
+ action: ConfirmAction,
1379
+ worktrees: WorktreeInfo[],
1380
+ hasDirty: boolean
1381
+ ): void {
1382
+ if (action === CONFIRM_CANCEL_VALUE) {
1383
+ this.selectedForDelete.clear();
1384
+ this.hideConfirmDialog();
1385
+ return;
1386
+ }
1387
+
1388
+ if (!this.repoRoot) {
1389
+ this.selectedForDelete.clear();
1390
+ this.hideConfirmDialog();
1391
+ return;
1392
+ }
1393
+
1394
+ const count = worktrees.length;
1395
+ let successCount = 0;
1396
+ let failCount = 0;
1397
+
1398
+ for (const worktree of worktrees) {
1399
+ const branchName = worktree.branch || basename(worktree.path);
1400
+ const isDirty = hasUncommittedChanges(worktree.path);
1401
+
1402
+ if (action === CONFIRM_UNLINK_VALUE) {
1403
+ const result = unlinkWorktree(this.repoRoot, worktree.path, isDirty);
1404
+ if (result.success) {
1405
+ successCount++;
1406
+ } else {
1407
+ failCount++;
1408
+ }
1409
+ } else if (action === CONFIRM_DELETE_VALUE) {
1410
+ if (!worktree.branch) {
1411
+ // Can't delete branch for detached HEAD, just unlink
1412
+ const result = unlinkWorktree(this.repoRoot, worktree.path, isDirty);
1413
+ if (result.success) {
1414
+ successCount++;
1415
+ } else {
1416
+ failCount++;
1417
+ }
1418
+ } else {
1419
+ const result = deleteWorktree(
1420
+ this.repoRoot,
1421
+ worktree.path,
1422
+ worktree.branch,
1423
+ isDirty
1424
+ );
1425
+ if (result.success) {
1426
+ successCount++;
1427
+ } else {
1428
+ failCount++;
1429
+ }
1430
+ }
1431
+ }
1432
+ }
1433
+
1434
+ this.selectedForDelete.clear();
1435
+
1436
+ if (failCount === 0) {
1437
+ const actionWord = action === CONFIRM_UNLINK_VALUE ? "unlinked" : "deleted";
1438
+ this.setStatus(
1439
+ `Successfully ${actionWord} ${successCount} worktree${successCount === 1 ? "" : "s"}.`,
1440
+ "success"
1441
+ );
1442
+ } else {
1443
+ this.setStatus(
1444
+ `Completed with ${successCount} success, ${failCount} failed.`,
1445
+ "warning"
1446
+ );
1447
+ }
1448
+
1449
+ this.hideConfirmDialog();
1450
+ }
1451
+
643
1452
  private cleanup(shouldExit: boolean): void {
644
1453
  this.selectElement.blur();
645
1454
  if (this.branchInput) {