ccmanager 2.5.0 → 2.6.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.
@@ -8,8 +8,10 @@ import DeleteWorktree from './DeleteWorktree.js';
8
8
  import MergeWorktree from './MergeWorktree.js';
9
9
  import Configuration from './Configuration.js';
10
10
  import PresetSelector from './PresetSelector.js';
11
+ import RemoteBranchSelector from './RemoteBranchSelector.js';
11
12
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
12
13
  import { WorktreeService } from '../services/worktreeService.js';
14
+ import { AmbiguousBranchError, } from '../types/index.js';
13
15
  import { configurationManager } from '../services/configurationManager.js';
14
16
  import { ENV_VARS } from '../constants/env.js';
15
17
  import { MULTI_PROJECT_ERRORS } from '../constants/error.js';
@@ -24,6 +26,8 @@ const App = ({ devcontainerConfig, multiProject }) => {
24
26
  const [menuKey, setMenuKey] = useState(0); // Force menu refresh
25
27
  const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
26
28
  const [selectedProject, setSelectedProject] = useState(null); // Store selected project in multi-project mode
29
+ // State for remote branch disambiguation
30
+ const [pendingWorktreeCreation, setPendingWorktreeCreation] = useState(null);
27
31
  // Helper function to clear terminal screen
28
32
  const clearScreen = () => {
29
33
  if (process.stdout.isTTY) {
@@ -70,6 +74,51 @@ const App = ({ devcontainerConfig, multiProject }) => {
70
74
  // Don't destroy sessions on unmount - they persist in memory
71
75
  };
72
76
  }, [sessionManager, multiProject, selectedProject, navigateWithClear]);
77
+ // Helper function to parse ambiguous branch error and create AmbiguousBranchError
78
+ const parseAmbiguousBranchError = (errorMessage) => {
79
+ const pattern = /Ambiguous branch '(.+?)' found in multiple remotes: (.+?)\. Please specify which remote to use\./;
80
+ const match = errorMessage.match(pattern);
81
+ if (!match) {
82
+ return null;
83
+ }
84
+ const branchName = match[1];
85
+ const remoteRefsText = match[2];
86
+ const remoteRefs = remoteRefsText.split(', ');
87
+ // Parse remote refs into RemoteBranchMatch objects
88
+ const matches = remoteRefs.map(fullRef => {
89
+ const parts = fullRef.split('/');
90
+ const remote = parts[0];
91
+ const branch = parts.slice(1).join('/');
92
+ return {
93
+ remote,
94
+ branch,
95
+ fullRef,
96
+ };
97
+ });
98
+ return new AmbiguousBranchError(branchName, matches);
99
+ };
100
+ // Helper function to handle worktree creation results
101
+ const handleWorktreeCreationResult = (result, creationData) => {
102
+ if (result.success) {
103
+ handleReturnToMenu();
104
+ return;
105
+ }
106
+ const errorMessage = result.error || 'Failed to create worktree';
107
+ const ambiguousError = parseAmbiguousBranchError(errorMessage);
108
+ if (ambiguousError) {
109
+ // Handle ambiguous branch error
110
+ setPendingWorktreeCreation({
111
+ ...creationData,
112
+ ambiguousError,
113
+ });
114
+ navigateWithClear('remote-branch-selector');
115
+ }
116
+ else {
117
+ // Handle regular error
118
+ setError(errorMessage);
119
+ setView('new-worktree');
120
+ }
121
+ };
73
122
  const handleSelectWorktree = async (worktree) => {
74
123
  // Check if this is the new worktree option
75
124
  if (worktree.path === '') {
@@ -183,18 +232,43 @@ const App = ({ devcontainerConfig, multiProject }) => {
183
232
  setError(null);
184
233
  // Create the worktree
185
234
  const result = await worktreeService.createWorktree(path, branch, baseBranch, copySessionData, copyClaudeDirectory);
235
+ // Handle the result using the helper function
236
+ handleWorktreeCreationResult(result, {
237
+ path,
238
+ branch,
239
+ baseBranch,
240
+ copySessionData,
241
+ copyClaudeDirectory,
242
+ });
243
+ };
244
+ const handleCancelNewWorktree = () => {
245
+ handleReturnToMenu();
246
+ };
247
+ const handleRemoteBranchSelected = async (selectedRemoteRef) => {
248
+ if (!pendingWorktreeCreation)
249
+ return;
250
+ // Clear the pending creation data
251
+ const creationData = pendingWorktreeCreation;
252
+ setPendingWorktreeCreation(null);
253
+ // Retry worktree creation with the resolved base branch
254
+ setView('creating-worktree');
255
+ setError(null);
256
+ const result = await worktreeService.createWorktree(creationData.path, creationData.branch, selectedRemoteRef, // Use the selected remote reference
257
+ creationData.copySessionData, creationData.copyClaudeDirectory);
186
258
  if (result.success) {
187
259
  // Success - return to menu
188
260
  handleReturnToMenu();
189
261
  }
190
262
  else {
191
- // Show error
263
+ // Show error and return to new worktree form
192
264
  setError(result.error || 'Failed to create worktree');
193
265
  setView('new-worktree');
194
266
  }
195
267
  };
196
- const handleCancelNewWorktree = () => {
197
- handleReturnToMenu();
268
+ const handleRemoteBranchSelectorCancel = () => {
269
+ // Clear pending data and return to new worktree form
270
+ setPendingWorktreeCreation(null);
271
+ setView('new-worktree');
198
272
  };
199
273
  const handleDeleteWorktrees = async (worktreePaths, deleteBranch) => {
200
274
  setView('deleting-worktree');
@@ -303,6 +377,9 @@ const App = ({ devcontainerConfig, multiProject }) => {
303
377
  if (view === 'preset-selector') {
304
378
  return (React.createElement(PresetSelector, { onSelect: handlePresetSelected, onCancel: handlePresetSelectorCancel }));
305
379
  }
380
+ if (view === 'remote-branch-selector' && pendingWorktreeCreation) {
381
+ return (React.createElement(RemoteBranchSelector, { branchName: pendingWorktreeCreation.ambiguousError.branchName, matches: pendingWorktreeCreation.ambiguousError.matches, onSelect: handleRemoteBranchSelected, onCancel: handleRemoteBranchSelectorCancel }));
382
+ }
306
383
  if (view === 'clearing') {
307
384
  // Render nothing during the clearing phase to ensure clean transition
308
385
  return null;
@@ -5,6 +5,20 @@ import SelectInput from 'ink-select-input';
5
5
  import { configurationManager } from '../services/configurationManager.js';
6
6
  import { shortcutManager } from '../services/shortcutManager.js';
7
7
  import Confirmation from './Confirmation.js';
8
+ // This function ensures all strategies are included at compile time
9
+ const createStrategyItems = () => {
10
+ // This object MUST include all StateDetectionStrategy values as keys
11
+ // If any are missing, TypeScript will error
12
+ const strategies = {
13
+ claude: { label: 'Claude', value: 'claude' },
14
+ gemini: { label: 'Gemini', value: 'gemini' },
15
+ codex: { label: 'Codex', value: 'codex' },
16
+ cursor: { label: 'Cursor Agent', value: 'cursor' },
17
+ };
18
+ return Object.values(strategies);
19
+ };
20
+ // Type-safe strategy items that ensures all StateDetectionStrategy values are included
21
+ const ALL_STRATEGY_ITEMS = createStrategyItems();
8
22
  const formatDetectionStrategy = (strategy) => {
9
23
  const value = strategy || 'claude';
10
24
  switch (value) {
@@ -12,6 +26,8 @@ const formatDetectionStrategy = (strategy) => {
12
26
  return 'Gemini';
13
27
  case 'codex':
14
28
  return 'Codex';
29
+ case 'cursor':
30
+ return 'Cursor';
15
31
  default:
16
32
  return 'Claude';
17
33
  }
@@ -259,11 +275,7 @@ const ConfigureCommand = ({ onComplete }) => {
259
275
  const preset = presets.find(p => p.id === selectedPresetId);
260
276
  if (!preset)
261
277
  return null;
262
- const strategyItems = [
263
- { label: 'Claude', value: 'claude' },
264
- { label: 'Gemini', value: 'gemini' },
265
- { label: 'Codex', value: 'codex' },
266
- ];
278
+ const strategyItems = ALL_STRATEGY_ITEMS;
267
279
  const currentStrategy = preset.detectionStrategy || 'claude';
268
280
  const initialIndex = strategyItems.findIndex(item => item.value === currentStrategy);
269
281
  return (React.createElement(Box, { flexDirection: "column" },
@@ -308,11 +320,7 @@ const ConfigureCommand = ({ onComplete }) => {
308
320
  // Render add preset form
309
321
  if (viewMode === 'add') {
310
322
  if (isSelectingStrategyInAdd) {
311
- const strategyItems = [
312
- { label: 'Claude', value: 'claude' },
313
- { label: 'Gemini', value: 'gemini' },
314
- { label: 'Codex', value: 'codex' },
315
- ];
323
+ const strategyItems = ALL_STRATEGY_ITEMS;
316
324
  return (React.createElement(Box, { flexDirection: "column" },
317
325
  React.createElement(Box, { marginBottom: 1 },
318
326
  React.createElement(Text, { bold: true, color: "green" }, "Add New Preset - Detection Strategy")),
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { RemoteBranchMatch } from '../types/index.js';
3
+ interface RemoteBranchSelectorProps {
4
+ branchName: string;
5
+ matches: RemoteBranchMatch[];
6
+ onSelect: (selectedRemoteRef: string) => void;
7
+ onCancel: () => void;
8
+ }
9
+ declare const RemoteBranchSelector: React.FC<RemoteBranchSelectorProps>;
10
+ export default RemoteBranchSelector;
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import SelectInput from 'ink-select-input';
4
+ import { shortcutManager } from '../services/shortcutManager.js';
5
+ const RemoteBranchSelector = ({ branchName, matches, onSelect, onCancel, }) => {
6
+ const selectItems = [
7
+ ...matches.map(match => ({
8
+ label: `${match.fullRef} (from ${match.remote})`,
9
+ value: match.fullRef,
10
+ })),
11
+ { label: '← Cancel', value: 'cancel' },
12
+ ];
13
+ const handleSelectItem = (item) => {
14
+ if (item.value === 'cancel') {
15
+ onCancel();
16
+ }
17
+ else {
18
+ onSelect(item.value);
19
+ }
20
+ };
21
+ useInput((input, key) => {
22
+ if (shortcutManager.matchesShortcut('cancel', input, key)) {
23
+ onCancel();
24
+ }
25
+ });
26
+ return (React.createElement(Box, { flexDirection: "column" },
27
+ React.createElement(Box, { marginBottom: 1 },
28
+ React.createElement(Text, { bold: true, color: "yellow" }, "\u26A0\uFE0F Ambiguous Branch Reference")),
29
+ React.createElement(Box, { marginBottom: 1 },
30
+ React.createElement(Text, null,
31
+ "Branch ",
32
+ React.createElement(Text, { color: "cyan" },
33
+ "'",
34
+ branchName,
35
+ "'"),
36
+ " exists in multiple remotes.")),
37
+ React.createElement(Box, { marginBottom: 1 },
38
+ React.createElement(Text, { dimColor: true }, "Please select which remote branch you want to use as the base:")),
39
+ React.createElement(SelectInput, { items: selectItems, onSelect: handleSelectItem, initialIndex: 0 }),
40
+ React.createElement(Box, { marginTop: 1 },
41
+ React.createElement(Text, { dimColor: true },
42
+ "Press \u2191\u2193 to navigate, Enter to select,",
43
+ ' ',
44
+ shortcutManager.getShortcutDisplay('cancel'),
45
+ " to cancel"))));
46
+ };
47
+ export default RemoteBranchSelector;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import RemoteBranchSelector from './RemoteBranchSelector.js';
4
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ // Mock ink to avoid stdin issues
6
+ vi.mock('ink', async () => {
7
+ const actual = await vi.importActual('ink');
8
+ return {
9
+ ...actual,
10
+ useInput: vi.fn(),
11
+ };
12
+ });
13
+ // Mock SelectInput to simulate user interactions
14
+ vi.mock('ink-select-input', async () => {
15
+ const React = await vi.importActual('react');
16
+ const { Text, Box } = await vi.importActual('ink');
17
+ return {
18
+ default: ({ items, onSelect: _onSelect, initialIndex = 0, }) => {
19
+ return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => {
20
+ // Simulate selection for first item automatically in tests
21
+ if (index === 0 && initialIndex === 0) {
22
+ // onSelect will be called through the component's logic
23
+ }
24
+ return React.createElement(Text, {
25
+ key: index,
26
+ }, `${index === 0 ? '❯ ' : ' '}${item.label}`);
27
+ }));
28
+ },
29
+ };
30
+ });
31
+ // Mock shortcutManager
32
+ vi.mock('../services/shortcutManager.js', () => ({
33
+ shortcutManager: {
34
+ getShortcutDisplay: vi.fn().mockReturnValue('ESC'),
35
+ matchesShortcut: vi.fn().mockReturnValue(false),
36
+ },
37
+ }));
38
+ describe('RemoteBranchSelector Component', () => {
39
+ const mockBranchName = 'feature/awesome-feature';
40
+ const mockMatches = [
41
+ {
42
+ remote: 'origin',
43
+ branch: 'feature/awesome-feature',
44
+ fullRef: 'origin/feature/awesome-feature',
45
+ },
46
+ {
47
+ remote: 'upstream',
48
+ branch: 'feature/awesome-feature',
49
+ fullRef: 'upstream/feature/awesome-feature',
50
+ },
51
+ ];
52
+ let onSelect;
53
+ let onCancel;
54
+ beforeEach(() => {
55
+ onSelect = vi.fn();
56
+ onCancel = vi.fn();
57
+ });
58
+ afterEach(() => {
59
+ vi.clearAllMocks();
60
+ });
61
+ it('should render warning title and branch name', () => {
62
+ const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: mockBranchName, matches: mockMatches, onSelect: onSelect, onCancel: onCancel }));
63
+ const output = lastFrame();
64
+ expect(output).toContain('⚠️ Ambiguous Branch Reference');
65
+ expect(output).toContain(`Branch 'feature/awesome-feature' exists in multiple remotes`);
66
+ });
67
+ it('should render all remote branch options', () => {
68
+ const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: mockBranchName, matches: mockMatches, onSelect: onSelect, onCancel: onCancel }));
69
+ const output = lastFrame();
70
+ expect(output).toContain('origin/feature/awesome-feature (from origin)');
71
+ expect(output).toContain('upstream/feature/awesome-feature (from upstream)');
72
+ });
73
+ it('should render cancel option', () => {
74
+ const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: mockBranchName, matches: mockMatches, onSelect: onSelect, onCancel: onCancel }));
75
+ const output = lastFrame();
76
+ expect(output).toContain('← Cancel');
77
+ });
78
+ it('should display help text with shortcut information', () => {
79
+ const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: mockBranchName, matches: mockMatches, onSelect: onSelect, onCancel: onCancel }));
80
+ const output = lastFrame();
81
+ expect(output).toContain('Press ↑↓ to navigate, Enter to select, ESC to cancel');
82
+ });
83
+ it('should handle single remote branch match', () => {
84
+ const singleMatch = [
85
+ {
86
+ remote: 'origin',
87
+ branch: 'feature/single-feature',
88
+ fullRef: 'origin/feature/single-feature',
89
+ },
90
+ ];
91
+ const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: "feature/single-feature", matches: singleMatch, onSelect: onSelect, onCancel: onCancel }));
92
+ const output = lastFrame();
93
+ expect(output).toContain('origin/feature/single-feature (from origin)');
94
+ expect(output).not.toContain('upstream');
95
+ });
96
+ it('should handle complex branch names with multiple slashes', () => {
97
+ const complexMatches = [
98
+ {
99
+ remote: 'origin',
100
+ branch: 'feature/sub/complex-branch-name',
101
+ fullRef: 'origin/feature/sub/complex-branch-name',
102
+ },
103
+ {
104
+ remote: 'fork',
105
+ branch: 'feature/sub/complex-branch-name',
106
+ fullRef: 'fork/feature/sub/complex-branch-name',
107
+ },
108
+ ];
109
+ const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: "feature/sub/complex-branch-name", matches: complexMatches, onSelect: onSelect, onCancel: onCancel }));
110
+ const output = lastFrame();
111
+ expect(output).toContain('origin/feature/sub/complex-branch-name (from origin)');
112
+ expect(output).toContain('fork/feature/sub/complex-branch-name (from fork)');
113
+ });
114
+ it('should handle many remote matches', () => {
115
+ const manyMatches = [
116
+ { remote: 'origin', branch: 'test-branch', fullRef: 'origin/test-branch' },
117
+ {
118
+ remote: 'upstream',
119
+ branch: 'test-branch',
120
+ fullRef: 'upstream/test-branch',
121
+ },
122
+ { remote: 'fork1', branch: 'test-branch', fullRef: 'fork1/test-branch' },
123
+ { remote: 'fork2', branch: 'test-branch', fullRef: 'fork2/test-branch' },
124
+ {
125
+ remote: 'company',
126
+ branch: 'test-branch',
127
+ fullRef: 'company/test-branch',
128
+ },
129
+ ];
130
+ const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: "test-branch", matches: manyMatches, onSelect: onSelect, onCancel: onCancel }));
131
+ const output = lastFrame();
132
+ // Verify all remotes are shown
133
+ expect(output).toContain('origin/test-branch (from origin)');
134
+ expect(output).toContain('upstream/test-branch (from upstream)');
135
+ expect(output).toContain('fork1/test-branch (from fork1)');
136
+ expect(output).toContain('fork2/test-branch (from fork2)');
137
+ expect(output).toContain('company/test-branch (from company)');
138
+ });
139
+ // Note: Testing actual selection behavior is complex with ink-testing-library
140
+ // as it requires simulating user interactions. The component logic is tested
141
+ // through integration tests in App.test.tsx where we can mock the callbacks
142
+ // and verify they're called with the correct parameters.
143
+ });
@@ -17,3 +17,6 @@ export declare class GeminiStateDetector extends BaseStateDetector {
17
17
  export declare class CodexStateDetector extends BaseStateDetector {
18
18
  detectState(terminal: Terminal, _currentState: SessionState): SessionState;
19
19
  }
20
+ export declare class CursorStateDetector extends BaseStateDetector {
21
+ detectState(terminal: Terminal, _currentState: SessionState): SessionState;
22
+ }
@@ -6,6 +6,8 @@ export function createStateDetector(strategy = 'claude') {
6
6
  return new GeminiStateDetector();
7
7
  case 'codex':
8
8
  return new CodexStateDetector();
9
+ case 'cursor':
10
+ return new CursorStateDetector();
9
11
  default:
10
12
  return new ClaudeStateDetector();
11
13
  }
@@ -89,3 +91,21 @@ export class CodexStateDetector extends BaseStateDetector {
89
91
  return 'idle';
90
92
  }
91
93
  }
94
+ export class CursorStateDetector extends BaseStateDetector {
95
+ detectState(terminal, _currentState) {
96
+ const content = this.getTerminalContent(terminal);
97
+ const lowerContent = content.toLowerCase();
98
+ // Check for waiting prompts - Priority 1
99
+ if (lowerContent.includes('(y) (enter)') ||
100
+ lowerContent.includes('keep (n)') ||
101
+ /auto .* \(shift\+tab\)/.test(lowerContent)) {
102
+ return 'waiting_input';
103
+ }
104
+ // Check for busy state - Priority 2
105
+ if (lowerContent.includes('ctrl+c to stop')) {
106
+ return 'busy';
107
+ }
108
+ // Otherwise idle - Priority 3
109
+ return 'idle';
110
+ }
111
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
- import { ClaudeStateDetector, GeminiStateDetector, CodexStateDetector, } from './stateDetector.js';
2
+ import { ClaudeStateDetector, GeminiStateDetector, CodexStateDetector, CursorStateDetector, } from './stateDetector.js';
3
3
  describe('ClaudeStateDetector', () => {
4
4
  let detector;
5
5
  let terminal;
@@ -363,3 +363,147 @@ describe('CodexStateDetector', () => {
363
363
  expect(state).toBe('waiting_input');
364
364
  });
365
365
  });
366
+ describe('CursorStateDetector', () => {
367
+ let detector;
368
+ let terminal;
369
+ const createMockTerminal = (lines) => {
370
+ const buffer = {
371
+ length: lines.length,
372
+ active: {
373
+ length: lines.length,
374
+ getLine: (index) => {
375
+ if (index >= 0 && index < lines.length) {
376
+ return {
377
+ translateToString: () => lines[index],
378
+ };
379
+ }
380
+ return null;
381
+ },
382
+ },
383
+ };
384
+ return { buffer };
385
+ };
386
+ beforeEach(() => {
387
+ detector = new CursorStateDetector();
388
+ });
389
+ it('should detect waiting_input state for (y) (enter) pattern', () => {
390
+ // Arrange
391
+ terminal = createMockTerminal([
392
+ 'Some output',
393
+ 'Apply changes? (y) (enter)',
394
+ '> ',
395
+ ]);
396
+ // Act
397
+ const state = detector.detectState(terminal, 'idle');
398
+ // Assert
399
+ expect(state).toBe('waiting_input');
400
+ });
401
+ it('should detect waiting_input state for (Y) (ENTER) pattern (case insensitive)', () => {
402
+ // Arrange
403
+ terminal = createMockTerminal([
404
+ 'Some output',
405
+ 'Continue? (Y) (ENTER)',
406
+ '> ',
407
+ ]);
408
+ // Act
409
+ const state = detector.detectState(terminal, 'idle');
410
+ // Assert
411
+ expect(state).toBe('waiting_input');
412
+ });
413
+ it('should detect waiting_input state for Keep (n) pattern', () => {
414
+ // Arrange
415
+ terminal = createMockTerminal([
416
+ 'Changes detected',
417
+ 'Keep (n) or replace?',
418
+ '> ',
419
+ ]);
420
+ // Act
421
+ const state = detector.detectState(terminal, 'idle');
422
+ // Assert
423
+ expect(state).toBe('waiting_input');
424
+ });
425
+ it('should detect waiting_input state for KEEP (N) pattern (case insensitive)', () => {
426
+ // Arrange
427
+ terminal = createMockTerminal([
428
+ 'Some output',
429
+ 'KEEP (N) current version?',
430
+ '> ',
431
+ ]);
432
+ // Act
433
+ const state = detector.detectState(terminal, 'idle');
434
+ // Assert
435
+ expect(state).toBe('waiting_input');
436
+ });
437
+ it('should detect waiting_input state for Auto pattern with shift+tab', () => {
438
+ // Arrange
439
+ terminal = createMockTerminal([
440
+ 'Some output',
441
+ 'Auto apply changes (shift+tab)',
442
+ '> ',
443
+ ]);
444
+ // Act
445
+ const state = detector.detectState(terminal, 'idle');
446
+ // Assert
447
+ expect(state).toBe('waiting_input');
448
+ });
449
+ it('should detect waiting_input state for AUTO with SHIFT+TAB (case insensitive)', () => {
450
+ // Arrange
451
+ terminal = createMockTerminal([
452
+ 'Some output',
453
+ 'AUTO COMPLETE (SHIFT+TAB)',
454
+ '> ',
455
+ ]);
456
+ // Act
457
+ const state = detector.detectState(terminal, 'idle');
458
+ // Assert
459
+ expect(state).toBe('waiting_input');
460
+ });
461
+ it('should detect busy state for ctrl+c to stop pattern', () => {
462
+ // Arrange
463
+ terminal = createMockTerminal([
464
+ 'Processing...',
465
+ 'Press ctrl+c to stop',
466
+ 'Working...',
467
+ ]);
468
+ // Act
469
+ const state = detector.detectState(terminal, 'idle');
470
+ // Assert
471
+ expect(state).toBe('busy');
472
+ });
473
+ it('should detect busy state for CTRL+C TO STOP (case insensitive)', () => {
474
+ // Arrange
475
+ terminal = createMockTerminal([
476
+ 'Running...',
477
+ 'PRESS CTRL+C TO STOP',
478
+ 'Processing...',
479
+ ]);
480
+ // Act
481
+ const state = detector.detectState(terminal, 'idle');
482
+ // Assert
483
+ expect(state).toBe('busy');
484
+ });
485
+ it('should detect idle state when no patterns match', () => {
486
+ // Arrange
487
+ terminal = createMockTerminal(['Normal output', 'Some message', 'Ready']);
488
+ // Act
489
+ const state = detector.detectState(terminal, 'idle');
490
+ // Assert
491
+ expect(state).toBe('idle');
492
+ });
493
+ it('should prioritize waiting_input over busy (Priority 1)', () => {
494
+ // Arrange
495
+ terminal = createMockTerminal(['ctrl+c to stop', '(y) (enter)']);
496
+ // Act
497
+ const state = detector.detectState(terminal, 'idle');
498
+ // Assert
499
+ expect(state).toBe('waiting_input'); // waiting_input should take precedence
500
+ });
501
+ it('should handle empty terminal', () => {
502
+ // Arrange
503
+ terminal = createMockTerminal([]);
504
+ // Act
505
+ const state = detector.detectState(terminal, 'idle');
506
+ // Assert
507
+ expect(state).toBe('idle');
508
+ });
509
+ });
@@ -10,6 +10,21 @@ export declare class WorktreeService {
10
10
  getGitRootPath(): string;
11
11
  getDefaultBranch(): string;
12
12
  getAllBranches(): string[];
13
+ /**
14
+ * Resolves a branch name to its proper git reference.
15
+ * Handles multiple remotes and throws AmbiguousBranchError when disambiguation is needed.
16
+ *
17
+ * Priority order:
18
+ * 1. Local branch exists -> return as-is
19
+ * 2. Single remote branch -> return remote/branch
20
+ * 3. Multiple remote branches -> throw AmbiguousBranchError
21
+ * 4. No branches found -> return original (let git handle error)
22
+ */
23
+ private resolveBranchReference;
24
+ /**
25
+ * Gets all git remotes for this repository.
26
+ */
27
+ private getAllRemotes;
13
28
  createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Promise<{
14
29
  success: boolean;
15
30
  error?: string;
@@ -1,6 +1,7 @@
1
1
  import { execSync } from 'child_process';
2
2
  import { existsSync, statSync, cpSync } from 'fs';
3
3
  import path from 'path';
4
+ import { AmbiguousBranchError, } from '../types/index.js';
4
5
  import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
5
6
  import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
6
7
  import { executeWorktreePostCreationHook } from '../utils/hookExecutor.js';
@@ -190,6 +191,93 @@ export class WorktreeService {
190
191
  return [];
191
192
  }
192
193
  }
194
+ /**
195
+ * Resolves a branch name to its proper git reference.
196
+ * Handles multiple remotes and throws AmbiguousBranchError when disambiguation is needed.
197
+ *
198
+ * Priority order:
199
+ * 1. Local branch exists -> return as-is
200
+ * 2. Single remote branch -> return remote/branch
201
+ * 3. Multiple remote branches -> throw AmbiguousBranchError
202
+ * 4. No branches found -> return original (let git handle error)
203
+ */
204
+ resolveBranchReference(branchName) {
205
+ try {
206
+ // First check if local branch exists (highest priority)
207
+ try {
208
+ execSync(`git show-ref --verify --quiet refs/heads/${branchName}`, {
209
+ cwd: this.rootPath,
210
+ encoding: 'utf8',
211
+ });
212
+ // Local branch exists, use it as-is
213
+ return branchName;
214
+ }
215
+ catch {
216
+ // Local branch doesn't exist, check remotes
217
+ }
218
+ // Get all remotes
219
+ const remotes = this.getAllRemotes();
220
+ const remoteBranchMatches = [];
221
+ // Check each remote for the branch
222
+ for (const remote of remotes) {
223
+ try {
224
+ execSync(`git show-ref --verify --quiet refs/remotes/${remote}/${branchName}`, {
225
+ cwd: this.rootPath,
226
+ encoding: 'utf8',
227
+ });
228
+ // Remote branch exists
229
+ remoteBranchMatches.push({
230
+ remote,
231
+ branch: branchName,
232
+ fullRef: `${remote}/${branchName}`,
233
+ });
234
+ }
235
+ catch {
236
+ // This remote doesn't have the branch, continue
237
+ }
238
+ }
239
+ // Handle results based on number of matches
240
+ if (remoteBranchMatches.length === 0) {
241
+ // No remote branches found, return original (let git handle the error)
242
+ return branchName;
243
+ }
244
+ else if (remoteBranchMatches.length === 1) {
245
+ // Single remote branch found, use it
246
+ return remoteBranchMatches[0].fullRef;
247
+ }
248
+ else {
249
+ // Multiple remote branches found, throw ambiguous error
250
+ throw new AmbiguousBranchError(branchName, remoteBranchMatches);
251
+ }
252
+ }
253
+ catch (error) {
254
+ // Re-throw AmbiguousBranchError as-is
255
+ if (error instanceof AmbiguousBranchError) {
256
+ throw error;
257
+ }
258
+ // For any other error, return original branch name
259
+ return branchName;
260
+ }
261
+ }
262
+ /**
263
+ * Gets all git remotes for this repository.
264
+ */
265
+ getAllRemotes() {
266
+ try {
267
+ const output = execSync('git remote', {
268
+ cwd: this.rootPath,
269
+ encoding: 'utf8',
270
+ });
271
+ return output
272
+ .trim()
273
+ .split('\n')
274
+ .filter(remote => remote.length > 0);
275
+ }
276
+ catch {
277
+ // If git remote fails, return empty array
278
+ return [];
279
+ }
280
+ }
193
281
  async createWorktree(worktreePath, branch, baseBranch, copySessionData = false, copyClaudeDirectory = false) {
194
282
  try {
195
283
  // Resolve the worktree path relative to the git repository root
@@ -214,8 +302,27 @@ export class WorktreeService {
214
302
  command = `git worktree add "${resolvedPath}" "${branch}"`;
215
303
  }
216
304
  else {
217
- // Create new branch from specified base branch
218
- command = `git worktree add -b "${branch}" "${resolvedPath}" "${baseBranch}"`;
305
+ // Resolve the base branch to its proper git reference
306
+ try {
307
+ const resolvedBaseBranch = this.resolveBranchReference(baseBranch);
308
+ // Create new branch from specified base branch
309
+ command = `git worktree add -b "${branch}" "${resolvedPath}" "${resolvedBaseBranch}"`;
310
+ }
311
+ catch (error) {
312
+ if (error instanceof AmbiguousBranchError) {
313
+ // TODO: Future enhancement - show disambiguation modal in UI
314
+ // The UI should present the available remote options to the user:
315
+ // - origin/foo/bar-xyz
316
+ // - upstream/foo/bar-xyz
317
+ // For now, return error message to be displayed to user
318
+ return {
319
+ success: false,
320
+ error: error.message,
321
+ };
322
+ }
323
+ // Re-throw any other errors
324
+ throw error;
325
+ }
219
326
  }
220
327
  execSync(command, {
221
328
  cwd: this.gitRootPath, // Execute from git root to ensure proper resolution
@@ -192,6 +192,122 @@ origin/feature/test
192
192
  expect(result).toEqual([]);
193
193
  });
194
194
  });
195
+ describe('resolveBranchReference', () => {
196
+ it('should return local branch when it exists', () => {
197
+ mockedExecSync.mockImplementation((cmd, _options) => {
198
+ if (typeof cmd === 'string') {
199
+ if (cmd === 'git rev-parse --git-common-dir') {
200
+ return '/fake/path/.git\n';
201
+ }
202
+ if (cmd.includes('show-ref --verify --quiet refs/heads/foo/bar-xyz')) {
203
+ return ''; // Local branch exists
204
+ }
205
+ }
206
+ throw new Error('Command not mocked: ' + cmd);
207
+ });
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ const result = service.resolveBranchReference('foo/bar-xyz');
210
+ expect(result).toBe('foo/bar-xyz');
211
+ });
212
+ it('should return single remote branch when local does not exist', () => {
213
+ mockedExecSync.mockImplementation((cmd, _options) => {
214
+ if (typeof cmd === 'string') {
215
+ if (cmd === 'git rev-parse --git-common-dir') {
216
+ return '/fake/path/.git\n';
217
+ }
218
+ if (cmd.includes('show-ref --verify --quiet refs/heads/foo/bar-xyz')) {
219
+ throw new Error('Local branch not found');
220
+ }
221
+ if (cmd === 'git remote') {
222
+ return 'origin\nupstream\n';
223
+ }
224
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/foo/bar-xyz')) {
225
+ return ''; // Remote branch exists in origin
226
+ }
227
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/upstream/foo/bar-xyz')) {
228
+ throw new Error('Remote branch not found in upstream');
229
+ }
230
+ }
231
+ throw new Error('Command not mocked: ' + cmd);
232
+ });
233
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
234
+ const result = service.resolveBranchReference('foo/bar-xyz');
235
+ expect(result).toBe('origin/foo/bar-xyz');
236
+ });
237
+ it('should throw AmbiguousBranchError when multiple remotes have the branch', () => {
238
+ mockedExecSync.mockImplementation((cmd, _options) => {
239
+ if (typeof cmd === 'string') {
240
+ if (cmd === 'git rev-parse --git-common-dir') {
241
+ return '/fake/path/.git\n';
242
+ }
243
+ if (cmd.includes('show-ref --verify --quiet refs/heads/foo/bar-xyz')) {
244
+ throw new Error('Local branch not found');
245
+ }
246
+ if (cmd === 'git remote') {
247
+ return 'origin\nupstream\n';
248
+ }
249
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/foo/bar-xyz') ||
250
+ cmd.includes('show-ref --verify --quiet refs/remotes/upstream/foo/bar-xyz')) {
251
+ return ''; // Both remotes have the branch
252
+ }
253
+ }
254
+ throw new Error('Command not mocked: ' + cmd);
255
+ });
256
+ expect(() => {
257
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
+ service.resolveBranchReference('foo/bar-xyz');
259
+ }).toThrow("Ambiguous branch 'foo/bar-xyz' found in multiple remotes: origin/foo/bar-xyz, upstream/foo/bar-xyz. Please specify which remote to use.");
260
+ });
261
+ it('should return original branch name when no branches exist', () => {
262
+ mockedExecSync.mockImplementation((cmd, _options) => {
263
+ if (typeof cmd === 'string') {
264
+ if (cmd === 'git rev-parse --git-common-dir') {
265
+ return '/fake/path/.git\n';
266
+ }
267
+ if (cmd === 'git remote') {
268
+ return 'origin\n';
269
+ }
270
+ }
271
+ throw new Error('Branch not found');
272
+ });
273
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
274
+ const result = service.resolveBranchReference('nonexistent-branch');
275
+ expect(result).toBe('nonexistent-branch');
276
+ });
277
+ it('should handle no remotes gracefully', () => {
278
+ mockedExecSync.mockImplementation((cmd, _options) => {
279
+ if (typeof cmd === 'string') {
280
+ if (cmd === 'git rev-parse --git-common-dir') {
281
+ return '/fake/path/.git\n';
282
+ }
283
+ if (cmd === 'git remote') {
284
+ return ''; // No remotes
285
+ }
286
+ }
287
+ throw new Error('Command not mocked: ' + cmd);
288
+ });
289
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
290
+ const result = service.resolveBranchReference('some-branch');
291
+ expect(result).toBe('some-branch');
292
+ });
293
+ it('should prefer local branch over remote branches', () => {
294
+ mockedExecSync.mockImplementation((cmd, _options) => {
295
+ if (typeof cmd === 'string') {
296
+ if (cmd === 'git rev-parse --git-common-dir') {
297
+ return '/fake/path/.git\n';
298
+ }
299
+ if (cmd.includes('show-ref --verify --quiet refs/heads/foo/bar-xyz')) {
300
+ return ''; // Local branch exists
301
+ }
302
+ // Remote commands should not be called when local exists
303
+ }
304
+ throw new Error('Command not mocked: ' + cmd);
305
+ });
306
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
307
+ const result = service.resolveBranchReference('foo/bar-xyz');
308
+ expect(result).toBe('foo/bar-xyz');
309
+ });
310
+ });
195
311
  describe('createWorktree', () => {
196
312
  it('should create worktree with base branch when branch does not exist', async () => {
197
313
  mockedExecSync.mockImplementation((cmd, _options) => {
@@ -227,6 +343,33 @@ origin/feature/test
227
343
  expect(result).toEqual({ success: true });
228
344
  expect(execSync).toHaveBeenCalledWith('git worktree add "/path/to/worktree" "existing-feature"', expect.any(Object));
229
345
  });
346
+ it('should handle ambiguous branch error gracefully', async () => {
347
+ mockedExecSync.mockImplementation((cmd, _options) => {
348
+ if (typeof cmd === 'string') {
349
+ if (cmd === 'git rev-parse --git-common-dir') {
350
+ return '/fake/path/.git\n';
351
+ }
352
+ if (cmd.includes('rev-parse --verify new-feature')) {
353
+ throw new Error('Branch not found');
354
+ }
355
+ if (cmd.includes('show-ref --verify --quiet refs/heads/foo/bar-xyz')) {
356
+ throw new Error('Local branch not found');
357
+ }
358
+ if (cmd === 'git remote') {
359
+ return 'origin\nupstream\n';
360
+ }
361
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/foo/bar-xyz') ||
362
+ cmd.includes('show-ref --verify --quiet refs/remotes/upstream/foo/bar-xyz')) {
363
+ return ''; // Both remotes have the branch
364
+ }
365
+ }
366
+ throw new Error('Command not mocked: ' + cmd);
367
+ });
368
+ const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'foo/bar-xyz');
369
+ expect(result.success).toBe(false);
370
+ expect(result.error).toContain("Ambiguous branch 'foo/bar-xyz' found in multiple remotes");
371
+ expect(result.error).toContain('origin/foo/bar-xyz, upstream/foo/bar-xyz');
372
+ });
230
373
  it('should create worktree from specified base branch when branch does not exist', async () => {
231
374
  mockedExecSync.mockImplementation((cmd, _options) => {
232
375
  if (typeof cmd === 'string') {
@@ -544,4 +687,183 @@ branch refs/heads/other-branch
544
687
  expect(mockedExecuteHook).toHaveBeenCalled();
545
688
  });
546
689
  });
690
+ describe('AmbiguousBranchError Integration', () => {
691
+ it('should return error message when createWorktree encounters ambiguous branch', async () => {
692
+ mockedExecSync.mockImplementation((cmd, _options) => {
693
+ if (typeof cmd === 'string') {
694
+ if (cmd === 'git rev-parse --git-common-dir') {
695
+ return '/fake/path/.git\n';
696
+ }
697
+ if (cmd.includes('rev-parse --verify new-feature')) {
698
+ throw new Error('Branch not found');
699
+ }
700
+ if (cmd.includes('show-ref --verify --quiet refs/heads/ambiguous-branch')) {
701
+ throw new Error('Local branch not found');
702
+ }
703
+ if (cmd === 'git remote') {
704
+ return 'origin\nupstream\n';
705
+ }
706
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/ambiguous-branch') ||
707
+ cmd.includes('show-ref --verify --quiet refs/remotes/upstream/ambiguous-branch')) {
708
+ return ''; // Both remotes have the branch
709
+ }
710
+ }
711
+ throw new Error('Command not mocked: ' + cmd);
712
+ });
713
+ const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'ambiguous-branch');
714
+ expect(result.success).toBe(false);
715
+ expect(result.error).toContain("Ambiguous branch 'ambiguous-branch' found in multiple remotes");
716
+ expect(result.error).toContain('origin/ambiguous-branch, upstream/ambiguous-branch');
717
+ expect(result.error).toContain('Please specify which remote to use');
718
+ });
719
+ it('should successfully create worktree with resolved remote reference', async () => {
720
+ mockedExecSync.mockImplementation((cmd, _options) => {
721
+ if (typeof cmd === 'string') {
722
+ if (cmd === 'git rev-parse --git-common-dir') {
723
+ return '/fake/path/.git\n';
724
+ }
725
+ if (cmd.includes('rev-parse --verify new-feature')) {
726
+ throw new Error('Branch not found');
727
+ }
728
+ // Simulate resolved reference (origin/ambiguous-branch) exists
729
+ if (cmd.includes('show-ref --verify --quiet refs/heads/origin/ambiguous-branch')) {
730
+ throw new Error('Local branch not found');
731
+ }
732
+ if (cmd === 'git remote') {
733
+ return 'origin\n';
734
+ }
735
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/origin/ambiguous-branch')) {
736
+ throw new Error('Remote branch not found'); // This is expected for resolved reference
737
+ }
738
+ // Mock successful worktree creation with resolved reference
739
+ if (cmd.includes('git worktree add -b "new-feature" "/path/to/worktree" "origin/ambiguous-branch"')) {
740
+ return '';
741
+ }
742
+ }
743
+ throw new Error('Command not mocked: ' + cmd);
744
+ });
745
+ mockedExistsSync.mockReturnValue(false);
746
+ const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'origin/ambiguous-branch');
747
+ expect(result.success).toBe(true);
748
+ expect(mockedExecSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "origin/ambiguous-branch"', { cwd: '/fake/path', encoding: 'utf8' });
749
+ });
750
+ it('should handle three-way ambiguous branch scenario', async () => {
751
+ mockedExecSync.mockImplementation((cmd, _options) => {
752
+ if (typeof cmd === 'string') {
753
+ if (cmd === 'git rev-parse --git-common-dir') {
754
+ return '/fake/path/.git\n';
755
+ }
756
+ if (cmd.includes('rev-parse --verify test-branch')) {
757
+ throw new Error('Branch not found');
758
+ }
759
+ if (cmd.includes('show-ref --verify --quiet refs/heads/three-way-branch')) {
760
+ throw new Error('Local branch not found');
761
+ }
762
+ if (cmd === 'git remote') {
763
+ return 'origin\nupstream\nfork\n';
764
+ }
765
+ // All three remotes have the branch
766
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/three-way-branch') ||
767
+ cmd.includes('show-ref --verify --quiet refs/remotes/upstream/three-way-branch') ||
768
+ cmd.includes('show-ref --verify --quiet refs/remotes/fork/three-way-branch')) {
769
+ return '';
770
+ }
771
+ }
772
+ throw new Error('Command not mocked: ' + cmd);
773
+ });
774
+ const result = await service.createWorktree('/path/to/worktree', 'test-branch', 'three-way-branch');
775
+ expect(result.success).toBe(false);
776
+ expect(result.error).toContain("Ambiguous branch 'three-way-branch' found in multiple remotes");
777
+ expect(result.error).toContain('origin/three-way-branch, upstream/three-way-branch, fork/three-way-branch');
778
+ });
779
+ it('should handle complex branch names with slashes in ambiguous scenario', async () => {
780
+ mockedExecSync.mockImplementation((cmd, _options) => {
781
+ if (typeof cmd === 'string') {
782
+ if (cmd === 'git rev-parse --git-common-dir') {
783
+ return '/fake/path/.git\n';
784
+ }
785
+ if (cmd.includes('rev-parse --verify new-feature')) {
786
+ throw new Error('Branch not found');
787
+ }
788
+ if (cmd.includes('show-ref --verify --quiet refs/heads/feature/sub/complex-name')) {
789
+ throw new Error('Local branch not found');
790
+ }
791
+ if (cmd === 'git remote') {
792
+ return 'origin\nfork\n';
793
+ }
794
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/feature/sub/complex-name') ||
795
+ cmd.includes('show-ref --verify --quiet refs/remotes/fork/feature/sub/complex-name')) {
796
+ return '';
797
+ }
798
+ }
799
+ throw new Error('Command not mocked: ' + cmd);
800
+ });
801
+ const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'feature/sub/complex-name');
802
+ expect(result.success).toBe(false);
803
+ expect(result.error).toContain("Ambiguous branch 'feature/sub/complex-name' found in multiple remotes");
804
+ expect(result.error).toContain('origin/feature/sub/complex-name, fork/feature/sub/complex-name');
805
+ });
806
+ it('should successfully resolve single remote branch with slashes', async () => {
807
+ mockedExecSync.mockImplementation((cmd, _options) => {
808
+ if (typeof cmd === 'string') {
809
+ if (cmd === 'git rev-parse --git-common-dir') {
810
+ return '/fake/path/.git\n';
811
+ }
812
+ if (cmd.includes('rev-parse --verify new-feature')) {
813
+ throw new Error('Branch not found');
814
+ }
815
+ if (cmd.includes('show-ref --verify --quiet refs/heads/feature/auto-resolve')) {
816
+ throw new Error('Local branch not found');
817
+ }
818
+ if (cmd === 'git remote') {
819
+ return 'origin\nupstream\n';
820
+ }
821
+ // Only origin has this branch
822
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/feature/auto-resolve')) {
823
+ return '';
824
+ }
825
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/upstream/feature/auto-resolve')) {
826
+ throw new Error('Remote branch not found');
827
+ }
828
+ // Mock successful worktree creation with auto-resolved reference
829
+ if (cmd.includes('git worktree add -b "new-feature" "/path/to/worktree" "origin/feature/auto-resolve"')) {
830
+ return '';
831
+ }
832
+ }
833
+ throw new Error('Command not mocked: ' + cmd);
834
+ });
835
+ mockedExistsSync.mockReturnValue(false);
836
+ const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'feature/auto-resolve');
837
+ expect(result.success).toBe(true);
838
+ expect(mockedExecSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "origin/feature/auto-resolve"', { cwd: '/fake/path', encoding: 'utf8' });
839
+ });
840
+ it('should prioritize local branch over remote branches', async () => {
841
+ mockedExecSync.mockImplementation((cmd, _options) => {
842
+ if (typeof cmd === 'string') {
843
+ if (cmd === 'git rev-parse --git-common-dir') {
844
+ return '/fake/path/.git\n';
845
+ }
846
+ if (cmd.includes('rev-parse --verify new-feature')) {
847
+ throw new Error('Branch not found');
848
+ }
849
+ // Local branch exists (highest priority)
850
+ if (cmd.includes('show-ref --verify --quiet refs/heads/local-priority')) {
851
+ return '';
852
+ }
853
+ // Remote checks should not be executed when local exists
854
+ // Mock successful worktree creation with local branch
855
+ if (cmd.includes('git worktree add -b "new-feature" "/path/to/worktree" "local-priority"')) {
856
+ return '';
857
+ }
858
+ }
859
+ throw new Error('Command not mocked: ' + cmd);
860
+ });
861
+ mockedExistsSync.mockReturnValue(false);
862
+ const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'local-priority');
863
+ expect(result.success).toBe(true);
864
+ expect(mockedExecSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "local-priority"', { cwd: '/fake/path', encoding: 'utf8' });
865
+ // Verify remote command was never called since local branch exists
866
+ expect(mockedExecSync).not.toHaveBeenCalledWith('git remote', expect.any(Object));
867
+ });
868
+ });
547
869
  });
@@ -3,7 +3,7 @@ import type pkg from '@xterm/headless';
3
3
  import { GitStatus } from '../utils/gitStatus.js';
4
4
  export type Terminal = InstanceType<typeof pkg.Terminal>;
5
5
  export type SessionState = 'idle' | 'busy' | 'waiting_input';
6
- export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex';
6
+ export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor';
7
7
  export interface Worktree {
8
8
  path: string;
9
9
  branch?: string;
@@ -133,6 +133,16 @@ export interface IProjectManager {
133
133
  clearRecentProjects(): void;
134
134
  validateGitRepository(path: string): Promise<boolean>;
135
135
  }
136
+ export interface RemoteBranchMatch {
137
+ remote: string;
138
+ branch: string;
139
+ fullRef: string;
140
+ }
141
+ export declare class AmbiguousBranchError extends Error {
142
+ branchName: string;
143
+ matches: RemoteBranchMatch[];
144
+ constructor(branchName: string, matches: RemoteBranchMatch[]);
145
+ }
136
146
  export interface IWorktreeService {
137
147
  getWorktrees(): Worktree[];
138
148
  getGitRootPath(): string;
@@ -2,3 +2,23 @@ export const DEFAULT_SHORTCUTS = {
2
2
  returnToMenu: { ctrl: true, key: 'e' },
3
3
  cancel: { key: 'escape' },
4
4
  };
5
+ export class AmbiguousBranchError extends Error {
6
+ constructor(branchName, matches) {
7
+ super(`Ambiguous branch '${branchName}' found in multiple remotes: ${matches
8
+ .map(m => m.fullRef)
9
+ .join(', ')}. Please specify which remote to use.`);
10
+ Object.defineProperty(this, "branchName", {
11
+ enumerable: true,
12
+ configurable: true,
13
+ writable: true,
14
+ value: branchName
15
+ });
16
+ Object.defineProperty(this, "matches", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: matches
21
+ });
22
+ this.name = 'AmbiguousBranchError';
23
+ }
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",