Skip to content

Commit ddc1180

Browse files
committed
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
1 parent 52c3be8 commit ddc1180

7 files changed

Lines changed: 107 additions & 5 deletions

File tree

electron/ipc/git.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ export async function createWorktree(
337337
branchName: string,
338338
symlinkDirs: string[],
339339
forceClean = false,
340+
startPoint?: string,
340341
): Promise<{ path: string; branch: string }> {
341342
const worktreePath = `${repoRoot}/.worktrees/${branchName}`;
342343

@@ -362,7 +363,11 @@ export async function createWorktree(
362363
}
363364

364365
// Create fresh worktree with new branch
365-
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath], { cwd: repoRoot });
366+
await exec(
367+
'git',
368+
['worktree', 'add', '-b', branchName, worktreePath, ...(startPoint ? [startPoint] : [])],
369+
{ cwd: repoRoot },
370+
);
366371

367372
// Symlink selected directories
368373
for (const name of symlinkDirs) {
@@ -863,11 +868,12 @@ export async function mergeTask(
863868
squash: boolean,
864869
message: string | null,
865870
cleanup: boolean,
871+
targetBranch?: string,
866872
): Promise<{ main_branch: string; lines_added: number; lines_removed: number }> {
867873
const lockKey = await detectRepoLockKey(projectRoot).catch(() => projectRoot);
868874

869875
return withWorktreeLock(lockKey, async () => {
870-
const mainBranch = await detectMainBranch(projectRoot);
876+
const mainBranch = targetBranch ?? (await detectMainBranch(projectRoot));
871877
const { linesAdded, linesRemoved } = await computeBranchDiffStats(
872878
projectRoot,
873879
mainBranch,

electron/ipc/register.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,14 @@ export function registerAllHandlers(win: BrowserWindow): void {
131131
validatePath(args.projectRoot, 'projectRoot');
132132
assertStringArray(args.symlinkDirs, 'symlinkDirs');
133133
assertOptionalString(args.branchPrefix, 'branchPrefix');
134-
const result = createTask(args.name, args.projectRoot, args.symlinkDirs, args.branchPrefix);
134+
assertOptionalString(args.baseBranch, 'baseBranch');
135+
const result = createTask(
136+
args.name,
137+
args.projectRoot,
138+
args.symlinkDirs,
139+
args.branchPrefix,
140+
args.baseBranch,
141+
);
135142
result.then((r: { id: string }) => taskNames.set(r.id, args.name)).catch(() => {});
136143
return result;
137144
});
@@ -200,7 +207,15 @@ export function registerAllHandlers(win: BrowserWindow): void {
200207
assertBoolean(args.squash, 'squash');
201208
assertOptionalString(args.message, 'message');
202209
assertOptionalBoolean(args.cleanup, 'cleanup');
203-
return mergeTask(args.projectRoot, args.branchName, args.squash, args.message, args.cleanup);
210+
assertOptionalString(args.targetBranch, 'targetBranch');
211+
return mergeTask(
212+
args.projectRoot,
213+
args.branchName,
214+
args.squash,
215+
args.message,
216+
args.cleanup,
217+
args.targetBranch,
218+
);
204219
});
205220
ipcMain.handle(IPC.GetBranchLog, (_e, args) => {
206221
validatePath(args.worktreePath, 'worktreePath');

electron/ipc/tasks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ export async function createTask(
3333
projectRoot: string,
3434
symlinkDirs: string[],
3535
branchPrefix: string,
36+
baseBranch?: string,
3637
): Promise<{ id: string; branch_name: string; worktree_path: string }> {
3738
const prefix = sanitizeBranchPrefix(branchPrefix);
3839
const branchName = `${prefix}/${slug(name)}`;
39-
const worktree = await createWorktree(projectRoot, branchName, symlinkDirs);
40+
const worktree = await createWorktree(projectRoot, branchName, symlinkDirs, false, baseBranch);
4041
return {
4142
id: randomUUID(),
4243
branch_name: worktree.branch,

src/components/NewTaskDialog.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
4343
const [directMode, setDirectMode] = createSignal(false);
4444
const [skipPermissions, setSkipPermissions] = createSignal(false);
4545
const [branchPrefix, setBranchPrefix] = createSignal('');
46+
const [baseBranch, setBaseBranch] = createSignal('');
4647
let promptRef!: HTMLTextAreaElement;
4748
let formRef!: HTMLFormElement;
4849

@@ -105,6 +106,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
105106
setLoading(false);
106107
setDirectMode(false);
107108
setSkipPermissions(false);
109+
setBaseBranch('');
108110

109111
void (async () => {
110112
if (store.availableAgents.length === 0) {
@@ -194,6 +196,32 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
194196
setBranchPrefix(pid ? getProjectBranchPrefix(pid) : 'task');
195197
});
196198

199+
// Auto-detect base branch when project changes
200+
createEffect(() => {
201+
const pid = selectedProjectId();
202+
const path = pid ? getProjectPath(pid) : undefined;
203+
let cancelled = false;
204+
205+
if (!path) {
206+
setBaseBranch('');
207+
return;
208+
}
209+
210+
void (async () => {
211+
try {
212+
const detected = await invoke<string>(IPC.GetMainBranch, { projectRoot: path });
213+
if (cancelled) return;
214+
setBaseBranch((prev) => (prev === '' ? detected : prev));
215+
} catch {
216+
/* ignore */
217+
}
218+
})();
219+
220+
onCleanup(() => {
221+
cancelled = true;
222+
});
223+
});
224+
197225
// Pre-check direct mode based on project setting
198226
createEffect(() => {
199227
const pid = selectedProjectId();
@@ -298,6 +326,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
298326
skipPermissions: agentSupportsSkipPermissions() && skipPermissions(),
299327
});
300328
} else {
329+
const bb = baseBranch().trim() || undefined;
301330
taskId = await createTask({
302331
name: n,
303332
agentDef: agent,
@@ -307,6 +336,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
307336
branchPrefixOverride: prefix,
308337
githubUrl: ghUrl,
309338
skipPermissions: agentSupportsSkipPermissions() && skipPermissions(),
339+
baseBranch: bb,
310340
});
311341
}
312342
// Drop flow: prefill prompt without auto-sending
@@ -495,6 +525,45 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
495525
/>
496526
</Show>
497527

528+
<Show when={!directMode()}>
529+
<div
530+
data-nav-field="base-branch"
531+
style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}
532+
>
533+
<label
534+
style={{
535+
'font-size': '11px',
536+
color: theme.fgMuted,
537+
'text-transform': 'uppercase',
538+
'letter-spacing': '0.05em',
539+
}}
540+
>
541+
Base branch{' '}
542+
<span style={{ opacity: '0.5', 'text-transform': 'none' }}>(merge target)</span>
543+
</label>
544+
<input
545+
class="input-field"
546+
type="text"
547+
value={baseBranch()}
548+
onInput={(e) => setBaseBranch(e.currentTarget.value)}
549+
placeholder="main"
550+
style={{
551+
background: theme.bgInput,
552+
border: `1px solid ${theme.border}`,
553+
'border-radius': '8px',
554+
padding: '10px 14px',
555+
color: theme.fg,
556+
'font-size': '13px',
557+
'font-family': "'JetBrains Mono', monospace",
558+
outline: 'none',
559+
}}
560+
/>
561+
<span style={{ 'font-size': '11px', color: theme.fgSubtle, padding: '0 2px' }}>
562+
Worktree branches from and merges back into this branch.
563+
</span>
564+
</div>
565+
</Show>
566+
498567
<AgentSelector
499568
agents={store.availableAgents}
500569
selectedAgent={selectedAgent()}

src/store/persistence.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export async function saveState(): Promise<void> {
6666
githubUrl: task.githubUrl,
6767
savedInitialPrompt: task.savedInitialPrompt,
6868
planFileName: task.planFileName,
69+
baseBranch: task.baseBranch,
6970
};
7071
}
7172

@@ -91,6 +92,7 @@ export async function saveState(): Promise<void> {
9192
savedInitialPrompt: task.savedInitialPrompt,
9293
planFileName: task.planFileName,
9394
collapsed: true,
95+
baseBranch: task.baseBranch,
9496
};
9597
}
9698

@@ -337,6 +339,7 @@ export async function loadState(): Promise<void> {
337339
githubUrl: pt.githubUrl,
338340
savedInitialPrompt: pt.savedInitialPrompt,
339341
planFileName: pt.planFileName,
342+
baseBranch: pt.baseBranch,
340343
};
341344

342345
s.tasks[taskId] = task;
@@ -404,6 +407,7 @@ export async function loadState(): Promise<void> {
404407
planFileName: pt.planFileName,
405408
collapsed: true,
406409
savedAgentDef: agentDef ?? undefined,
410+
baseBranch: pt.baseBranch,
407411
};
408412

409413
s.tasks[taskId] = task;

src/store/tasks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export interface CreateTaskOptions {
5757
branchPrefixOverride?: string;
5858
githubUrl?: string;
5959
skipPermissions?: boolean;
60+
baseBranch?: string;
6061
}
6162

6263
export async function createTask(opts: CreateTaskOptions): Promise<string> {
@@ -68,6 +69,7 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
6869
initialPrompt,
6970
githubUrl,
7071
skipPermissions,
72+
baseBranch,
7173
} = opts;
7274
const projectRoot = getProjectPath(projectId);
7375
if (!projectRoot) throw new Error('Project not found');
@@ -79,6 +81,7 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
7981
projectRoot,
8082
symlinkDirs,
8183
branchPrefix,
84+
baseBranch,
8285
});
8386

8487
const agentId = crypto.randomUUID();
@@ -96,6 +99,7 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
9699
skipPermissions: skipPermissions || undefined,
97100
githubUrl,
98101
savedInitialPrompt: initialPrompt || undefined,
102+
baseBranch: baseBranch || undefined,
99103
};
100104

101105
const agent: Agent = {
@@ -327,6 +331,7 @@ export async function mergeTask(
327331
squash: options?.squash ?? false,
328332
message: options?.message,
329333
cleanup,
334+
targetBranch: task.baseBranch,
330335
});
331336
recordMergedLines(mergeResult.lines_added, mergeResult.lines_removed);
332337

src/store/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface Task {
5252
savedAgentDef?: AgentDef;
5353
planContent?: string;
5454
planFileName?: string;
55+
baseBranch?: string;
5556
}
5657

5758
export interface Terminal {
@@ -77,6 +78,7 @@ export interface PersistedTask {
7778
savedInitialPrompt?: string;
7879
collapsed?: boolean;
7980
planFileName?: string;
81+
baseBranch?: string;
8082
}
8183

8284
export interface PersistedTerminal {

0 commit comments

Comments
 (0)