From ddc1180a4e6696d4dc152a01c12deec37ff01ed0 Mon Sep 17 00:00:00 2001 From: Qazi Atiq Date: Tue, 17 Mar 2026 12:27:12 +1100 Subject: [PATCH] feat(tasks): add configurable base branch for worktree creation and merging - Add optional baseBranch parameter to createTask and mergeTask flows - Add base branch input field in NewTaskDialog with auto-detection - Pass baseBranch as startPoint to git worktree add command - Use baseBranch as merge target instead of auto-detected main branch - Persist baseBranch in Task and PersistedTask types - Add validation for baseBranch parameter in IPC handlers --- electron/ipc/git.ts | 10 ++++- electron/ipc/register.ts | 19 ++++++++- electron/ipc/tasks.ts | 3 +- src/components/NewTaskDialog.tsx | 69 ++++++++++++++++++++++++++++++++ src/store/persistence.ts | 4 ++ src/store/tasks.ts | 5 +++ src/store/types.ts | 2 + 7 files changed, 107 insertions(+), 5 deletions(-) diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index e633324f..bee2f089 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -337,6 +337,7 @@ export async function createWorktree( branchName: string, symlinkDirs: string[], forceClean = false, + startPoint?: string, ): Promise<{ path: string; branch: string }> { const worktreePath = `${repoRoot}/.worktrees/${branchName}`; @@ -362,7 +363,11 @@ export async function createWorktree( } // Create fresh worktree with new branch - await exec('git', ['worktree', 'add', '-b', branchName, worktreePath], { cwd: repoRoot }); + await exec( + 'git', + ['worktree', 'add', '-b', branchName, worktreePath, ...(startPoint ? [startPoint] : [])], + { cwd: repoRoot }, + ); // Symlink selected directories for (const name of symlinkDirs) { @@ -863,11 +868,12 @@ export async function mergeTask( squash: boolean, message: string | null, cleanup: boolean, + targetBranch?: string, ): Promise<{ main_branch: string; lines_added: number; lines_removed: number }> { const lockKey = await detectRepoLockKey(projectRoot).catch(() => projectRoot); return withWorktreeLock(lockKey, async () => { - const mainBranch = await detectMainBranch(projectRoot); + const mainBranch = targetBranch ?? (await detectMainBranch(projectRoot)); const { linesAdded, linesRemoved } = await computeBranchDiffStats( projectRoot, mainBranch, diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 1a054d6d..3f77b592 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -131,7 +131,14 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.projectRoot, 'projectRoot'); assertStringArray(args.symlinkDirs, 'symlinkDirs'); assertOptionalString(args.branchPrefix, 'branchPrefix'); - const result = createTask(args.name, args.projectRoot, args.symlinkDirs, args.branchPrefix); + assertOptionalString(args.baseBranch, 'baseBranch'); + const result = createTask( + args.name, + args.projectRoot, + args.symlinkDirs, + args.branchPrefix, + args.baseBranch, + ); result.then((r: { id: string }) => taskNames.set(r.id, args.name)).catch(() => {}); return result; }); @@ -200,7 +207,15 @@ export function registerAllHandlers(win: BrowserWindow): void { assertBoolean(args.squash, 'squash'); assertOptionalString(args.message, 'message'); assertOptionalBoolean(args.cleanup, 'cleanup'); - return mergeTask(args.projectRoot, args.branchName, args.squash, args.message, args.cleanup); + assertOptionalString(args.targetBranch, 'targetBranch'); + return mergeTask( + args.projectRoot, + args.branchName, + args.squash, + args.message, + args.cleanup, + args.targetBranch, + ); }); ipcMain.handle(IPC.GetBranchLog, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); diff --git a/electron/ipc/tasks.ts b/electron/ipc/tasks.ts index c6198099..65c703c4 100644 --- a/electron/ipc/tasks.ts +++ b/electron/ipc/tasks.ts @@ -33,10 +33,11 @@ export async function createTask( projectRoot: string, symlinkDirs: string[], branchPrefix: string, + baseBranch?: string, ): Promise<{ id: string; branch_name: string; worktree_path: string }> { const prefix = sanitizeBranchPrefix(branchPrefix); const branchName = `${prefix}/${slug(name)}`; - const worktree = await createWorktree(projectRoot, branchName, symlinkDirs); + const worktree = await createWorktree(projectRoot, branchName, symlinkDirs, false, baseBranch); return { id: randomUUID(), branch_name: worktree.branch, diff --git a/src/components/NewTaskDialog.tsx b/src/components/NewTaskDialog.tsx index 1789bdd6..2dc53e68 100644 --- a/src/components/NewTaskDialog.tsx +++ b/src/components/NewTaskDialog.tsx @@ -43,6 +43,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { const [directMode, setDirectMode] = createSignal(false); const [skipPermissions, setSkipPermissions] = createSignal(false); const [branchPrefix, setBranchPrefix] = createSignal(''); + const [baseBranch, setBaseBranch] = createSignal(''); let promptRef!: HTMLTextAreaElement; let formRef!: HTMLFormElement; @@ -105,6 +106,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { setLoading(false); setDirectMode(false); setSkipPermissions(false); + setBaseBranch(''); void (async () => { if (store.availableAgents.length === 0) { @@ -194,6 +196,32 @@ export function NewTaskDialog(props: NewTaskDialogProps) { setBranchPrefix(pid ? getProjectBranchPrefix(pid) : 'task'); }); + // Auto-detect base branch when project changes + createEffect(() => { + const pid = selectedProjectId(); + const path = pid ? getProjectPath(pid) : undefined; + let cancelled = false; + + if (!path) { + setBaseBranch(''); + return; + } + + void (async () => { + try { + const detected = await invoke(IPC.GetMainBranch, { projectRoot: path }); + if (cancelled) return; + setBaseBranch((prev) => (prev === '' ? detected : prev)); + } catch { + /* ignore */ + } + })(); + + onCleanup(() => { + cancelled = true; + }); + }); + // Pre-check direct mode based on project setting createEffect(() => { const pid = selectedProjectId(); @@ -298,6 +326,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { skipPermissions: agentSupportsSkipPermissions() && skipPermissions(), }); } else { + const bb = baseBranch().trim() || undefined; taskId = await createTask({ name: n, agentDef: agent, @@ -307,6 +336,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { branchPrefixOverride: prefix, githubUrl: ghUrl, skipPermissions: agentSupportsSkipPermissions() && skipPermissions(), + baseBranch: bb, }); } // Drop flow: prefill prompt without auto-sending @@ -495,6 +525,45 @@ export function NewTaskDialog(props: NewTaskDialogProps) { /> + +
+ + setBaseBranch(e.currentTarget.value)} + placeholder="main" + style={{ + background: theme.bgInput, + border: `1px solid ${theme.border}`, + 'border-radius': '8px', + padding: '10px 14px', + color: theme.fg, + 'font-size': '13px', + 'font-family': "'JetBrains Mono', monospace", + outline: 'none', + }} + /> + + Worktree branches from and merges back into this branch. + +
+
+ { githubUrl: task.githubUrl, savedInitialPrompt: task.savedInitialPrompt, planFileName: task.planFileName, + baseBranch: task.baseBranch, }; } @@ -91,6 +92,7 @@ export async function saveState(): Promise { savedInitialPrompt: task.savedInitialPrompt, planFileName: task.planFileName, collapsed: true, + baseBranch: task.baseBranch, }; } @@ -337,6 +339,7 @@ export async function loadState(): Promise { githubUrl: pt.githubUrl, savedInitialPrompt: pt.savedInitialPrompt, planFileName: pt.planFileName, + baseBranch: pt.baseBranch, }; s.tasks[taskId] = task; @@ -404,6 +407,7 @@ export async function loadState(): Promise { planFileName: pt.planFileName, collapsed: true, savedAgentDef: agentDef ?? undefined, + baseBranch: pt.baseBranch, }; s.tasks[taskId] = task; diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 2bf3168d..d96b36e0 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -57,6 +57,7 @@ export interface CreateTaskOptions { branchPrefixOverride?: string; githubUrl?: string; skipPermissions?: boolean; + baseBranch?: string; } export async function createTask(opts: CreateTaskOptions): Promise { @@ -68,6 +69,7 @@ export async function createTask(opts: CreateTaskOptions): Promise { initialPrompt, githubUrl, skipPermissions, + baseBranch, } = opts; const projectRoot = getProjectPath(projectId); if (!projectRoot) throw new Error('Project not found'); @@ -79,6 +81,7 @@ export async function createTask(opts: CreateTaskOptions): Promise { projectRoot, symlinkDirs, branchPrefix, + baseBranch, }); const agentId = crypto.randomUUID(); @@ -96,6 +99,7 @@ export async function createTask(opts: CreateTaskOptions): Promise { skipPermissions: skipPermissions || undefined, githubUrl, savedInitialPrompt: initialPrompt || undefined, + baseBranch: baseBranch || undefined, }; const agent: Agent = { @@ -327,6 +331,7 @@ export async function mergeTask( squash: options?.squash ?? false, message: options?.message, cleanup, + targetBranch: task.baseBranch, }); recordMergedLines(mergeResult.lines_added, mergeResult.lines_removed); diff --git a/src/store/types.ts b/src/store/types.ts index 3aa0df36..55986295 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -52,6 +52,7 @@ export interface Task { savedAgentDef?: AgentDef; planContent?: string; planFileName?: string; + baseBranch?: string; } export interface Terminal { @@ -77,6 +78,7 @@ export interface PersistedTask { savedInitialPrompt?: string; collapsed?: boolean; planFileName?: string; + baseBranch?: string; } export interface PersistedTerminal {