ccmanager 4.3.0 → 4.3.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 +3 -3
- package/dist/components/NewWorktree.js +66 -10
- package/dist/components/NewWorktree.test.js +37 -0
- package/dist/components/SessionActions.d.ts +1 -0
- package/dist/components/SessionActions.js +2 -2
- package/dist/services/worktreeService.d.ts +16 -1
- package/dist/services/worktreeService.js +80 -1
- package/dist/services/worktreeService.test.js +172 -3
- package/dist/types/index.d.ts +27 -0
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -672,8 +672,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
672
672
|
if (view === 'session-actions' && sessionActionsTarget) {
|
|
673
673
|
const { session: targetSession, worktreePath } = sessionActionsTarget;
|
|
674
674
|
const label = targetSession.sessionName
|
|
675
|
-
?
|
|
676
|
-
:
|
|
675
|
+
? targetSession.sessionName
|
|
676
|
+
: `Session #${targetSession.sessionNumber}`;
|
|
677
677
|
const handleSessionAction = async (action) => {
|
|
678
678
|
setSessionActionsTarget(null);
|
|
679
679
|
switch (action) {
|
|
@@ -698,7 +698,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
698
698
|
return;
|
|
699
699
|
}
|
|
700
700
|
};
|
|
701
|
-
return (_jsx(SessionActions, { sessionLabel: label, onSelect: handleSessionAction, onCancel: () => {
|
|
701
|
+
return (_jsx(SessionActions, { sessionLabel: label, worktreePath: worktreePath, onSelect: handleSessionAction, onCancel: () => {
|
|
702
702
|
setSessionActionsTarget(null);
|
|
703
703
|
handleReturnToMenu();
|
|
704
704
|
} }));
|
|
@@ -10,6 +10,7 @@ import { WorktreeService } from '../services/worktreeService.js';
|
|
|
10
10
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
11
11
|
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
12
12
|
import SearchableList from './SearchableList.js';
|
|
13
|
+
import RemoteBranchSelector from './RemoteBranchSelector.js';
|
|
13
14
|
import { Effect } from 'effect';
|
|
14
15
|
import { describePromptInjection, getPromptInjectionMethod, } from '../utils/presetPrompt.js';
|
|
15
16
|
const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
@@ -28,6 +29,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
28
29
|
const [path, setPath] = useState('');
|
|
29
30
|
const [branch, setBranch] = useState('');
|
|
30
31
|
const [baseBranch, setBaseBranch] = useState('');
|
|
32
|
+
// Short branch name to use when creating a local branch from baseBranch
|
|
33
|
+
// (differs from baseBranch when baseBranch is a remote ref like "origin/x")
|
|
34
|
+
const [baseBranchLocalName, setBaseBranchLocalName] = useState('');
|
|
35
|
+
const [ambiguousBase, setAmbiguousBase] = useState(null);
|
|
31
36
|
const [copyClaudeDirectory, setCopyClaudeDirectory] = useState(true);
|
|
32
37
|
const [copySessionData, setCopySessionData] = useState(worktreeConfig.copySessionData ?? true);
|
|
33
38
|
const [selectedPresetId, setSelectedPresetId] = useState(presetsConfig.defaultPresetId);
|
|
@@ -37,9 +42,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
37
42
|
const [branches, setBranches] = useState([]);
|
|
38
43
|
const [remoteBranches, setRemoteBranches] = useState([]);
|
|
39
44
|
const [defaultBranch, setDefaultBranch] = useState('main');
|
|
45
|
+
const worktreeService = useMemo(() => new WorktreeService(projectPath), [projectPath]);
|
|
40
46
|
useEffect(() => {
|
|
41
47
|
let cancelled = false;
|
|
42
|
-
const service =
|
|
48
|
+
const service = worktreeService;
|
|
43
49
|
const loadBranches = async () => {
|
|
44
50
|
const branchesEffect = includeRemoteBranches
|
|
45
51
|
? service.getBranchesWithRemotesEffect()
|
|
@@ -70,8 +76,15 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
70
76
|
setRemoteBranches(result.remote);
|
|
71
77
|
setDefaultBranch(result.defaultBranch);
|
|
72
78
|
setIsLoadingBranches(false);
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
// When the default branch is ambiguous across remotes we can't
|
|
80
|
+
// pick one silently, so keep the base-branch step and let the
|
|
81
|
+
// user choose.
|
|
82
|
+
const resolution = isAutoUseDefaultBranch && result.defaultBranch
|
|
83
|
+
? service.resolveBaseBranch(result.defaultBranch)
|
|
84
|
+
: null;
|
|
85
|
+
if (resolution && resolution.kind !== 'ambiguous') {
|
|
86
|
+
setBaseBranch(resolution.ref);
|
|
87
|
+
setBaseBranchLocalName(resolution.localName);
|
|
75
88
|
setStep(currentStep => currentStep === 'base-branch' ? 'creation-mode' : currentStep);
|
|
76
89
|
}
|
|
77
90
|
}
|
|
@@ -86,7 +99,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
86
99
|
return () => {
|
|
87
100
|
cancelled = true;
|
|
88
101
|
};
|
|
89
|
-
}, [
|
|
102
|
+
}, [worktreeService, isAutoUseDefaultBranch, includeRemoteBranches]);
|
|
90
103
|
const allBranchItems = useMemo(() => {
|
|
91
104
|
const defaultRemoteSuffix = `/${defaultBranch}`;
|
|
92
105
|
const defaultRemotes = remoteBranches.filter(br => br.endsWith(defaultRemoteSuffix));
|
|
@@ -123,6 +136,11 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
123
136
|
const selectedPreset = useMemo(() => presetsConfig.presets.find(preset => preset.id === selectedPresetId) ||
|
|
124
137
|
presetsConfig.presets[0], [selectedPresetId, presetsConfig.presets]);
|
|
125
138
|
useInput((input, key) => {
|
|
139
|
+
if (step === 'remote-branch-confirm') {
|
|
140
|
+
// RemoteBranchSelector handles its own cancel shortcut (returns to
|
|
141
|
+
// the base-branch list); don't also cancel the whole wizard here.
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
126
144
|
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
127
145
|
onCancel();
|
|
128
146
|
}
|
|
@@ -130,22 +148,57 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
130
148
|
return;
|
|
131
149
|
}
|
|
132
150
|
});
|
|
151
|
+
/**
|
|
152
|
+
* Resolves the selected base branch right away. Returns true when the
|
|
153
|
+
* selection is settled; returns false when the branch exists on multiple
|
|
154
|
+
* remotes, in which case the remote-branch-confirm step is shown so the
|
|
155
|
+
* user can disambiguate immediately (instead of failing later when the
|
|
156
|
+
* worktree is actually created).
|
|
157
|
+
*/
|
|
158
|
+
const applyBaseBranchSelection = (name) => {
|
|
159
|
+
const resolution = worktreeService.resolveBaseBranch(name);
|
|
160
|
+
if (resolution.kind === 'ambiguous') {
|
|
161
|
+
setAmbiguousBase({
|
|
162
|
+
branchName: resolution.branchName,
|
|
163
|
+
matches: resolution.matches,
|
|
164
|
+
});
|
|
165
|
+
setStep('remote-branch-confirm');
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
setBaseBranch(resolution.ref);
|
|
169
|
+
setBaseBranchLocalName(resolution.localName);
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
133
172
|
const handlePathSubmit = (value) => {
|
|
134
173
|
if (!value.trim())
|
|
135
174
|
return;
|
|
136
175
|
setPath(value.trim());
|
|
137
176
|
if (isAutoUseDefaultBranch && defaultBranch) {
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
if (applyBaseBranchSelection(defaultBranch)) {
|
|
178
|
+
setStep('creation-mode');
|
|
179
|
+
}
|
|
140
180
|
}
|
|
141
181
|
else {
|
|
142
182
|
setStep('base-branch');
|
|
143
183
|
}
|
|
144
184
|
};
|
|
145
185
|
const handleBaseBranchSelect = (item) => {
|
|
146
|
-
|
|
186
|
+
if (applyBaseBranchSelection(item.value)) {
|
|
187
|
+
setStep('creation-mode');
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const handleAmbiguousBaseSelect = (selectedRemoteRef) => {
|
|
191
|
+
if (!ambiguousBase)
|
|
192
|
+
return;
|
|
193
|
+
setBaseBranch(selectedRemoteRef);
|
|
194
|
+
setBaseBranchLocalName(ambiguousBase.branchName);
|
|
195
|
+
setAmbiguousBase(null);
|
|
147
196
|
setStep('creation-mode');
|
|
148
197
|
};
|
|
198
|
+
const handleAmbiguousBaseCancel = () => {
|
|
199
|
+
setAmbiguousBase(null);
|
|
200
|
+
setStep('base-branch');
|
|
201
|
+
};
|
|
149
202
|
const handleCreationModeSelect = (item) => {
|
|
150
203
|
if (item.value === 'manual') {
|
|
151
204
|
setStep('branch-strategy');
|
|
@@ -156,7 +209,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
156
209
|
const handleBranchStrategySelect = (item) => {
|
|
157
210
|
const useExisting = item.value === 'existing';
|
|
158
211
|
if (useExisting) {
|
|
159
|
-
|
|
212
|
+
// Use the short branch name: when baseBranch is a remote ref
|
|
213
|
+
// (e.g. "origin/feature/x"), the local branch to attach/create is
|
|
214
|
+
// "feature/x", not a branch literally named "origin/feature/x".
|
|
215
|
+
setBranch(baseBranchLocalName || baseBranch);
|
|
160
216
|
setStep('copy-settings');
|
|
161
217
|
}
|
|
162
218
|
else {
|
|
@@ -264,7 +320,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
264
320
|
const promptMethod = selectedPreset
|
|
265
321
|
? getPromptInjectionMethod(selectedPreset)
|
|
266
322
|
: 'stdin';
|
|
267
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
|
|
323
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'remote-branch-confirm' && ambiguousBase && (_jsx(RemoteBranchSelector, { branchName: ambiguousBase.branchName, matches: ambiguousBase.matches, onSelect: handleAmbiguousBaseSelect, onCancel: handleAmbiguousBaseCancel })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
|
|
268
324
|
{
|
|
269
325
|
label: '1. Choose the branch name yourself',
|
|
270
326
|
value: 'manual',
|
|
@@ -291,6 +347,6 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
291
347
|
], onSelect: handleCopySettingsSelect, initialIndex: 0 })] })), step === 'copy-session' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Copy Claude Code session data to the new worktree?" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "This will copy conversation history and context from the current worktree." }) }), isAutoDirectory && generatedPath ? (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Worktree path preview:", ' ', _jsx(Text, { color: "green", children: generatedPath })] }) })) : null, _jsx(SelectInput, { items: [
|
|
292
348
|
{ label: '✅ Yes, copy session data', value: 'yes' },
|
|
293
349
|
{ label: '❌ No, start fresh', value: 'no' },
|
|
294
|
-
], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) })] }));
|
|
350
|
+
], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), step !== 'remote-branch-confirm' && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) }))] }));
|
|
295
351
|
};
|
|
296
352
|
export default NewWorktree;
|
|
@@ -289,6 +289,11 @@ describe('NewWorktree component Effect integration', () => {
|
|
|
289
289
|
return {
|
|
290
290
|
getAllBranchesEffect: vi.fn(() => Effect.succeed(mockBranches)),
|
|
291
291
|
getDefaultBranchEffect: vi.fn(() => Effect.succeed(mockDefaultBranch)),
|
|
292
|
+
resolveBaseBranch: vi.fn((name) => ({
|
|
293
|
+
kind: 'local',
|
|
294
|
+
ref: name,
|
|
295
|
+
localName: name,
|
|
296
|
+
})),
|
|
292
297
|
};
|
|
293
298
|
});
|
|
294
299
|
const onComplete = vi.fn();
|
|
@@ -305,6 +310,38 @@ describe('NewWorktree component Effect integration', () => {
|
|
|
305
310
|
expect(output).toContain('Choose the branch name yourself');
|
|
306
311
|
expect(output).toContain('Enter a prompt first');
|
|
307
312
|
});
|
|
313
|
+
it('should keep the base branch selection when the default branch is ambiguous across remotes', async () => {
|
|
314
|
+
const { Effect } = await import('effect');
|
|
315
|
+
const { WorktreeService } = await import('../services/worktreeService.js');
|
|
316
|
+
const { configReader } = await import('../services/config/configReader.js');
|
|
317
|
+
vi.spyOn(configReader, 'getWorktreeConfig').mockReturnValue({
|
|
318
|
+
autoDirectory: true,
|
|
319
|
+
autoDirectoryPattern: '../{project}-{branch}',
|
|
320
|
+
copySessionData: true,
|
|
321
|
+
autoUseDefaultBranch: true,
|
|
322
|
+
});
|
|
323
|
+
// Default branch has no local ref and exists on two remotes: it cannot
|
|
324
|
+
// be picked silently, so the base-branch step must not be skipped.
|
|
325
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
326
|
+
return {
|
|
327
|
+
getAllBranchesEffect: vi.fn(() => Effect.succeed(['main', 'develop'])),
|
|
328
|
+
getDefaultBranchEffect: vi.fn(() => Effect.succeed('main')),
|
|
329
|
+
resolveBaseBranch: vi.fn(() => ({
|
|
330
|
+
kind: 'ambiguous',
|
|
331
|
+
branchName: 'main',
|
|
332
|
+
matches: [
|
|
333
|
+
{ remote: 'origin', branch: 'main', fullRef: 'origin/main' },
|
|
334
|
+
{ remote: 'upstream', branch: 'main', fullRef: 'upstream/main' },
|
|
335
|
+
],
|
|
336
|
+
})),
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
const { lastFrame } = render(_jsx(NewWorktree, { onComplete: vi.fn(), onCancel: vi.fn() }));
|
|
340
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
341
|
+
const output = lastFrame();
|
|
342
|
+
expect(output).toContain('Select base branch');
|
|
343
|
+
expect(output).not.toContain('How do you want to create the new worktree?');
|
|
344
|
+
});
|
|
308
345
|
it('should show base branch selection when autoUseDefaultBranch is disabled', async () => {
|
|
309
346
|
const { Effect } = await import('effect');
|
|
310
347
|
const { WorktreeService } = await import('../services/worktreeService.js');
|
|
@@ -6,7 +6,7 @@ const items = [
|
|
|
6
6
|
{ label: 'R Rename this session', value: 'rename' },
|
|
7
7
|
{ label: 'X Close session', value: 'kill' },
|
|
8
8
|
];
|
|
9
|
-
const SessionActions = ({ sessionLabel, onSelect, onCancel, }) => {
|
|
9
|
+
const SessionActions = ({ sessionLabel, worktreePath, onSelect, onCancel, }) => {
|
|
10
10
|
useInput((input, key) => {
|
|
11
11
|
if (key.escape) {
|
|
12
12
|
onCancel();
|
|
@@ -24,6 +24,6 @@ const SessionActions = ({ sessionLabel, onSelect, onCancel, }) => {
|
|
|
24
24
|
break;
|
|
25
25
|
}
|
|
26
26
|
});
|
|
27
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Session Actions" }),
|
|
27
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Session Actions" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: sessionLabel }), _jsxs(Text, { dimColor: true, children: ["Directory: ", worktreePath] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "S/R/X or arrow keys + Enter | Escape to cancel" }) })] }));
|
|
28
28
|
};
|
|
29
29
|
export default SessionActions;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect } from 'effect';
|
|
2
|
-
import { Worktree, CreateWorktreeResult, AmbiguousBranchError, MergeConfig } from '../types/index.js';
|
|
2
|
+
import { Worktree, CreateWorktreeResult, AmbiguousBranchError, BaseBranchResolution, MergeConfig } from '../types/index.js';
|
|
3
3
|
import { GitError, FileSystemError, ProcessError } from '../types/errors.js';
|
|
4
4
|
/**
|
|
5
5
|
* WorktreeService - Git worktree management with Effect-based error handling
|
|
@@ -54,6 +54,21 @@ export declare class WorktreeService {
|
|
|
54
54
|
*/
|
|
55
55
|
private resolveBranchReference;
|
|
56
56
|
private resolveBranchReferenceEffect;
|
|
57
|
+
/**
|
|
58
|
+
* Classifies a base branch picked in the UI, without throwing.
|
|
59
|
+
*
|
|
60
|
+
* Unlike resolveBranchReference(), this is meant to run right after the
|
|
61
|
+
* user selects a base branch so that:
|
|
62
|
+
* - a local branch is confirmed immediately (never routed to the
|
|
63
|
+
* ambiguous-remote confirmation), and
|
|
64
|
+
* - an ambiguous branch (same name in multiple remotes) can be
|
|
65
|
+
* disambiguated right away instead of failing later at creation time.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} branchName - Branch name or remote-qualified ref
|
|
68
|
+
* (e.g. "feature/x" or "origin/feature/x") selected as base branch
|
|
69
|
+
* @returns {BaseBranchResolution} Classification result (see type docs)
|
|
70
|
+
*/
|
|
71
|
+
resolveBaseBranch(branchName: string): BaseBranchResolution;
|
|
57
72
|
/**
|
|
58
73
|
* SYNCHRONOUS HELPER: Gets all git remotes for this repository.
|
|
59
74
|
*
|
|
@@ -174,6 +174,79 @@ export class WorktreeService {
|
|
|
174
174
|
},
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Classifies a base branch picked in the UI, without throwing.
|
|
179
|
+
*
|
|
180
|
+
* Unlike resolveBranchReference(), this is meant to run right after the
|
|
181
|
+
* user selects a base branch so that:
|
|
182
|
+
* - a local branch is confirmed immediately (never routed to the
|
|
183
|
+
* ambiguous-remote confirmation), and
|
|
184
|
+
* - an ambiguous branch (same name in multiple remotes) can be
|
|
185
|
+
* disambiguated right away instead of failing later at creation time.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} branchName - Branch name or remote-qualified ref
|
|
188
|
+
* (e.g. "feature/x" or "origin/feature/x") selected as base branch
|
|
189
|
+
* @returns {BaseBranchResolution} Classification result (see type docs)
|
|
190
|
+
*/
|
|
191
|
+
resolveBaseBranch(branchName) {
|
|
192
|
+
// Local branch has the highest priority
|
|
193
|
+
try {
|
|
194
|
+
execSync(`git show-ref --verify --quiet refs/heads/${branchName}`, {
|
|
195
|
+
cwd: this.rootPath,
|
|
196
|
+
encoding: 'utf8',
|
|
197
|
+
});
|
|
198
|
+
return { kind: 'local', ref: branchName, localName: branchName };
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Not a local branch, check remotes below
|
|
202
|
+
}
|
|
203
|
+
const remotes = this.getAllRemotes();
|
|
204
|
+
// Already remote-qualified (e.g. "origin/feature/x" selected from the
|
|
205
|
+
// remote section of the branch list): not ambiguous by construction.
|
|
206
|
+
for (const remote of remotes) {
|
|
207
|
+
const prefix = `${remote}/`;
|
|
208
|
+
if (!branchName.startsWith(prefix))
|
|
209
|
+
continue;
|
|
210
|
+
try {
|
|
211
|
+
execSync(`git show-ref --verify --quiet refs/remotes/${branchName}`, {
|
|
212
|
+
cwd: this.rootPath,
|
|
213
|
+
encoding: 'utf8',
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
kind: 'remote',
|
|
217
|
+
ref: branchName,
|
|
218
|
+
localName: branchName.slice(prefix.length),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Not an existing remote-tracking ref; fall through to matching
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const matches = [];
|
|
226
|
+
for (const remote of remotes) {
|
|
227
|
+
try {
|
|
228
|
+
execSync(`git show-ref --verify --quiet refs/remotes/${remote}/${branchName}`, {
|
|
229
|
+
cwd: this.rootPath,
|
|
230
|
+
encoding: 'utf8',
|
|
231
|
+
});
|
|
232
|
+
matches.push({
|
|
233
|
+
remote,
|
|
234
|
+
branch: branchName,
|
|
235
|
+
fullRef: `${remote}/${branchName}`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// This remote doesn't have the branch, continue
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (matches.length === 1) {
|
|
243
|
+
return { kind: 'remote', ref: matches[0].fullRef, localName: branchName };
|
|
244
|
+
}
|
|
245
|
+
if (matches.length > 1) {
|
|
246
|
+
return { kind: 'ambiguous', branchName, matches };
|
|
247
|
+
}
|
|
248
|
+
return { kind: 'none', ref: branchName, localName: branchName };
|
|
249
|
+
}
|
|
177
250
|
/**
|
|
178
251
|
* SYNCHRONOUS HELPER: Gets all git remotes for this repository.
|
|
179
252
|
*
|
|
@@ -831,7 +904,13 @@ export class WorktreeService {
|
|
|
831
904
|
command = `git worktree add -b "${branch}" "${resolvedPath}" "${baseBranch}"`;
|
|
832
905
|
}
|
|
833
906
|
else {
|
|
834
|
-
|
|
907
|
+
// The new branch name itself may match remote branches (checkout
|
|
908
|
+
// semantics: typing "feature/x" checks out origin/feature/x when it
|
|
909
|
+
// exists on exactly one remote). When the name exists on MULTIPLE
|
|
910
|
+
// remotes, don't fail with AmbiguousBranchError: the user already
|
|
911
|
+
// chose baseBranch explicitly, so create the new branch from
|
|
912
|
+
// baseBranch instead of asking which remote to track.
|
|
913
|
+
const resolvedRef = yield* Effect.catchAll(self.resolveBranchReferenceEffect(branch), () => Effect.succeed(branch));
|
|
835
914
|
const isRemoteBranch = resolvedRef !== branch;
|
|
836
915
|
const startPoint = isRemoteBranch
|
|
837
916
|
? resolvedRef
|
|
@@ -425,6 +425,133 @@ origin/feature/test
|
|
|
425
425
|
}
|
|
426
426
|
});
|
|
427
427
|
});
|
|
428
|
+
describe('resolveBaseBranch', () => {
|
|
429
|
+
it('should classify a local branch as local without checking remotes', () => {
|
|
430
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
431
|
+
if (typeof cmd === 'string') {
|
|
432
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
433
|
+
return '/fake/path/.git\n';
|
|
434
|
+
}
|
|
435
|
+
if (cmd === 'git show-ref --verify --quiet refs/heads/feature/x') {
|
|
436
|
+
return ''; // Local branch exists
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
440
|
+
});
|
|
441
|
+
const result = service.resolveBaseBranch('feature/x');
|
|
442
|
+
expect(result).toEqual({
|
|
443
|
+
kind: 'local',
|
|
444
|
+
ref: 'feature/x',
|
|
445
|
+
localName: 'feature/x',
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
it('should classify a remote-qualified ref as remote with the short local name', () => {
|
|
449
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
450
|
+
if (typeof cmd === 'string') {
|
|
451
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
452
|
+
return '/fake/path/.git\n';
|
|
453
|
+
}
|
|
454
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
455
|
+
throw new Error('Local branch not found');
|
|
456
|
+
}
|
|
457
|
+
if (cmd === 'git remote') {
|
|
458
|
+
return 'origin\nupstream\n';
|
|
459
|
+
}
|
|
460
|
+
if (cmd ===
|
|
461
|
+
'git show-ref --verify --quiet refs/remotes/origin/feature/x') {
|
|
462
|
+
return '';
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
466
|
+
});
|
|
467
|
+
const result = service.resolveBaseBranch('origin/feature/x');
|
|
468
|
+
expect(result).toEqual({
|
|
469
|
+
kind: 'remote',
|
|
470
|
+
ref: 'origin/feature/x',
|
|
471
|
+
localName: 'feature/x',
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
it('should classify a branch existing on a single remote as remote', () => {
|
|
475
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
476
|
+
if (typeof cmd === 'string') {
|
|
477
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
478
|
+
return '/fake/path/.git\n';
|
|
479
|
+
}
|
|
480
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
481
|
+
throw new Error('Local branch not found');
|
|
482
|
+
}
|
|
483
|
+
if (cmd === 'git remote') {
|
|
484
|
+
return 'origin\nupstream\n';
|
|
485
|
+
}
|
|
486
|
+
if (cmd ===
|
|
487
|
+
'git show-ref --verify --quiet refs/remotes/origin/feature/x') {
|
|
488
|
+
return '';
|
|
489
|
+
}
|
|
490
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
491
|
+
throw new Error('Remote branch not found');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
495
|
+
});
|
|
496
|
+
const result = service.resolveBaseBranch('feature/x');
|
|
497
|
+
expect(result).toEqual({
|
|
498
|
+
kind: 'remote',
|
|
499
|
+
ref: 'origin/feature/x',
|
|
500
|
+
localName: 'feature/x',
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
it('should classify a branch existing on multiple remotes as ambiguous', () => {
|
|
504
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
505
|
+
if (typeof cmd === 'string') {
|
|
506
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
507
|
+
return '/fake/path/.git\n';
|
|
508
|
+
}
|
|
509
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
510
|
+
throw new Error('Local branch not found');
|
|
511
|
+
}
|
|
512
|
+
if (cmd === 'git remote') {
|
|
513
|
+
return 'origin\nupstream\n';
|
|
514
|
+
}
|
|
515
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
516
|
+
return ''; // Both remotes have the branch
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
520
|
+
});
|
|
521
|
+
const result = service.resolveBaseBranch('feature/x');
|
|
522
|
+
expect(result).toEqual({
|
|
523
|
+
kind: 'ambiguous',
|
|
524
|
+
branchName: 'feature/x',
|
|
525
|
+
matches: [
|
|
526
|
+
{ remote: 'origin', branch: 'feature/x', fullRef: 'origin/feature/x' },
|
|
527
|
+
{
|
|
528
|
+
remote: 'upstream',
|
|
529
|
+
branch: 'feature/x',
|
|
530
|
+
fullRef: 'upstream/feature/x',
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
it('should classify an unknown branch as none', () => {
|
|
536
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
537
|
+
if (typeof cmd === 'string') {
|
|
538
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
539
|
+
return '/fake/path/.git\n';
|
|
540
|
+
}
|
|
541
|
+
if (cmd === 'git remote') {
|
|
542
|
+
return 'origin\n';
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
throw new Error('Branch not found');
|
|
546
|
+
});
|
|
547
|
+
const result = service.resolveBaseBranch('nonexistent');
|
|
548
|
+
expect(result).toEqual({
|
|
549
|
+
kind: 'none',
|
|
550
|
+
ref: 'nonexistent',
|
|
551
|
+
localName: 'nonexistent',
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
});
|
|
428
555
|
describe('hasClaudeDirectoryInBranchEffect', () => {
|
|
429
556
|
it('should return Effect with true when .claude directory exists in branch worktree', async () => {
|
|
430
557
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
@@ -751,7 +878,43 @@ branch refs/heads/feature
|
|
|
751
878
|
expect(worktreeAddCmd).toContain('-b "feature/remote-only"');
|
|
752
879
|
expect(worktreeAddCmd).toContain('"origin/feature/remote-only"');
|
|
753
880
|
});
|
|
754
|
-
it('should
|
|
881
|
+
it('should fall back to baseBranch when the new branch name exists in multiple remotes', async () => {
|
|
882
|
+
const executedCommands = [];
|
|
883
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
884
|
+
if (typeof cmd === 'string') {
|
|
885
|
+
executedCommands.push(cmd);
|
|
886
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
887
|
+
return '/fake/path/.git\n';
|
|
888
|
+
}
|
|
889
|
+
// baseBranch "main" exists locally; the new branch does not
|
|
890
|
+
if (cmd === 'git show-ref --verify --quiet refs/heads/main') {
|
|
891
|
+
return '';
|
|
892
|
+
}
|
|
893
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
894
|
+
throw new Error('Branch not found');
|
|
895
|
+
}
|
|
896
|
+
if (cmd === 'git remote') {
|
|
897
|
+
return 'origin\nkbwo-fork\n';
|
|
898
|
+
}
|
|
899
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
900
|
+
return ''; // Both remotes have the new branch name
|
|
901
|
+
}
|
|
902
|
+
if (cmd.includes('git worktree add')) {
|
|
903
|
+
return '';
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return '';
|
|
907
|
+
});
|
|
908
|
+
// The user explicitly chose "main" as base branch; the ambiguity of
|
|
909
|
+
// "feature/feed-mention" across remotes must not fail the creation.
|
|
910
|
+
const effect = service.createWorktreeEffect('/path/to/worktree', 'feature/feed-mention', 'main');
|
|
911
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
912
|
+
expect(result._tag).toBe('Right');
|
|
913
|
+
const worktreeAddCmd = executedCommands.find(c => c.includes('git worktree add'));
|
|
914
|
+
expect(worktreeAddCmd).toContain('-b "feature/feed-mention"');
|
|
915
|
+
expect(worktreeAddCmd).toContain('"main"');
|
|
916
|
+
});
|
|
917
|
+
it('should return Effect Left with AmbiguousBranchError when baseBranch exists in multiple remotes', async () => {
|
|
755
918
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
756
919
|
if (typeof cmd === 'string') {
|
|
757
920
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
@@ -763,13 +926,19 @@ branch refs/heads/feature
|
|
|
763
926
|
if (cmd === 'git remote') {
|
|
764
927
|
return 'origin\nkbwo-fork\n';
|
|
765
928
|
}
|
|
929
|
+
// Only the baseBranch exists on the remotes; the new branch
|
|
930
|
+
// name matches nothing anywhere.
|
|
931
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/feature/feed-mention') ||
|
|
932
|
+
cmd.includes('show-ref --verify --quiet refs/remotes/kbwo-fork/feature/feed-mention')) {
|
|
933
|
+
return '';
|
|
934
|
+
}
|
|
766
935
|
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
767
|
-
|
|
936
|
+
throw new Error('Remote branch not found');
|
|
768
937
|
}
|
|
769
938
|
}
|
|
770
939
|
throw new Error('Command not mocked: ' + cmd);
|
|
771
940
|
});
|
|
772
|
-
const effect = service.createWorktreeEffect('/path/to/worktree', 'feature/feed-mention'
|
|
941
|
+
const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'feature/feed-mention');
|
|
773
942
|
const result = await Effect.runPromise(Effect.either(effect));
|
|
774
943
|
expect(result._tag).toBe('Left');
|
|
775
944
|
if (result._tag === 'Left') {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -249,6 +249,33 @@ export interface RemoteBranchMatch {
|
|
|
249
249
|
branch: string;
|
|
250
250
|
fullRef: string;
|
|
251
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Result of classifying a base branch the user picked in the UI.
|
|
254
|
+
*
|
|
255
|
+
* - 'local': branch exists locally; `ref` is the branch name as-is.
|
|
256
|
+
* - 'remote': resolved to exactly one remote-tracking ref; `ref` is the full
|
|
257
|
+
* ref (e.g. "origin/foo") and `localName` the short branch name to use when
|
|
258
|
+
* creating a local branch from it.
|
|
259
|
+
* - 'ambiguous': branch exists in multiple remotes; the user must pick one.
|
|
260
|
+
* - 'none': nothing matched; pass `ref` through and let git report errors.
|
|
261
|
+
*/
|
|
262
|
+
export type BaseBranchResolution = {
|
|
263
|
+
kind: 'local';
|
|
264
|
+
ref: string;
|
|
265
|
+
localName: string;
|
|
266
|
+
} | {
|
|
267
|
+
kind: 'remote';
|
|
268
|
+
ref: string;
|
|
269
|
+
localName: string;
|
|
270
|
+
} | {
|
|
271
|
+
kind: 'none';
|
|
272
|
+
ref: string;
|
|
273
|
+
localName: string;
|
|
274
|
+
} | {
|
|
275
|
+
kind: 'ambiguous';
|
|
276
|
+
branchName: string;
|
|
277
|
+
matches: RemoteBranchMatch[];
|
|
278
|
+
};
|
|
252
279
|
export declare class AmbiguousBranchError extends Error {
|
|
253
280
|
readonly _tag: "AmbiguousBranchError";
|
|
254
281
|
branchName: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kodai Kabasawa",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"bin"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@kodaikabasawa/ccmanager-darwin-arm64": "4.3.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.3.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.3.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.3.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.3.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.3.1",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.3.1",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.3.1",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.3.1",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.3.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|