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.
@@ -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
- ? `${worktreePath} : ${targetSession.sessionName}`
676
- : `${worktreePath} #${targetSession.sessionNumber}`;
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 = new WorktreeService(projectPath);
48
+ const service = worktreeService;
43
49
  const loadBranches = async () => {
44
50
  const branchesEffect = includeRemoteBranches
45
51
  ? service.getBranchesWithRemotesEffect()
@@ -70,8 +76,15 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
70
76
  setRemoteBranches(result.remote);
71
77
  setDefaultBranch(result.defaultBranch);
72
78
  setIsLoadingBranches(false);
73
- if (isAutoUseDefaultBranch && result.defaultBranch) {
74
- setBaseBranch(result.defaultBranch);
79
+ // When the default branch is ambiguous across remotes we can't
80
+ // pick one silently, so keep the base-branch step and let the
81
+ // user choose.
82
+ const resolution = isAutoUseDefaultBranch && result.defaultBranch
83
+ ? service.resolveBaseBranch(result.defaultBranch)
84
+ : null;
85
+ if (resolution && resolution.kind !== 'ambiguous') {
86
+ setBaseBranch(resolution.ref);
87
+ setBaseBranchLocalName(resolution.localName);
75
88
  setStep(currentStep => currentStep === 'base-branch' ? 'creation-mode' : currentStep);
76
89
  }
77
90
  }
@@ -86,7 +99,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
86
99
  return () => {
87
100
  cancelled = true;
88
101
  };
89
- }, [projectPath, isAutoUseDefaultBranch, includeRemoteBranches]);
102
+ }, [worktreeService, isAutoUseDefaultBranch, includeRemoteBranches]);
90
103
  const allBranchItems = useMemo(() => {
91
104
  const defaultRemoteSuffix = `/${defaultBranch}`;
92
105
  const defaultRemotes = remoteBranches.filter(br => br.endsWith(defaultRemoteSuffix));
@@ -123,6 +136,11 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
123
136
  const selectedPreset = useMemo(() => presetsConfig.presets.find(preset => preset.id === selectedPresetId) ||
124
137
  presetsConfig.presets[0], [selectedPresetId, presetsConfig.presets]);
125
138
  useInput((input, key) => {
139
+ if (step === 'remote-branch-confirm') {
140
+ // RemoteBranchSelector handles its own cancel shortcut (returns to
141
+ // the base-branch list); don't also cancel the whole wizard here.
142
+ return;
143
+ }
126
144
  if (shortcutManager.matchesShortcut('cancel', input, key)) {
127
145
  onCancel();
128
146
  }
@@ -130,22 +148,57 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
130
148
  return;
131
149
  }
132
150
  });
151
+ /**
152
+ * Resolves the selected base branch right away. Returns true when the
153
+ * selection is settled; returns false when the branch exists on multiple
154
+ * remotes, in which case the remote-branch-confirm step is shown so the
155
+ * user can disambiguate immediately (instead of failing later when the
156
+ * worktree is actually created).
157
+ */
158
+ const applyBaseBranchSelection = (name) => {
159
+ const resolution = worktreeService.resolveBaseBranch(name);
160
+ if (resolution.kind === 'ambiguous') {
161
+ setAmbiguousBase({
162
+ branchName: resolution.branchName,
163
+ matches: resolution.matches,
164
+ });
165
+ setStep('remote-branch-confirm');
166
+ return false;
167
+ }
168
+ setBaseBranch(resolution.ref);
169
+ setBaseBranchLocalName(resolution.localName);
170
+ return true;
171
+ };
133
172
  const handlePathSubmit = (value) => {
134
173
  if (!value.trim())
135
174
  return;
136
175
  setPath(value.trim());
137
176
  if (isAutoUseDefaultBranch && defaultBranch) {
138
- setBaseBranch(defaultBranch);
139
- setStep('creation-mode');
177
+ if (applyBaseBranchSelection(defaultBranch)) {
178
+ setStep('creation-mode');
179
+ }
140
180
  }
141
181
  else {
142
182
  setStep('base-branch');
143
183
  }
144
184
  };
145
185
  const handleBaseBranchSelect = (item) => {
146
- setBaseBranch(item.value);
186
+ if (applyBaseBranchSelection(item.value)) {
187
+ setStep('creation-mode');
188
+ }
189
+ };
190
+ const handleAmbiguousBaseSelect = (selectedRemoteRef) => {
191
+ if (!ambiguousBase)
192
+ return;
193
+ setBaseBranch(selectedRemoteRef);
194
+ setBaseBranchLocalName(ambiguousBase.branchName);
195
+ setAmbiguousBase(null);
147
196
  setStep('creation-mode');
148
197
  };
198
+ const handleAmbiguousBaseCancel = () => {
199
+ setAmbiguousBase(null);
200
+ setStep('base-branch');
201
+ };
149
202
  const handleCreationModeSelect = (item) => {
150
203
  if (item.value === 'manual') {
151
204
  setStep('branch-strategy');
@@ -156,7 +209,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
156
209
  const handleBranchStrategySelect = (item) => {
157
210
  const useExisting = item.value === 'existing';
158
211
  if (useExisting) {
159
- setBranch(baseBranch);
212
+ // Use the short branch name: when baseBranch is a remote ref
213
+ // (e.g. "origin/feature/x"), the local branch to attach/create is
214
+ // "feature/x", not a branch literally named "origin/feature/x".
215
+ setBranch(baseBranchLocalName || baseBranch);
160
216
  setStep('copy-settings');
161
217
  }
162
218
  else {
@@ -264,7 +320,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
264
320
  const promptMethod = selectedPreset
265
321
  ? getPromptInjectionMethod(selectedPreset)
266
322
  : 'stdin';
267
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
323
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'remote-branch-confirm' && ambiguousBase && (_jsx(RemoteBranchSelector, { branchName: ambiguousBase.branchName, matches: ambiguousBase.matches, onSelect: handleAmbiguousBaseSelect, onCancel: handleAmbiguousBaseCancel })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
268
324
  {
269
325
  label: '1. Choose the branch name yourself',
270
326
  value: 'manual',
@@ -291,6 +347,6 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
291
347
  ], onSelect: handleCopySettingsSelect, initialIndex: 0 })] })), step === 'copy-session' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Copy Claude Code session data to the new worktree?" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "This will copy conversation history and context from the current worktree." }) }), isAutoDirectory && generatedPath ? (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Worktree path preview:", ' ', _jsx(Text, { color: "green", children: generatedPath })] }) })) : null, _jsx(SelectInput, { items: [
292
348
  { label: '✅ Yes, copy session data', value: 'yes' },
293
349
  { label: '❌ No, start fresh', value: 'no' },
294
- ], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) })] }));
350
+ ], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), step !== 'remote-branch-confirm' && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) }))] }));
295
351
  };
296
352
  export default NewWorktree;
@@ -289,6 +289,11 @@ describe('NewWorktree component Effect integration', () => {
289
289
  return {
290
290
  getAllBranchesEffect: vi.fn(() => Effect.succeed(mockBranches)),
291
291
  getDefaultBranchEffect: vi.fn(() => Effect.succeed(mockDefaultBranch)),
292
+ resolveBaseBranch: vi.fn((name) => ({
293
+ kind: 'local',
294
+ ref: name,
295
+ localName: name,
296
+ })),
292
297
  };
293
298
  });
294
299
  const onComplete = vi.fn();
@@ -305,6 +310,38 @@ describe('NewWorktree component Effect integration', () => {
305
310
  expect(output).toContain('Choose the branch name yourself');
306
311
  expect(output).toContain('Enter a prompt first');
307
312
  });
313
+ it('should keep the base branch selection when the default branch is ambiguous across remotes', async () => {
314
+ const { Effect } = await import('effect');
315
+ const { WorktreeService } = await import('../services/worktreeService.js');
316
+ const { configReader } = await import('../services/config/configReader.js');
317
+ vi.spyOn(configReader, 'getWorktreeConfig').mockReturnValue({
318
+ autoDirectory: true,
319
+ autoDirectoryPattern: '../{project}-{branch}',
320
+ copySessionData: true,
321
+ autoUseDefaultBranch: true,
322
+ });
323
+ // Default branch has no local ref and exists on two remotes: it cannot
324
+ // be picked silently, so the base-branch step must not be skipped.
325
+ vi.mocked(WorktreeService).mockImplementation(function () {
326
+ return {
327
+ getAllBranchesEffect: vi.fn(() => Effect.succeed(['main', 'develop'])),
328
+ getDefaultBranchEffect: vi.fn(() => Effect.succeed('main')),
329
+ resolveBaseBranch: vi.fn(() => ({
330
+ kind: 'ambiguous',
331
+ branchName: 'main',
332
+ matches: [
333
+ { remote: 'origin', branch: 'main', fullRef: 'origin/main' },
334
+ { remote: 'upstream', branch: 'main', fullRef: 'upstream/main' },
335
+ ],
336
+ })),
337
+ };
338
+ });
339
+ const { lastFrame } = render(_jsx(NewWorktree, { onComplete: vi.fn(), onCancel: vi.fn() }));
340
+ await new Promise(resolve => setTimeout(resolve, 100));
341
+ const output = lastFrame();
342
+ expect(output).toContain('Select base branch');
343
+ expect(output).not.toContain('How do you want to create the new worktree?');
344
+ });
308
345
  it('should show base branch selection when autoUseDefaultBranch is disabled', async () => {
309
346
  const { Effect } = await import('effect');
310
347
  const { WorktreeService } = await import('../services/worktreeService.js');
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export type SessionActionType = 'newSession' | 'rename' | 'kill';
3
3
  interface SessionActionsProps {
4
4
  sessionLabel: string;
5
+ worktreePath: string;
5
6
  onSelect: (action: SessionActionType) => void;
6
7
  onCancel: () => void;
7
8
  }
@@ -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" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: sessionLabel }) }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "S/R/X or arrow keys + Enter | Escape to cancel" }) })] }));
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
- const resolvedRef = yield* self.resolveBranchReferenceEffect(branch);
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 return Effect Left with AmbiguousBranchError when branch exists in multiple remotes', async () => {
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
- return ''; // Both remotes have the branch
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', 'main');
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') {
@@ -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.0",
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.0",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.3.0",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.3.0",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.3.0",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.3.0"
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",