ccmanager 4.3.0 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
15
15
  - Visual status indicators for session states (busy, waiting, idle)
16
16
  - Create, merge, and delete worktrees from within the app
17
17
  - **Copy Claude Code session data** between worktrees to maintain conversation context
18
+ - **`.worktreeinclude` support**: carry gitignored project files (`.env`, local certs) into newly created worktrees
18
19
  - Configurable keyboard shortcuts
19
20
  - Command presets with automatic fallback support
20
21
  - Configurable state detection strategies for different CLI tools
@@ -209,6 +210,17 @@ The default choice (copy or start fresh) will be pre-selected when creating new
209
210
  - **Context Preservation**: Maintain long conversations across multiple development branches
210
211
 
211
212
 
213
+ ## Copying Gitignored Files into New Worktrees
214
+
215
+ A new Git worktree contains only tracked files, so gitignored files a project needs in order to run — `.env`, local certificates, generated local config — do not come along. If the repository root has a `.worktreeinclude` file (gitignore syntax) listing those files, CCManager copies them into every worktree it creates.
216
+
217
+ - **Shared convention**: same file name and semantics as Claude Code, Conductor, OpenAI Codex, and `git-worktreeinclude`, so an existing `.worktreeinclude` works with CCManager unchanged
218
+ - **No configuration**: the copy runs whenever a `.worktreeinclude` file exists
219
+ - **Safe by construction**: a file is copied only if it both matches a pattern and is actually gitignored, so tracked files are never duplicated
220
+ - **Hook-friendly**: files are copied before the post-creation worktree hook runs, so hook commands can rely on them
221
+
222
+ For pattern syntax, the exact selection rule, and troubleshooting, see [docs/worktree-include.md](docs/worktree-include.md).
223
+
212
224
  ## Status Change Hooks
213
225
 
214
226
  CCManager can execute custom commands when Claude Code session status changes. This enables powerful automation workflows like desktop notifications, logging, or integration with other tools.
@@ -7,6 +7,7 @@ import Dashboard from './Dashboard.js';
7
7
  import Session from './Session.js';
8
8
  import NewWorktree from './NewWorktree.js';
9
9
  import DeleteWorktree from './DeleteWorktree.js';
10
+ import DeleteConfirmation from './DeleteConfirmation.js';
10
11
  import MergeWorktree from './MergeWorktree.js';
11
12
  import Configuration from './Configuration.js';
12
13
  import PresetSelector from './PresetSelector.js';
@@ -22,7 +23,7 @@ import { configReader } from '../services/config/configReader.js';
22
23
  import { ENV_VARS } from '../constants/env.js';
23
24
  import { MULTI_PROJECT_ERRORS } from '../constants/error.js';
24
25
  import { projectManager } from '../services/projectManager.js';
25
- import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
26
+ import { generateWorktreeDirectory, isDeletableWorktree, } from '../utils/worktreeUtils.js';
26
27
  const App = ({ devcontainerConfig, multiProject, version, }) => {
27
28
  const { exit } = useApp();
28
29
  const [view, setView] = useState(multiProject ? 'project-list' : 'menu');
@@ -36,6 +37,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
36
37
  const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
37
38
  const [renameTarget, setRenameTarget] = useState(null);
38
39
  const [sessionActionsTarget, setSessionActionsTarget] = useState(null);
40
+ // Worktree awaiting confirmation of the per-row delete action
41
+ const [worktreeToDelete, setWorktreeToDelete] = useState(null);
39
42
  const [selectedProject, setSelectedProject] = useState(null); // Store selected project in multi-project mode
40
43
  const [configScope, setConfigScope] = useState('global'); // Store config scope for configuration view
41
44
  const [pendingMenuSessionLaunch, setPendingMenuSessionLaunch] = useState(null);
@@ -303,8 +306,9 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
303
306
  return;
304
307
  case 'sessionActions':
305
308
  setSessionActionsTarget({
309
+ worktreePath: action.worktree.path,
306
310
  session: action.session,
307
- worktreePath: action.worktreePath,
311
+ worktree: action.worktree,
308
312
  });
309
313
  navigateWithClear('session-actions');
310
314
  return;
@@ -520,7 +524,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
520
524
  setPendingWorktreeCreation(null);
521
525
  setView('new-worktree');
522
526
  };
523
- const handleDeleteWorktrees = async (worktreePaths, deleteBranch) => {
527
+ const handleDeleteWorktrees = async (worktreePaths, deleteBranch, options) => {
524
528
  // Set loading context before showing loading view
525
529
  setLoadingContext({ deleteBranch });
526
530
  setView('deleting-worktree');
@@ -558,7 +562,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
558
562
  }
559
563
  else {
560
564
  // Show error
561
- setView('delete-worktree');
565
+ if (options?.onError) {
566
+ options.onError();
567
+ }
568
+ else {
569
+ setView('delete-worktree');
570
+ }
562
571
  }
563
572
  };
564
573
  const handleCancelDeleteWorktree = () => {
@@ -670,10 +679,14 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
670
679
  } }));
671
680
  }
672
681
  if (view === 'session-actions' && sessionActionsTarget) {
673
- const { session: targetSession, worktreePath } = sessionActionsTarget;
674
- const label = targetSession.sessionName
675
- ? `${worktreePath} : ${targetSession.sessionName}`
676
- : `${worktreePath} #${targetSession.sessionNumber}`;
682
+ const { session: targetSession, worktreePath, worktree: targetWorktree, } = sessionActionsTarget;
683
+ // A worktree row without a session has no session name to show; the
684
+ // worktree path is rendered on its own line by SessionActions.
685
+ const label = !targetSession
686
+ ? undefined
687
+ : targetSession.sessionName
688
+ ? targetSession.sessionName
689
+ : `Session #${targetSession.sessionNumber}`;
677
690
  const handleSessionAction = async (action) => {
678
691
  setSessionActionsTarget(null);
679
692
  switch (action) {
@@ -686,6 +699,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
686
699
  }, { forceNew: true });
687
700
  return;
688
701
  case 'rename':
702
+ if (!targetSession)
703
+ return;
689
704
  setRenameTarget({
690
705
  id: targetSession.id,
691
706
  name: targetSession.sessionName,
@@ -693,16 +708,38 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
693
708
  navigateWithClear('rename-session');
694
709
  return;
695
710
  case 'kill':
711
+ if (!targetSession)
712
+ return;
696
713
  sessionManager.destroySession(targetSession.id);
697
714
  handleReturnToMenu();
698
715
  return;
716
+ case 'deleteWorktree':
717
+ if (!targetWorktree)
718
+ return;
719
+ setWorktreeToDelete(targetWorktree);
720
+ navigateWithClear('confirm-delete-worktree');
721
+ return;
699
722
  }
700
723
  };
701
- return (_jsx(SessionActions, { sessionLabel: label, onSelect: handleSessionAction, onCancel: () => {
724
+ return (_jsx(SessionActions, { sessionLabel: label, worktreePath: worktreePath, hasSession: !!targetSession, canDeleteWorktree: !!targetWorktree && isDeletableWorktree(targetWorktree), onSelect: handleSessionAction, onCancel: () => {
702
725
  setSessionActionsTarget(null);
703
726
  handleReturnToMenu();
704
727
  } }));
705
728
  }
729
+ if (view === 'confirm-delete-worktree' && worktreeToDelete) {
730
+ const target = worktreeToDelete;
731
+ return (_jsx(DeleteConfirmation, { worktrees: [target], onConfirm: deleteBranch => {
732
+ setWorktreeToDelete(null);
733
+ void handleDeleteWorktrees([target.path], deleteBranch, {
734
+ // The multi-select delete screen was never opened in this flow,
735
+ // so surface the failure on the menu instead.
736
+ onError: handleReturnToMenu,
737
+ });
738
+ }, onCancel: () => {
739
+ setWorktreeToDelete(null);
740
+ handleReturnToMenu();
741
+ } }));
742
+ }
706
743
  if (view === 'preset-selector') {
707
744
  return (_jsx(PresetSelector, { onSelect: handlePresetSelected, onCancel: handlePresetSelectorCancel }));
708
745
  }
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
- import path from 'path';
4
3
  import { Box, Text, useInput } from 'ink';
5
4
  import SelectInput from 'ink-select-input';
6
5
  import { Effect } from 'effect';
@@ -11,6 +10,7 @@ import { useSearchMode } from '../hooks/useSearchMode.js';
11
10
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
12
11
  import { filterWorktreesByQuery } from '../utils/filterByQuery.js';
13
12
  import SearchableList from './SearchableList.js';
13
+ import { isDeletableWorktree } from '../utils/worktreeUtils.js';
14
14
  const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
15
15
  const [worktrees, setWorktrees] = useState([]);
16
16
  const [selectedIndices, setSelectedIndices] = useState(new Set());
@@ -34,18 +34,7 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
34
34
  try {
35
35
  const allWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
36
36
  if (!cancelled) {
37
- // Filter out main worktree and current working directory worktree
38
- const resolvedCwd = path.resolve(process.cwd());
39
- const deletableWorktrees = allWorktrees.filter(wt => {
40
- if (wt.isMainWorktree)
41
- return false;
42
- const resolvedPath = path.resolve(wt.path);
43
- if (resolvedCwd === resolvedPath ||
44
- resolvedCwd.startsWith(resolvedPath + path.sep)) {
45
- return false;
46
- }
47
- return true;
48
- });
37
+ const deletableWorktrees = allWorktrees.filter(wt => isDeletableWorktree(wt));
49
38
  setWorktrees(deletableWorktrees);
50
39
  setIsLoading(false);
51
40
  }
@@ -41,11 +41,12 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
41
41
  const worktrees = useGitStatus(baseWorktrees, defaultBranch);
42
42
  // Seed from the in-memory session list so the cached snapshot renders with its
43
43
  // sessions attached. Waiting for the async git load would leave every row
44
- // session-less for that window, disabling the Space session-actions shortcut.
44
+ // session-less for that window, hiding the session entries of the Space
45
+ // actions menu.
45
46
  const [sessions, setSessions] = useState(() => sessionManager.getAllSessions());
46
47
  const [items, setItems] = useState([]);
47
48
  const [recentProjects, setRecentProjects] = useState([]);
48
- const [highlightedWorktreePath, setHighlightedWorktreePath] = useState(null);
49
+ const [highlightedWorktree, setHighlightedWorktree] = useState(null);
49
50
  const [highlightedSession, setHighlightedSession] = useState(undefined);
50
51
  const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
51
52
  const [stateFilter, setStateFilter] = useState('all');
@@ -288,16 +289,20 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
288
289
  }
289
290
  }
290
291
  setItems(menuItems);
291
- // Ensure highlighted worktree path is valid for hotkey support
292
- setHighlightedWorktreePath(prev => {
293
- if (prev &&
294
- menuItems.some(item => item.type === 'worktree' && item.worktree.path === prev)) {
295
- return prev;
292
+ // Ensure highlighted worktree is valid for hotkey support
293
+ setHighlightedWorktree(prev => {
294
+ const stillListed = prev
295
+ ? menuItems.find(item => item.type === 'worktree' && item.worktree.path === prev.path)
296
+ : undefined;
297
+ if (stillListed && stillListed.type === 'worktree') {
298
+ // Re-read the item so the highlighted worktree keeps up with
299
+ // refreshed git status instead of pinning the stale object.
300
+ return stillListed.worktree;
296
301
  }
297
302
  const first = menuItems.find(item => item.type === 'worktree');
298
303
  if (first && first.type === 'worktree') {
299
304
  setHighlightedSession(first.session);
300
- return first.worktree.path;
305
+ return first.worktree;
301
306
  }
302
307
  setHighlightedSession(undefined);
303
308
  return null;
@@ -371,18 +376,19 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
371
376
  switch (keyPressed) {
372
377
  case 'a':
373
378
  // Toggle auto-approval for the currently highlighted worktree
374
- if (configReader.isAutoApprovalEnabled() && highlightedWorktreePath) {
375
- sessionManager.toggleAutoApprovalForWorktree(highlightedWorktreePath);
379
+ if (configReader.isAutoApprovalEnabled() && highlightedWorktree) {
380
+ sessionManager.toggleAutoApprovalForWorktree(highlightedWorktree.path);
376
381
  setAutoApprovalToggleCounter(c => c + 1);
377
382
  }
378
383
  break;
379
384
  case ' ':
380
- // Open session actions for highlighted session
381
- if (highlightedSession && highlightedWorktreePath) {
385
+ // Open the row actions for the highlighted worktree. Rows without a
386
+ // session open the same menu with only the worktree-level entries.
387
+ if (highlightedWorktree) {
382
388
  onMenuAction({
383
389
  type: 'sessionActions',
390
+ worktree: highlightedWorktree,
384
391
  session: highlightedSession,
385
- worktreePath: highlightedWorktreePath,
386
392
  });
387
393
  }
388
394
  break;
@@ -477,13 +483,13 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
477
483
  if (!item)
478
484
  return;
479
485
  if (item.type === 'worktree') {
480
- setHighlightedWorktreePath(item.worktree.path);
486
+ setHighlightedWorktree(item.worktree);
481
487
  setHighlightedSession(item.session);
482
488
  }
483
489
  }, isFocused: !error, initialIndex: selectedIndex, limit: limit }) }), (error || loadError) && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", error || loadError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE, configReader.isAutoApprovalEnabled() && (_jsxs(_Fragment, { children: [' | ', _jsx(Text, { color: "green", children: "Auto Approval Enabled" })] }))] }), _jsx(Text, { dimColor: true, children: isSearchMode
484
490
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
485
491
  : searchQuery
486
- ? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
487
- : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
492
+ ? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
493
+ : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
488
494
  };
489
495
  export default Menu;
@@ -319,8 +319,44 @@ describe('Menu component Effect-based error handling', () => {
319
319
  }
320
320
  expect(onMenuAction).toHaveBeenCalledWith({
321
321
  type: 'sessionActions',
322
+ worktree: cachedWorktree,
322
323
  session: cachedSession,
323
- worktreePath: '/test/cached',
324
+ });
325
+ });
326
+ it('should open the actions menu with Space on a worktree row that has no session', async () => {
327
+ const { Effect } = await import('effect');
328
+ const sessionlessWorktree = {
329
+ path: '/test/no-session',
330
+ branch: 'feature/no-session',
331
+ isMainWorktree: false,
332
+ hasSession: false,
333
+ };
334
+ vi.spyOn(sessionManager, 'getAllSessions').mockReturnValue([]);
335
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed([sessionlessWorktree]));
336
+ vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
337
+ const onMenuAction = vi.fn();
338
+ vi.mocked(useInput).mockClear();
339
+ render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: {
340
+ worktrees: [sessionlessWorktree],
341
+ defaultBranch: 'main',
342
+ }, onMenuAction: onMenuAction, version: "test" }));
343
+ await new Promise(resolve => setTimeout(resolve, 0));
344
+ // Menu's hotkey handler bails out when raw mode is unavailable.
345
+ const origSetRawMode = process.stdin.setRawMode;
346
+ process.stdin.setRawMode = vi.fn();
347
+ try {
348
+ const calls = vi.mocked(useInput).mock.calls;
349
+ const handler = calls[calls.length - 1]?.[0];
350
+ expect(handler).toBeDefined();
351
+ handler(' ', makeKey());
352
+ }
353
+ finally {
354
+ process.stdin.setRawMode = origSetRawMode;
355
+ }
356
+ expect(onMenuAction).toHaveBeenCalledWith({
357
+ type: 'sessionActions',
358
+ worktree: sessionlessWorktree,
359
+ session: undefined,
324
360
  });
325
361
  });
326
362
  });
@@ -10,6 +10,7 @@ import { WorktreeService } from '../services/worktreeService.js';
10
10
  import { useSearchMode } from '../hooks/useSearchMode.js';
11
11
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
12
12
  import SearchableList from './SearchableList.js';
13
+ import RemoteBranchSelector from './RemoteBranchSelector.js';
13
14
  import { Effect } from 'effect';
14
15
  import { describePromptInjection, getPromptInjectionMethod, } from '../utils/presetPrompt.js';
15
16
  const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
@@ -28,6 +29,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
28
29
  const [path, setPath] = useState('');
29
30
  const [branch, setBranch] = useState('');
30
31
  const [baseBranch, setBaseBranch] = useState('');
32
+ // Short branch name to use when creating a local branch from baseBranch
33
+ // (differs from baseBranch when baseBranch is a remote ref like "origin/x")
34
+ const [baseBranchLocalName, setBaseBranchLocalName] = useState('');
35
+ const [ambiguousBase, setAmbiguousBase] = useState(null);
31
36
  const [copyClaudeDirectory, setCopyClaudeDirectory] = useState(true);
32
37
  const [copySessionData, setCopySessionData] = useState(worktreeConfig.copySessionData ?? true);
33
38
  const [selectedPresetId, setSelectedPresetId] = useState(presetsConfig.defaultPresetId);
@@ -37,9 +42,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
37
42
  const [branches, setBranches] = useState([]);
38
43
  const [remoteBranches, setRemoteBranches] = useState([]);
39
44
  const [defaultBranch, setDefaultBranch] = useState('main');
45
+ const worktreeService = useMemo(() => new WorktreeService(projectPath), [projectPath]);
40
46
  useEffect(() => {
41
47
  let cancelled = false;
42
- const service = new WorktreeService(projectPath);
48
+ const service = worktreeService;
43
49
  const loadBranches = async () => {
44
50
  const branchesEffect = includeRemoteBranches
45
51
  ? service.getBranchesWithRemotesEffect()
@@ -70,8 +76,15 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
70
76
  setRemoteBranches(result.remote);
71
77
  setDefaultBranch(result.defaultBranch);
72
78
  setIsLoadingBranches(false);
73
- if (isAutoUseDefaultBranch && result.defaultBranch) {
74
- setBaseBranch(result.defaultBranch);
79
+ // When the default branch is ambiguous across remotes we can't
80
+ // pick one silently, so keep the base-branch step and let the
81
+ // user choose.
82
+ const resolution = isAutoUseDefaultBranch && result.defaultBranch
83
+ ? service.resolveBaseBranch(result.defaultBranch)
84
+ : null;
85
+ if (resolution && resolution.kind !== 'ambiguous') {
86
+ setBaseBranch(resolution.ref);
87
+ setBaseBranchLocalName(resolution.localName);
75
88
  setStep(currentStep => currentStep === 'base-branch' ? 'creation-mode' : currentStep);
76
89
  }
77
90
  }
@@ -86,7 +99,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
86
99
  return () => {
87
100
  cancelled = true;
88
101
  };
89
- }, [projectPath, isAutoUseDefaultBranch, includeRemoteBranches]);
102
+ }, [worktreeService, isAutoUseDefaultBranch, includeRemoteBranches]);
90
103
  const allBranchItems = useMemo(() => {
91
104
  const defaultRemoteSuffix = `/${defaultBranch}`;
92
105
  const defaultRemotes = remoteBranches.filter(br => br.endsWith(defaultRemoteSuffix));
@@ -123,6 +136,11 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
123
136
  const selectedPreset = useMemo(() => presetsConfig.presets.find(preset => preset.id === selectedPresetId) ||
124
137
  presetsConfig.presets[0], [selectedPresetId, presetsConfig.presets]);
125
138
  useInput((input, key) => {
139
+ if (step === 'remote-branch-confirm') {
140
+ // RemoteBranchSelector handles its own cancel shortcut (returns to
141
+ // the base-branch list); don't also cancel the whole wizard here.
142
+ return;
143
+ }
126
144
  if (shortcutManager.matchesShortcut('cancel', input, key)) {
127
145
  onCancel();
128
146
  }
@@ -130,22 +148,57 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
130
148
  return;
131
149
  }
132
150
  });
151
+ /**
152
+ * Resolves the selected base branch right away. Returns true when the
153
+ * selection is settled; returns false when the branch exists on multiple
154
+ * remotes, in which case the remote-branch-confirm step is shown so the
155
+ * user can disambiguate immediately (instead of failing later when the
156
+ * worktree is actually created).
157
+ */
158
+ const applyBaseBranchSelection = (name) => {
159
+ const resolution = worktreeService.resolveBaseBranch(name);
160
+ if (resolution.kind === 'ambiguous') {
161
+ setAmbiguousBase({
162
+ branchName: resolution.branchName,
163
+ matches: resolution.matches,
164
+ });
165
+ setStep('remote-branch-confirm');
166
+ return false;
167
+ }
168
+ setBaseBranch(resolution.ref);
169
+ setBaseBranchLocalName(resolution.localName);
170
+ return true;
171
+ };
133
172
  const handlePathSubmit = (value) => {
134
173
  if (!value.trim())
135
174
  return;
136
175
  setPath(value.trim());
137
176
  if (isAutoUseDefaultBranch && defaultBranch) {
138
- setBaseBranch(defaultBranch);
139
- setStep('creation-mode');
177
+ if (applyBaseBranchSelection(defaultBranch)) {
178
+ setStep('creation-mode');
179
+ }
140
180
  }
141
181
  else {
142
182
  setStep('base-branch');
143
183
  }
144
184
  };
145
185
  const handleBaseBranchSelect = (item) => {
146
- setBaseBranch(item.value);
186
+ if (applyBaseBranchSelection(item.value)) {
187
+ setStep('creation-mode');
188
+ }
189
+ };
190
+ const handleAmbiguousBaseSelect = (selectedRemoteRef) => {
191
+ if (!ambiguousBase)
192
+ return;
193
+ setBaseBranch(selectedRemoteRef);
194
+ setBaseBranchLocalName(ambiguousBase.branchName);
195
+ setAmbiguousBase(null);
147
196
  setStep('creation-mode');
148
197
  };
198
+ const handleAmbiguousBaseCancel = () => {
199
+ setAmbiguousBase(null);
200
+ setStep('base-branch');
201
+ };
149
202
  const handleCreationModeSelect = (item) => {
150
203
  if (item.value === 'manual') {
151
204
  setStep('branch-strategy');
@@ -156,7 +209,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
156
209
  const handleBranchStrategySelect = (item) => {
157
210
  const useExisting = item.value === 'existing';
158
211
  if (useExisting) {
159
- setBranch(baseBranch);
212
+ // Use the short branch name: when baseBranch is a remote ref
213
+ // (e.g. "origin/feature/x"), the local branch to attach/create is
214
+ // "feature/x", not a branch literally named "origin/feature/x".
215
+ setBranch(baseBranchLocalName || baseBranch);
160
216
  setStep('copy-settings');
161
217
  }
162
218
  else {
@@ -264,7 +320,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
264
320
  const promptMethod = selectedPreset
265
321
  ? getPromptInjectionMethod(selectedPreset)
266
322
  : 'stdin';
267
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
323
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'remote-branch-confirm' && ambiguousBase && (_jsx(RemoteBranchSelector, { branchName: ambiguousBase.branchName, matches: ambiguousBase.matches, onSelect: handleAmbiguousBaseSelect, onCancel: handleAmbiguousBaseCancel })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
268
324
  {
269
325
  label: '1. Choose the branch name yourself',
270
326
  value: 'manual',
@@ -291,6 +347,6 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
291
347
  ], onSelect: handleCopySettingsSelect, initialIndex: 0 })] })), step === 'copy-session' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Copy Claude Code session data to the new worktree?" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "This will copy conversation history and context from the current worktree." }) }), isAutoDirectory && generatedPath ? (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Worktree path preview:", ' ', _jsx(Text, { color: "green", children: generatedPath })] }) })) : null, _jsx(SelectInput, { items: [
292
348
  { label: '✅ Yes, copy session data', value: 'yes' },
293
349
  { label: '❌ No, start fresh', value: 'no' },
294
- ], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) })] }));
350
+ ], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), step !== 'remote-branch-confirm' && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) }))] }));
295
351
  };
296
352
  export default NewWorktree;
@@ -289,6 +289,11 @@ describe('NewWorktree component Effect integration', () => {
289
289
  return {
290
290
  getAllBranchesEffect: vi.fn(() => Effect.succeed(mockBranches)),
291
291
  getDefaultBranchEffect: vi.fn(() => Effect.succeed(mockDefaultBranch)),
292
+ resolveBaseBranch: vi.fn((name) => ({
293
+ kind: 'local',
294
+ ref: name,
295
+ localName: name,
296
+ })),
292
297
  };
293
298
  });
294
299
  const onComplete = vi.fn();
@@ -305,6 +310,38 @@ describe('NewWorktree component Effect integration', () => {
305
310
  expect(output).toContain('Choose the branch name yourself');
306
311
  expect(output).toContain('Enter a prompt first');
307
312
  });
313
+ it('should keep the base branch selection when the default branch is ambiguous across remotes', async () => {
314
+ const { Effect } = await import('effect');
315
+ const { WorktreeService } = await import('../services/worktreeService.js');
316
+ const { configReader } = await import('../services/config/configReader.js');
317
+ vi.spyOn(configReader, 'getWorktreeConfig').mockReturnValue({
318
+ autoDirectory: true,
319
+ autoDirectoryPattern: '../{project}-{branch}',
320
+ copySessionData: true,
321
+ autoUseDefaultBranch: true,
322
+ });
323
+ // Default branch has no local ref and exists on two remotes: it cannot
324
+ // be picked silently, so the base-branch step must not be skipped.
325
+ vi.mocked(WorktreeService).mockImplementation(function () {
326
+ return {
327
+ getAllBranchesEffect: vi.fn(() => Effect.succeed(['main', 'develop'])),
328
+ getDefaultBranchEffect: vi.fn(() => Effect.succeed('main')),
329
+ resolveBaseBranch: vi.fn(() => ({
330
+ kind: 'ambiguous',
331
+ branchName: 'main',
332
+ matches: [
333
+ { remote: 'origin', branch: 'main', fullRef: 'origin/main' },
334
+ { remote: 'upstream', branch: 'main', fullRef: 'upstream/main' },
335
+ ],
336
+ })),
337
+ };
338
+ });
339
+ const { lastFrame } = render(_jsx(NewWorktree, { onComplete: vi.fn(), onCancel: vi.fn() }));
340
+ await new Promise(resolve => setTimeout(resolve, 100));
341
+ const output = lastFrame();
342
+ expect(output).toContain('Select base branch');
343
+ expect(output).not.toContain('How do you want to create the new worktree?');
344
+ });
308
345
  it('should show base branch selection when autoUseDefaultBranch is disabled', async () => {
309
346
  const { Effect } = await import('effect');
310
347
  const { WorktreeService } = await import('../services/worktreeService.js');
@@ -1,7 +1,23 @@
1
1
  import React from 'react';
2
- export type SessionActionType = 'newSession' | 'rename' | 'kill';
2
+ export type SessionActionType = 'newSession' | 'rename' | 'kill' | 'deleteWorktree';
3
3
  interface SessionActionsProps {
4
- sessionLabel: string;
4
+ /**
5
+ * Name of the session this menu was opened from. Absent for a worktree row
6
+ * that has no session yet.
7
+ */
8
+ sessionLabel?: string;
9
+ worktreePath: string;
10
+ /**
11
+ * Whether the row this menu was opened from has a running session. Session
12
+ * specific actions (rename, close) are hidden when it does not.
13
+ */
14
+ hasSession?: boolean;
15
+ /**
16
+ * Whether the worktree of this row may be deleted; see isDeletableWorktree.
17
+ * The delete entry is hidden rather than shown-and-rejected so no
18
+ * unselectable option appears.
19
+ */
20
+ canDeleteWorktree?: boolean;
5
21
  onSelect: (action: SessionActionType) => void;
6
22
  onCancel: () => void;
7
23
  }
@@ -1,29 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import SelectInput from 'ink-select-input';
4
- const items = [
5
- { label: 'S New session in same directory', value: 'newSession' },
6
- { label: 'R Rename this session', value: 'rename' },
7
- { label: 'X Close session', value: 'kill' },
8
- ];
9
- const SessionActions = ({ sessionLabel, onSelect, onCancel, }) => {
4
+ const buildItems = (hasSession, canDeleteWorktree) => {
5
+ const items = [
6
+ { label: 'S New session in this worktree', value: 'newSession' },
7
+ ];
8
+ if (hasSession) {
9
+ items.push({ label: 'R Rename this session', value: 'rename' });
10
+ items.push({ label: 'X Close session', value: 'kill' });
11
+ }
12
+ if (canDeleteWorktree) {
13
+ items.push({ label: 'D Delete this worktree', value: 'deleteWorktree' });
14
+ }
15
+ return items;
16
+ };
17
+ const SessionActions = ({ sessionLabel, worktreePath, hasSession = true, canDeleteWorktree = false, onSelect, onCancel, }) => {
18
+ const items = buildItems(hasSession, canDeleteWorktree);
10
19
  useInput((input, key) => {
11
20
  if (key.escape) {
12
21
  onCancel();
13
22
  return;
14
23
  }
15
- switch (input.toLowerCase()) {
16
- case 's':
17
- onSelect('newSession');
18
- break;
19
- case 'r':
20
- onSelect('rename');
21
- break;
22
- case 'x':
23
- onSelect('kill');
24
- break;
24
+ const shortcut = items.find(item => item.label[0]?.toLowerCase() === input.toLowerCase());
25
+ if (shortcut) {
26
+ onSelect(shortcut.value);
25
27
  }
26
28
  });
27
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Session Actions" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: sessionLabel }) }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "S/R/X or arrow keys + Enter | Escape to cancel" }) })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: hasSession ? 'Session Actions' : 'Worktree Actions' }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [sessionLabel && _jsx(Text, { dimColor: true, children: sessionLabel }), _jsxs(Text, { dimColor: true, children: ["Directory: ", worktreePath] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [items.map(item => item.label[0]).join('/'), " or arrow keys + Enter | Escape to cancel"] }) })] }));
28
30
  };
29
31
  export default SessionActions;
@@ -0,0 +1 @@
1
+ export {};