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
20 changes: 20 additions & 0 deletions electron/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ async function localBranchExists(repoRoot: string, branch: string): Promise<bool
}
}

async function findLocalBranchPrefixConflict(
repoRoot: string,
branchName: string,
): Promise<string | null> {
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<string> {
// Try remote HEAD reference first
const branch = await resolveOriginHead(repoRoot);
Expand Down Expand Up @@ -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 });
Expand Down
6 changes: 5 additions & 1 deletion src/components/BranchPrefixField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { theme } from '../lib/theme';
interface BranchPrefixFieldProps {
branchPrefix: string;
branchPreview: string;
error?: string;
projectPath: string | undefined;
onPrefixChange: (prefix: string) => void;
}
Expand All @@ -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,
Expand All @@ -37,6 +38,9 @@ export function BranchPrefixField(props: BranchPrefixFieldProps) {
}}
/>
</div>
<Show when={props.error}>
<div style={{ 'font-size': '12px', color: theme.error }}>{props.error}</div>
</Show>
<Show when={props.branchPreview && props.projectPath}>
<div
style={{
Expand Down
41 changes: 35 additions & 6 deletions src/components/NewTaskDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createSignal, createEffect, createUniqueId, Show, onCleanup } from 'solid-js';
import { createSignal, createEffect, createMemo, createUniqueId, Show, onCleanup } from 'solid-js';
import { Dialog } from './Dialog';
import { invoke } from '../lib/ipc';
import { IPC } from '../../electron/ipc/channels';
Expand All @@ -19,7 +19,12 @@ import {
setDockerImage,
} from '../store/store';
import type { GitIsolationMode } from '../store/types';
import { toBranchName, sanitizeBranchPrefix } from '../lib/branch-name';
import {
toBranchName,
sanitizeBranchPrefix,
findBranchPrefixConflict,
branchPrefixConflictError,
} from '../lib/branch-name';
import { SegmentedButtons } from './SegmentedButtons';
import { autoTaskNameFromPrompt } from '../lib/clean-task-name';
import { extractGitHubUrl } from '../lib/github-url';
Expand Down Expand Up @@ -466,6 +471,16 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
return n ? `${prefix}/${toBranchName(n)}` : '';
};

const branchPrefixConflict = createMemo(() => {
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;
Expand All @@ -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) {
Expand All @@ -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 });
Expand Down Expand Up @@ -757,6 +785,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
<BranchPrefixField
branchPrefix={branchPrefix()}
branchPreview={branchPreview()}
error={branchPrefixError()}
projectPath={selectedProjectPath()}
onPrefixChange={setBranchPrefix}
/>
Expand Down
14 changes: 14 additions & 0 deletions src/lib/branch-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}/...".`;
}
Loading