feat(worktree): add start point option for new branch creation

Allow selecting a base branch when creating new worktree branches
Add dropdown to choose start point from existing branches
Extend worktree creation flow to support custom branch origins
This commit is contained in:
Bohdan Triapitsyn
2026-01-01 13:39:55 +02:00
parent 39b9f985ce
commit 5a71e050a5
8 changed files with 326 additions and 48 deletions
@@ -1366,6 +1366,7 @@ pub async fn add_git_worktree(
path_str: String,
branch: String,
create_branch: Option<bool>,
start_point: Option<String>,
state: State<'_, DesktopRuntime>,
) -> Result<(), String> {
let root = validate_git_path(&directory, state.settings())
@@ -1381,6 +1382,11 @@ pub async fn add_git_worktree(
if !create_branch.unwrap_or(false) {
args.push(&branch);
} else if let Some(start_point) = start_point.as_deref() {
let start_point = start_point.trim();
if !start_point.is_empty() {
args.push(start_point);
}
}
run_git(&args, &root).await.map_err(|e| e.to_string())?;
+2 -1
View File
@@ -116,7 +116,8 @@ export const createDesktopGitAPI = (): GitAPI => ({
directory,
pathStr: payload.path,
branch: payload.branch,
createBranch: payload.createBranch
createBranch: payload.createBranch,
startPoint: payload.startPoint,
});
return { success: true, path: payload.path, branch: payload.branch };
},
@@ -1,7 +1,18 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
import {
Dialog,
DialogContent,
@@ -23,7 +34,7 @@ import {
mapWorktreeToMetadata,
removeWorktree,
} from '@/lib/git/worktreeService';
import { checkIsGitRepository, ensureOpenChamberIgnored } from '@/lib/gitApi';
import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -78,6 +89,14 @@ const joinWorktreePath = (projectDirectory: string, slug: string): string => {
return cleanSlug ? `${base}/${cleanSlug}` : base;
};
type WorktreeBaseOption = {
value: string;
label: string;
group: 'special' | 'local' | 'remote';
};
type WorktreeCreateMode = 'new' | 'existing';
type DeleteDialogState = {
sessions: Session[];
dateLabel?: string;
@@ -88,7 +107,14 @@ type DeleteDialogState = {
export const SessionDialogs: React.FC = () => {
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
const [worktreeCreateMode, setWorktreeCreateMode] = React.useState<WorktreeCreateMode>('new');
const [branchName, setBranchName] = React.useState<string>('');
const [existingWorktreeBranch, setExistingWorktreeBranch] = React.useState<string>('');
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
const [availableWorktreeBaseBranches, setAvailableWorktreeBaseBranches] = React.useState<WorktreeBaseOption[]>([
{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' },
]);
const [isLoadingWorktreeBaseBranches, setIsLoadingWorktreeBaseBranches] = React.useState(false);
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
const [worktreeError, setWorktreeError] = React.useState<string | null>(null);
@@ -120,8 +146,12 @@ export const SessionDialogs: React.FC = () => {
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
const projectDirectory = React.useMemo(() => normalizeProjectDirectory(currentDirectory), [currentDirectory]);
const sanitizedBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(sanitizedBranchName), [sanitizedBranchName]);
const sanitizedNewBranchName = React.useMemo(() => sanitizeBranchNameInput(branchName), [branchName]);
const worktreeTargetBranch = React.useMemo(
() => (worktreeCreateMode === 'existing' ? existingWorktreeBranch.trim() : sanitizedNewBranchName),
[existingWorktreeBranch, sanitizedNewBranchName, worktreeCreateMode],
);
const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(worktreeTargetBranch), [worktreeTargetBranch]);
const worktreePreviewPath = React.useMemo(() => {
if (!projectDirectory || !sanitizedWorktreeSlug) {
return '';
@@ -129,6 +159,16 @@ export const SessionDialogs: React.FC = () => {
return joinWorktreePath(projectDirectory, sanitizedWorktreeSlug);
}, [projectDirectory, sanitizedWorktreeSlug]);
const isGitRepo = isGitRepository === true;
const selectedWorktreeBaseLabel = React.useMemo(() => {
const match = availableWorktreeBaseBranches.find((option) => option.value === worktreeBaseBranch);
if (match) {
return match.label;
}
if (worktreeBaseBranch === 'HEAD') {
return 'Current (HEAD)';
}
return worktreeBaseBranch;
}, [availableWorktreeBaseBranches, worktreeBaseBranch]);
const hasDirtyWorktrees = React.useMemo(
() =>
(deleteDialog?.worktree?.status?.isDirty ?? false) ||
@@ -181,7 +221,12 @@ export const SessionDialogs: React.FC = () => {
React.useEffect(() => {
if (!isSessionCreateDialogOpen) {
setWorktreeCreateMode('new');
setBranchName('');
setExistingWorktreeBranch('');
setWorktreeBaseBranch('HEAD');
setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
setIsLoadingWorktreeBaseBranches(false);
setAvailableWorktrees([]);
setWorktreeError(null);
setIsLoadingWorktrees(false);
@@ -192,6 +237,10 @@ export const SessionDialogs: React.FC = () => {
}
if (!projectDirectory) {
setWorktreeCreateMode('new');
setExistingWorktreeBranch('');
setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
setWorktreeBaseBranch('HEAD');
setAvailableWorktrees([]);
setIsGitRepository(null);
setIsCheckingGitRepository(false);
@@ -200,6 +249,7 @@ export const SessionDialogs: React.FC = () => {
let cancelled = false;
setIsLoadingWorktrees(true);
setIsLoadingWorktreeBaseBranches(true);
setIsCheckingGitRepository(true);
setWorktreeError(null);
@@ -212,13 +262,60 @@ export const SessionDialogs: React.FC = () => {
setIsGitRepository(repoStatus);
if (!repoStatus) {
setWorktreeCreateMode('new');
setExistingWorktreeBranch('');
setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
setWorktreeBaseBranch('HEAD');
setAvailableWorktrees([]);
setWorktreeError(null);
} else {
const worktrees = await listGitWorktrees(projectDirectory);
const [worktrees, branches] = await Promise.all([
listGitWorktrees(projectDirectory),
getGitBranches(projectDirectory).catch(() => null),
]);
if (cancelled) {
return;
}
const worktreeBaseOptions: WorktreeBaseOption[] = [];
const headLabel = branches?.current
? `Current (HEAD: ${branches.current})`
: 'Current (HEAD)';
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
if (branches) {
const localBranches = branches.all
.filter((name) => !name.startsWith('remotes/'))
.sort((a, b) => a.localeCompare(b));
const defaultExistingBranch = branches.current && !branches.current.startsWith('remotes/')
? branches.current
: (localBranches[0] ?? '');
setExistingWorktreeBranch((previous) => {
const trimmed = previous.trim();
if (trimmed && localBranches.includes(trimmed)) {
return trimmed;
}
return defaultExistingBranch;
});
localBranches.forEach((name) => {
worktreeBaseOptions.push({ value: name, label: name, group: 'local' });
});
const remoteBranches = branches.all
.filter((name) => name.startsWith('remotes/'))
.map((name) => name.replace(/^remotes\//, ''))
.sort((a, b) => a.localeCompare(b));
remoteBranches.forEach((name) => {
worktreeBaseOptions.push({ value: name, label: name, group: 'remote' });
});
}
setAvailableWorktreeBaseBranches(worktreeBaseOptions);
setWorktreeBaseBranch((previous) =>
worktreeBaseOptions.some((option) => option.value === previous) ? previous : 'HEAD'
);
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectDirectory, info));
const worktreeRoot = joinWorktreePath(projectDirectory, '');
const worktreePrefix = `${worktreeRoot}/`;
@@ -234,6 +331,7 @@ export const SessionDialogs: React.FC = () => {
} finally {
if (!cancelled) {
setIsLoadingWorktrees(false);
setIsLoadingWorktreeBaseBranches(false);
setIsCheckingGitRepository(false);
}
}
@@ -274,7 +372,11 @@ export const SessionDialogs: React.FC = () => {
React.useEffect(() => {
return sessionEvents.onCreateRequest(() => {
setWorktreeCreateMode('new');
setBranchName('');
setExistingWorktreeBranch('');
setWorktreeBaseBranch('HEAD');
setWorktreeError(null);
setSessionCreateDialogOpen(true);
});
}, [setSessionCreateDialogOpen]);
@@ -378,10 +480,13 @@ export const SessionDialogs: React.FC = () => {
return false;
}
const normalizedBranch = sanitizedBranchName;
const normalizedBranch = worktreeTargetBranch;
const slugValue = sanitizedWorktreeSlug;
if (!normalizedBranch) {
const message = 'Provide a branch name for the new worktree.';
const message =
worktreeCreateMode === 'existing'
? 'Select an existing branch for the new worktree.'
: 'Provide a branch name for the new worktree.';
setWorktreeError(message);
toast.error(message);
return false;
@@ -402,7 +507,7 @@ export const SessionDialogs: React.FC = () => {
setWorktreeError(null);
return true;
}, [projectDirectory, sanitizedBranchName, sanitizedWorktreeSlug, availableWorktrees]);
}, [projectDirectory, worktreeCreateMode, worktreeTargetBranch, sanitizedWorktreeSlug, availableWorktrees]);
const handleCreateWorktree = async () => {
if (isCreatingWorktree || isLoading) {
@@ -419,13 +524,18 @@ export const SessionDialogs: React.FC = () => {
let cleanupMetadata: WorktreeMetadata | null = null;
try {
const normalizedBranch = sanitizedBranchName;
const normalizedBranch = worktreeTargetBranch;
const slugValue = sanitizedWorktreeSlug;
const shouldCreateBranch = worktreeCreateMode === 'new';
const startPoint = shouldCreateBranch && worktreeBaseBranch && worktreeBaseBranch !== 'HEAD'
? worktreeBaseBranch
: undefined;
const metadata = await createWorktree({
projectDirectory,
worktreeSlug: slugValue,
branch: normalizedBranch,
createBranch: true,
createBranch: shouldCreateBranch,
startPoint,
});
cleanupMetadata = metadata;
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
@@ -446,6 +556,7 @@ export const SessionDialogs: React.FC = () => {
await refreshWorktrees();
setBranchName('');
setExistingWorktreeBranch('');
toast.success('Worktree created');
} catch (error) {
if (cleanupMetadata) {
@@ -556,41 +667,195 @@ export const SessionDialogs: React.FC = () => {
</div>
<div className="space-y-2">
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-input">
Branch name
</label>
<div className="flex gap-2">
<Input
id="worktree-branch-input"
value={branchName}
onChange={(e) => handleBranchInputChange(e.target.value)}
placeholder="feature/new-branch"
className="h-8 flex-1 typography-meta text-foreground placeholder:text-muted-foreground/70"
disabled={!isGitRepo || isCheckingGitRepository}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isCreatingWorktree) {
handleCreateWorktree();
<AnimatedTabs
tabs={[
{ value: 'new', label: 'New branch' },
{ value: 'existing', label: 'Existing branch' },
]}
value={worktreeCreateMode}
onValueChange={(value) => {
setWorktreeCreateMode(value);
setWorktreeError(null);
if (value === 'existing' && !existingWorktreeBranch) {
const firstLocal = availableWorktreeBaseBranches.find((option) => option.group === 'local')?.value ?? '';
if (firstLocal) {
setExistingWorktreeBranch(firstLocal);
}
}}
/>
<Button
onClick={handleCreateWorktree}
disabled={isCreatingWorktree || isLoading || !isGitRepo || !sanitizedBranchName}
className="h-8"
>
{isCreatingWorktree ? 'Creating…' : 'Create'}
</Button>
</div>
{sanitizedBranchName && (
<p className="typography-micro text-muted-foreground/70">
Creates branch{' '}
<code className="font-mono text-xs text-muted-foreground">{sanitizedBranchName}</code>
{' '}at{' '}
<code className="font-mono text-xs text-muted-foreground break-all">
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
</code>
</p>
}
}}
animate={false}
/>
{worktreeCreateMode === 'new' ? (
<>
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-base-branch-select">
Base branch
</label>
<Select
value={worktreeBaseBranch}
onValueChange={setWorktreeBaseBranch}
disabled={!isGitRepo || isCheckingGitRepository || isLoadingWorktreeBaseBranches}
>
<SelectTrigger
id="worktree-base-branch-select"
size="lg"
className="w-full typography-meta text-foreground"
>
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Default</SelectLabel>
{availableWorktreeBaseBranches
.filter((option) => option.group === 'special')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
{availableWorktreeBaseBranches.some((option) => option.group === 'local') && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Local branches</SelectLabel>
{availableWorktreeBaseBranches
.filter((option) => option.group === 'local')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
)}
{availableWorktreeBaseBranches.some((option) => option.group === 'remote') && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Remote branches</SelectLabel>
{availableWorktreeBaseBranches
.filter((option) => option.group === 'remote')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
)}
</SelectContent>
</Select>
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-input">
New branch name
</label>
<div className="flex gap-2">
<Input
id="worktree-branch-input"
value={branchName}
onChange={(e) => handleBranchInputChange(e.target.value)}
placeholder="feature/new-branch"
className="h-8 flex-1 typography-meta text-foreground placeholder:text-muted-foreground/70"
disabled={!isGitRepo || isCheckingGitRepository}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isCreatingWorktree) {
handleCreateWorktree();
}
}}
/>
<Button
onClick={handleCreateWorktree}
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
className="h-8"
>
{isCreatingWorktree ? 'Creating…' : 'Create'}
</Button>
</div>
</>
) : (
<>
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-existing-branch-select">
Existing branch
</label>
<div className="flex gap-2">
<Select
value={existingWorktreeBranch}
onValueChange={(value) => {
setExistingWorktreeBranch(value);
setWorktreeError(null);
}}
disabled={
!isGitRepo
|| isCheckingGitRepository
|| isLoadingWorktreeBaseBranches
|| !availableWorktreeBaseBranches.some((option) => option.group === 'local')
}
>
<SelectTrigger
id="worktree-existing-branch-select"
size="lg"
className="flex-1 typography-meta text-foreground"
>
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Local branches</SelectLabel>
{availableWorktreeBaseBranches
.filter((option) => option.group === 'local')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
onClick={handleCreateWorktree}
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
className="h-8"
>
{isCreatingWorktree ? 'Creating…' : 'Create'}
</Button>
</div>
{!isLoadingWorktreeBaseBranches && !availableWorktreeBaseBranches.some((option) => option.group === 'local') ? (
<p className="typography-micro text-muted-foreground/70">
No local branches found. Fetch or create a branch first.
</p>
) : null}
</>
)}
{worktreeTargetBranch ? (
<p className="typography-micro text-muted-foreground/70">
{worktreeCreateMode === 'existing' ? (
<>
Uses branch{' '}
<code className="font-mono text-xs text-muted-foreground">{worktreeTargetBranch}</code>
{' '}at{' '}
<code className="font-mono text-xs text-muted-foreground break-all">
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
</code>
</>
) : (
<>
Creates branch{' '}
<code className="font-mono text-xs text-muted-foreground">{worktreeTargetBranch}</code>
{' '}from{' '}
<code className="font-mono text-xs text-muted-foreground">{selectedWorktreeBaseLabel}</code>
{' '}at{' '}
<code className="font-mono text-xs text-muted-foreground break-all">
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
</code>
</>
)}
</p>
) : null}
</div>
{worktreeError && <p className="typography-meta text-destructive">{worktreeError}</p>}
+2 -2
View File
@@ -31,14 +31,14 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: "sm" | "default" | "lg"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none focus-visible:outline-none hover:bg-muted data-[state=open]:bg-muted focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-6 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none focus-visible:outline-none hover:bg-muted data-[state=open]:bg-muted focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-6 data-[size=sm]:h-6 data-[size=lg]:h-8 data-[size=lg]:py-1.5 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
+1
View File
@@ -222,6 +222,7 @@ export interface GitAddWorktreePayload {
path: string;
branch: string;
createBranch?: boolean;
startPoint?: string;
}
export interface GitRemoveWorktreePayload {
+3 -1
View File
@@ -59,6 +59,7 @@ export interface CreateWorktreeOptions {
worktreeSlug: string;
branch: string;
createBranch?: boolean;
startPoint?: string;
}
export interface RemoveWorktreeOptions {
@@ -84,7 +85,7 @@ export async function resolveWorktreePath(projectDirectory: string, worktreeSlug
}
export async function createWorktree(options: CreateWorktreeOptions): Promise<WorktreeMetadata> {
const { projectDirectory, worktreeSlug, branch, createBranch } = options;
const { projectDirectory, worktreeSlug, branch, createBranch, startPoint } = options;
const normalizedProject = normalize(projectDirectory);
const worktreePath = await resolveWorktreePath(normalizedProject, worktreeSlug);
@@ -92,6 +93,7 @@ export async function createWorktree(options: CreateWorktreeOptions): Promise<Wo
path: worktreePath,
branch,
createBranch: Boolean(createBranch),
startPoint: startPoint?.trim() || undefined,
};
await addGitWorktree(normalizedProject, payload);
+2 -2
View File
@@ -3400,12 +3400,12 @@ async function main(options = {}) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path, branch, createBranch } = req.body;
const { path, branch, createBranch, startPoint } = req.body;
if (!path || !branch) {
return res.status(400).json({ error: 'path and branch are required' });
}
const result = await addWorktree(directory, path, branch, { createBranch });
const result = await addWorktree(directory, path, branch, { createBranch, startPoint });
res.json(result);
} catch (error) {
console.error('Failed to add worktree:', error);
+3
View File
@@ -830,6 +830,7 @@ export async function addWorktree(directory, worktreePath, branch, options = {})
try {
const args = ['worktree', 'add'];
const startPoint = typeof options.startPoint === 'string' ? options.startPoint.trim() : '';
if (options.createBranch) {
args.push('-b', branch);
@@ -839,6 +840,8 @@ export async function addWorktree(directory, worktreePath, branch, options = {})
if (!options.createBranch) {
args.push(branch);
} else if (startPoint) {
args.push(startPoint);
}
await git.raw(args);