ccmanager 2.4.1 → 2.5.1

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;
@@ -12,7 +12,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
12
12
  const isAutoDirectory = worktreeConfig.autoDirectory;
13
13
  const limit = 10;
14
14
  // Adjust initial step based on auto directory mode
15
- const [step, setStep] = useState(isAutoDirectory ? 'branch' : 'path');
15
+ const [step, setStep] = useState(isAutoDirectory ? 'base-branch' : 'path');
16
16
  const [path, setPath] = useState('');
17
17
  const [branch, setBranch] = useState('');
18
18
  const [baseBranch, setBaseBranch] = useState('');
@@ -59,18 +59,30 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
59
59
  const handlePathSubmit = (value) => {
60
60
  if (value.trim()) {
61
61
  setPath(value.trim());
62
- setStep('branch');
62
+ setStep('base-branch');
63
63
  }
64
64
  };
65
65
  const handleBranchSubmit = (value) => {
66
66
  if (value.trim()) {
67
67
  setBranch(value.trim());
68
- setStep('base-branch');
68
+ setStep('copy-settings');
69
69
  }
70
70
  };
71
71
  const handleBaseBranchSelect = (item) => {
72
72
  setBaseBranch(item.value);
73
- setStep('copy-settings');
73
+ setStep('branch-strategy');
74
+ };
75
+ const handleBranchStrategySelect = (item) => {
76
+ const useExisting = item.value === 'existing';
77
+ if (useExisting) {
78
+ // Use the base branch as the branch name for existing branch
79
+ setBranch(baseBranch);
80
+ setStep('copy-settings');
81
+ }
82
+ else {
83
+ // Need to input new branch name
84
+ setStep('branch');
85
+ }
74
86
  };
75
87
  const handleCopySettingsSelect = (item) => {
76
88
  setCopyClaudeDirectory(item.value);
@@ -107,31 +119,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
107
119
  React.createElement(Text, null, "Enter worktree path (relative to repository root):")),
108
120
  React.createElement(Box, null,
109
121
  React.createElement(Text, { color: "cyan" }, '> '),
110
- React.createElement(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })))) : step === 'branch' && !isAutoDirectory ? (React.createElement(Box, { flexDirection: "column" },
111
- React.createElement(Box, { marginBottom: 1 },
112
- React.createElement(Text, null,
113
- "Enter branch name for worktree at ",
114
- React.createElement(Text, { color: "cyan" }, path),
115
- ":")),
116
- React.createElement(Box, null,
117
- React.createElement(Text, { color: "cyan" }, '> '),
118
- React.createElement(TextInputWrapper, { value: branch, onChange: setBranch, onSubmit: handleBranchSubmit, placeholder: "e.g., feature/new-feature" })))) : step === 'branch' ? (React.createElement(Box, { flexDirection: "column" },
119
- React.createElement(Box, { marginBottom: 1 },
120
- React.createElement(Text, null, "Enter branch name (directory will be auto-generated):")),
121
- React.createElement(Box, null,
122
- React.createElement(Text, { color: "cyan" }, '> '),
123
- React.createElement(TextInputWrapper, { value: branch, onChange: setBranch, onSubmit: handleBranchSubmit, placeholder: "e.g., feature/new-feature" })),
124
- generatedPath && (React.createElement(Box, { marginTop: 1 },
125
- React.createElement(Text, { dimColor: true },
126
- "Worktree will be created at:",
127
- ' ',
128
- React.createElement(Text, { color: "green" }, generatedPath)))))) : null,
122
+ React.createElement(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })))) : null,
129
123
  step === 'base-branch' && (React.createElement(Box, { flexDirection: "column" },
130
124
  React.createElement(Box, { marginBottom: 1 },
131
- React.createElement(Text, null,
132
- "Select base branch for ",
133
- React.createElement(Text, { color: "cyan" }, branch),
134
- ":")),
125
+ React.createElement(Text, null, "Select base branch for the worktree:")),
135
126
  isSearchMode && (React.createElement(Box, { marginBottom: 1 },
136
127
  React.createElement(Text, null, "Search: "),
137
128
  React.createElement(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter branches..." }))),
@@ -143,6 +134,38 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
143
134
  item.label))))) : (React.createElement(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode })),
144
135
  !isSearchMode && (React.createElement(Box, { marginTop: 1 },
145
136
  React.createElement(Text, { dimColor: true }, "Press / to search"))))),
137
+ step === 'branch-strategy' && (React.createElement(Box, { flexDirection: "column" },
138
+ React.createElement(Box, { marginBottom: 1 },
139
+ React.createElement(Text, null,
140
+ "Base branch: ",
141
+ React.createElement(Text, { color: "cyan" }, baseBranch))),
142
+ React.createElement(Box, { marginBottom: 1 },
143
+ React.createElement(Text, null, "Choose branch creation strategy:")),
144
+ React.createElement(SelectInput, { items: [
145
+ {
146
+ label: 'Create new branch from base branch',
147
+ value: 'new',
148
+ },
149
+ {
150
+ label: 'Use existing base branch',
151
+ value: 'existing',
152
+ },
153
+ ], onSelect: handleBranchStrategySelect, initialIndex: 0 }))),
154
+ step === 'branch' && (React.createElement(Box, { flexDirection: "column" },
155
+ React.createElement(Box, { marginBottom: 1 },
156
+ React.createElement(Text, null,
157
+ "Enter new branch name (will be created from",
158
+ ' ',
159
+ React.createElement(Text, { color: "cyan" }, baseBranch),
160
+ "):")),
161
+ React.createElement(Box, null,
162
+ React.createElement(Text, { color: "cyan" }, '> '),
163
+ React.createElement(TextInputWrapper, { value: branch, onChange: setBranch, onSubmit: handleBranchSubmit, placeholder: "e.g., feature/new-feature" })),
164
+ isAutoDirectory && generatedPath && (React.createElement(Box, { marginTop: 1 },
165
+ React.createElement(Text, { dimColor: true },
166
+ "Worktree will be created at:",
167
+ ' ',
168
+ React.createElement(Text, { color: "green" }, generatedPath)))))),
146
169
  step === 'copy-settings' && (React.createElement(Box, { flexDirection: "column" },
147
170
  React.createElement(Box, { marginBottom: 1 },
148
171
  React.createElement(Text, null,
@@ -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
+ });
@@ -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
  });
@@ -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.4.1",
3
+ "version": "2.5.1",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",