Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/components/NewWorktree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,11 @@ describe('NewWorktree component Effect integration', () => {
return {
getAllBranchesEffect: vi.fn(() => Effect.succeed(mockBranches)),
getDefaultBranchEffect: vi.fn(() => Effect.succeed(mockDefaultBranch)),
resolveBaseBranch: vi.fn((name: string) => ({
kind: 'local',
ref: name,
localName: name,
})),
} as unknown as InstanceType<typeof WorktreeService>;
});

Expand All @@ -402,6 +407,46 @@ describe('NewWorktree component Effect integration', () => {
expect(output).toContain('Enter a prompt first');
});

it('should keep the base branch selection when the default branch is ambiguous across remotes', async () => {
const {Effect} = await import('effect');
const {WorktreeService} = await import('../services/worktreeService.js');
const {configReader} = await import('../services/config/configReader.js');

vi.spyOn(configReader, 'getWorktreeConfig').mockReturnValue({
autoDirectory: true,
autoDirectoryPattern: '../{project}-{branch}',
copySessionData: true,
autoUseDefaultBranch: true,
});

// Default branch has no local ref and exists on two remotes: it cannot
// be picked silently, so the base-branch step must not be skipped.
vi.mocked(WorktreeService).mockImplementation(function () {
return {
getAllBranchesEffect: vi.fn(() => Effect.succeed(['main', 'develop'])),
getDefaultBranchEffect: vi.fn(() => Effect.succeed('main')),
resolveBaseBranch: vi.fn(() => ({
kind: 'ambiguous',
branchName: 'main',
matches: [
{remote: 'origin', branch: 'main', fullRef: 'origin/main'},
{remote: 'upstream', branch: 'main', fullRef: 'upstream/main'},
],
})),
} as unknown as InstanceType<typeof WorktreeService>;
});

const {lastFrame} = render(
<NewWorktree onComplete={vi.fn()} onCancel={vi.fn()} />,
);

await new Promise(resolve => setTimeout(resolve, 100));

const output = lastFrame();
expect(output).toContain('Select base branch');
expect(output).not.toContain('How do you want to create the new worktree?');
});

it('should show base branch selection when autoUseDefaultBranch is disabled', async () => {
const {Effect} = await import('effect');
const {WorktreeService} = await import('../services/worktreeService.js');
Expand Down
106 changes: 93 additions & 13 deletions src/components/NewWorktree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {WorktreeService} from '../services/worktreeService.js';
import {useSearchMode} from '../hooks/useSearchMode.js';
import {useDynamicLimit} from '../hooks/useDynamicLimit.js';
import SearchableList from './SearchableList.js';
import RemoteBranchSelector from './RemoteBranchSelector.js';
import {Effect} from 'effect';
import type {AppError} from '../types/errors.js';
import type {RemoteBranchMatch} from '../types/index.js';
import {
describePromptInjection,
getPromptInjectionMethod,
Expand Down Expand Up @@ -47,6 +49,7 @@ export type NewWorktreeRequest =
type Step =
| 'path'
| 'base-branch'
| 'remote-branch-confirm'
| 'creation-mode'
| 'branch-strategy'
| 'branch'
Expand Down Expand Up @@ -83,6 +86,13 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
const [path, setPath] = useState('');
const [branch, setBranch] = useState('');
const [baseBranch, setBaseBranch] = useState('');
// Short branch name to use when creating a local branch from baseBranch
// (differs from baseBranch when baseBranch is a remote ref like "origin/x")
const [baseBranchLocalName, setBaseBranchLocalName] = useState('');
const [ambiguousBase, setAmbiguousBase] = useState<{
branchName: string;
matches: RemoteBranchMatch[];
} | null>(null);
const [copyClaudeDirectory, setCopyClaudeDirectory] = useState(true);
const [copySessionData, setCopySessionData] = useState(
worktreeConfig.copySessionData ?? true,
Expand All @@ -98,9 +108,14 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
const [defaultBranch, setDefaultBranch] = useState<string>('main');

const worktreeService = useMemo(
() => new WorktreeService(projectPath),
[projectPath],
);

useEffect(() => {
let cancelled = false;
const service = new WorktreeService(projectPath);
const service = worktreeService;

const loadBranches = async () => {
const branchesEffect = includeRemoteBranches
Expand Down Expand Up @@ -143,8 +158,16 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
setDefaultBranch(result.defaultBranch);
setIsLoadingBranches(false);

if (isAutoUseDefaultBranch && result.defaultBranch) {
setBaseBranch(result.defaultBranch);
// When the default branch is ambiguous across remotes we can't
// pick one silently, so keep the base-branch step and let the
// user choose.
const resolution =
isAutoUseDefaultBranch && result.defaultBranch
? service.resolveBaseBranch(result.defaultBranch)
: null;
if (resolution && resolution.kind !== 'ambiguous') {
setBaseBranch(resolution.ref);
setBaseBranchLocalName(resolution.localName);
setStep(currentStep =>
currentStep === 'base-branch' ? 'creation-mode' : currentStep,
);
Expand All @@ -163,7 +186,7 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
return () => {
cancelled = true;
};
}, [projectPath, isAutoUseDefaultBranch, includeRemoteBranches]);
}, [worktreeService, isAutoUseDefaultBranch, includeRemoteBranches]);

const allBranchItems: BranchItem[] = useMemo(() => {
const defaultRemoteSuffix = `/${defaultBranch}`;
Expand Down Expand Up @@ -226,6 +249,12 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
);

useInput((input, key) => {
if (step === 'remote-branch-confirm') {
// RemoteBranchSelector handles its own cancel shortcut (returns to
// the base-branch list); don't also cancel the whole wizard here.
return;
}

if (shortcutManager.matchesShortcut('cancel', input, key)) {
onCancel();
}
Expand All @@ -235,23 +264,60 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
}
});

/**
* Resolves the selected base branch right away. Returns true when the
* selection is settled; returns false when the branch exists on multiple
* remotes, in which case the remote-branch-confirm step is shown so the
* user can disambiguate immediately (instead of failing later when the
* worktree is actually created).
*/
const applyBaseBranchSelection = (name: string): boolean => {
const resolution = worktreeService.resolveBaseBranch(name);
if (resolution.kind === 'ambiguous') {
setAmbiguousBase({
branchName: resolution.branchName,
matches: resolution.matches,
});
setStep('remote-branch-confirm');
return false;
}
setBaseBranch(resolution.ref);
setBaseBranchLocalName(resolution.localName);
return true;
};

const handlePathSubmit = (value: string) => {
if (!value.trim()) return;

setPath(value.trim());
if (isAutoUseDefaultBranch && defaultBranch) {
setBaseBranch(defaultBranch);
setStep('creation-mode');
if (applyBaseBranchSelection(defaultBranch)) {
setStep('creation-mode');
}
} else {
setStep('base-branch');
}
};

const handleBaseBranchSelect = (item: {label: string; value: string}) => {
setBaseBranch(item.value);
if (applyBaseBranchSelection(item.value)) {
setStep('creation-mode');
}
};

const handleAmbiguousBaseSelect = (selectedRemoteRef: string) => {
if (!ambiguousBase) return;
setBaseBranch(selectedRemoteRef);
setBaseBranchLocalName(ambiguousBase.branchName);
setAmbiguousBase(null);
setStep('creation-mode');
};

const handleAmbiguousBaseCancel = () => {
setAmbiguousBase(null);
setStep('base-branch');
};

const handleCreationModeSelect = (item: {label: string; value: string}) => {
if (item.value === 'manual') {
setStep('branch-strategy');
Expand All @@ -264,7 +330,10 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
const handleBranchStrategySelect = (item: {label: string; value: string}) => {
const useExisting = item.value === 'existing';
if (useExisting) {
setBranch(baseBranch);
// Use the short branch name: when baseBranch is a remote ref
// (e.g. "origin/feature/x"), the local branch to attach/create is
// "feature/x", not a branch literally named "origin/feature/x".
setBranch(baseBranchLocalName || baseBranch);
setStep('copy-settings');
} else {
setStep('branch');
Expand Down Expand Up @@ -507,6 +576,15 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
</Box>
)}

{step === 'remote-branch-confirm' && ambiguousBase && (
<RemoteBranchSelector
branchName={ambiguousBase.branchName}
matches={ambiguousBase.matches}
onSelect={handleAmbiguousBaseSelect}
onCancel={handleAmbiguousBaseCancel}
/>
)}

{step === 'creation-mode' && (
<Box flexDirection="column">
<Box marginBottom={1}>
Expand Down Expand Up @@ -709,11 +787,13 @@ const NewWorktree: React.FC<NewWorktreeProps> = ({
</Box>
)}

<Box marginTop={1}>
<Text dimColor>
Press {shortcutManager.getShortcutDisplay('cancel')} to cancel
</Text>
</Box>
{step !== 'remote-branch-confirm' && (
<Box marginTop={1}>
<Text dimColor>
Press {shortcutManager.getShortcutDisplay('cancel')} to cancel
</Text>
</Box>
)}
</Box>
);
};
Expand Down
Loading
Loading