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.
- package/dist/components/App.js +80 -3
- package/dist/components/NewWorktree.js +50 -27
- package/dist/components/RemoteBranchSelector.d.ts +10 -0
- package/dist/components/RemoteBranchSelector.js +47 -0
- package/dist/components/RemoteBranchSelector.test.d.ts +1 -0
- package/dist/components/RemoteBranchSelector.test.js +143 -0
- package/dist/services/worktreeService.d.ts +15 -0
- package/dist/services/worktreeService.js +109 -2
- package/dist/services/worktreeService.test.js +322 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.js +20 -0
- package/package.json +1 -1
package/dist/components/App.js
CHANGED
|
@@ -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
|
|
197
|
-
|
|
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('
|
|
68
|
+
setStep('copy-settings');
|
|
69
69
|
}
|
|
70
70
|
};
|
|
71
71
|
const handleBaseBranchSelect = (item) => {
|
|
72
72
|
setBaseBranch(item.value);
|
|
73
|
-
setStep('
|
|
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" })))) :
|
|
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
|
-
//
|
|
218
|
-
|
|
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
|
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/index.js
CHANGED
|
@@ -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
|
+
}
|