From 32adb7692de9fdb08f35d741e6e78227592055d9 Mon Sep 17 00:00:00 2001 From: FourWindff Date: Mon, 25 May 2026 11:46:55 +0800 Subject: [PATCH] fix(worktree): prevent branch prefix conflicts Detect when a new worktree branch would conflict with an existing local branch (e.g. creating "feature/login" when "feature" already exists). - Backend: add findLocalBranchPrefixConflict in git.ts to check all prefix segments before git worktree add -b - Frontend: add findBranchPrefixConflict in branch-name.ts with createMemo caching; disable submit and show inline error when prefix conflicts - BranchPrefixField: accept error prop, show red border + error text Co-Authored-By: Claude Opus 4.7 (1M context) --- electron/ipc/git.ts | 20 ++++++++++++++ src/components/BranchPrefixField.tsx | 6 +++- src/components/NewTaskDialog.tsx | 41 ++++++++++++++++++++++++---- src/lib/branch-name.ts | 14 ++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index e4237c89..fb6e4b04 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -218,6 +218,18 @@ async function localBranchExists(repoRoot: string, branch: string): Promise { + const parts = branchName.split('/'); + for (let i = 1; i < parts.length; i++) { + const prefix = parts.slice(0, i).join('/'); + if (await localBranchExists(repoRoot, prefix)) return prefix; + } + return null; +} + async function detectMainBranchUncached(repoRoot: string): Promise { // Try remote HEAD reference first const branch = await resolveOriginHead(repoRoot); @@ -722,6 +734,14 @@ export async function createWorktree( } // Create fresh worktree with new branch + const conflictingBranch = await findLocalBranchPrefixConflict(repoRoot, branchName); + if (conflictingBranch) { + throw new Error( + `Cannot create branch "${branchName}" because local branch "${conflictingBranch}" already exists. ` + + `Choose a branch prefix other than "${conflictingBranch}" or "${conflictingBranch}/...".`, + ); + } + const worktreeArgs = ['worktree', 'add', '-b', branchName, worktreePath]; if (baseBranch) worktreeArgs.push(baseBranch); await exec('git', worktreeArgs, { cwd: repoRoot }); diff --git a/src/components/BranchPrefixField.tsx b/src/components/BranchPrefixField.tsx index db40c96c..4918d77d 100644 --- a/src/components/BranchPrefixField.tsx +++ b/src/components/BranchPrefixField.tsx @@ -4,6 +4,7 @@ import { theme } from '../lib/theme'; interface BranchPrefixFieldProps { branchPrefix: string; branchPreview: string; + error?: string; projectPath: string | undefined; onPrefixChange: (prefix: string) => void; } @@ -26,7 +27,7 @@ export function BranchPrefixField(props: BranchPrefixFieldProps) { placeholder="task" style={{ background: theme.bgInput, - border: `1px solid ${theme.border}`, + border: `1px solid ${props.error ? theme.error : theme.border}`, 'border-radius': '6px', padding: '4px 8px', color: theme.fg, @@ -37,6 +38,9 @@ export function BranchPrefixField(props: BranchPrefixFieldProps) { }} /> + +
{props.error}
+
{ + if (gitIsolation() !== 'worktree') return null; + return findBranchPrefixConflict(branchPrefix(), branches()); + }); + + const branchPrefixError = createMemo(() => { + const c = branchPrefixConflict(); + return c ? branchPrefixConflictError(c) : ''; + }); + const selectedProjectPath = () => { const pid = selectedProjectId(); return pid ? getProjectPath(pid) : undefined; @@ -492,7 +507,14 @@ export function NewTaskDialog(props: NewTaskDialogProps) { // for git projects — so a task can't be created with a stale or empty // base branch (e.g. after a failed branch fetch). const branchOk = isNonGitProject() || (!!baseBranch() && !branchesError()); - return hasContent && !!selectedProjectId() && !loading() && !branchesLoading() && branchOk; + return ( + hasContent && + !!selectedProjectId() && + !loading() && + !branchesLoading() && + branchOk && + !branchPrefixConflict() + ); }; async function handleSubmit(e: Event) { @@ -517,13 +539,19 @@ export function NewTaskDialog(props: NewTaskDialogProps) { return; } - setLoading(true); - setError(''); - const p = prompt().trim() || undefined; const isFromDrop = !!store.newTaskDropUrl; const prefix = sanitizeBranchPrefix(branchPrefix()); + const prefixConflict = branchPrefixConflict(); + if (prefixConflict) { + setError(branchPrefixConflictError(prefixConflict)); + return; + } const ghUrl = (p ? extractGitHubUrl(p) : null) ?? store.newTaskDropUrl ?? undefined; + + setLoading(true); + setError(''); + try { // Persist the branch prefix to the project for next time updateProject(projectId, { branchPrefix: prefix }); @@ -757,6 +785,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { diff --git a/src/lib/branch-name.ts b/src/lib/branch-name.ts index 88347c19..652a51f7 100644 --- a/src/lib/branch-name.ts +++ b/src/lib/branch-name.ts @@ -14,3 +14,17 @@ export function sanitizeBranchPrefix(prefix: string): string { .filter((segment) => segment.length > 0); return parts.join('/') || 'task'; } + +export function findBranchPrefixConflict( + branchPrefix: string, + existingBranches: string[], +): string | null { + const prefix = sanitizeBranchPrefix(branchPrefix); + return ( + existingBranches.find((branch) => branch === prefix || prefix.startsWith(`${branch}/`)) ?? null + ); +} + +export function branchPrefixConflictError(conflict: string): string { + return `Branch prefix conflicts with existing branch "${conflict}". Choose a prefix other than "${conflict}" or "${conflict}/...".`; +}