Skip to content

Commit cfdbc06

Browse files
committed
🤖 Add trunk branch selector for workspace creation
Adds ability to select a trunk/base branch when creating a new workspace. When a trunk branch is selected, the new workspace branch is created from that trunk instead of from HEAD or an existing remote branch. UI Changes: - Command palette: Added trunk branch selection field with fuzzy search - Modal (CMD+N): Added dropdown to select trunk branch - Both UIs load local branches and show them as options - Trunk branch selection is optional (defaults to HEAD if not selected) Backend Changes: - Added listLocalBranches() to git.ts to enumerate local branches - Updated createWorktree() to accept optional trunkBranch parameter - New logic: if trunk is specified, always create new branch from trunk - Ignores remote branches when trunk is explicitly selected - Errors if local branch already exists (prevents overwrite) - Without trunk: preserves existing behavior (checkout remote or create from HEAD) IPC Layer: - Added WORKSPACE_LIST_BRANCHES channel to fetch branches for a project - Updated workspace.create to accept and pass through trunkBranch - Wired through preload.ts, ipcMain.ts, and type definitions Integration: - Updated App.tsx to pass trunk branch from modal to backend - Updated command palette prompts to collect trunk branch selection - Added getBranchesForProject callback to command registry context - Command palette supports async option loading with fuzzy filtering This enables creating feature branches from any local branch, useful for: - Creating branches from main instead of current HEAD - Starting new work from a specific release branch - Isolating work from unrelated changes on current branch _Generated with `cmux`_ Change-Id: Ic2a710104f3a5925e0e6bdff1d0e5ed772bcf108 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 8b3af7f commit cfdbc06

File tree

13 files changed

+249
-45
lines changed

13 files changed

+249
-45
lines changed

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,7 @@
123123
"hardenedRuntime": true,
124124
"gatekeeperAssess": false,
125125
"entitlements": "build/entitlements.mac.plist",
126-
"entitlementsInherit": "build/entitlements.mac.plist",
127-
"notarize": true
126+
"entitlementsInherit": "build/entitlements.mac.plist"
128127
},
129128
"linux": {
130129
"target": "AppImage",

src/App.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,10 @@ function AppInner() {
210210
setWorkspaceModalOpen(true);
211211
}, []);
212212

213-
const handleCreateWorkspace = async (branchName: string) => {
213+
const handleCreateWorkspace = async (branchName: string, trunkBranch?: string) => {
214214
if (!workspaceModalProject) return;
215215

216-
const newWorkspace = await createWorkspace(workspaceModalProject, branchName);
216+
const newWorkspace = await createWorkspace(workspaceModalProject, branchName, trunkBranch);
217217
if (newWorkspace) {
218218
setSelectedWorkspace(newWorkspace);
219219
}
@@ -274,13 +274,22 @@ function AppInner() {
274274
);
275275

276276
const createWorkspaceFromPalette = useCallback(
277-
async (projectPath: string, branchName: string) => {
278-
const newWs = await createWorkspace(projectPath, branchName);
277+
async (projectPath: string, branchName: string, trunkBranch?: string) => {
278+
const newWs = await createWorkspace(projectPath, branchName, trunkBranch);
279279
if (newWs) setSelectedWorkspace(newWs);
280280
},
281281
[createWorkspace, setSelectedWorkspace]
282282
);
283283

284+
const getBranchesForProject = useCallback(async (projectPath: string) => {
285+
try {
286+
return await window.api.workspace.listBranches(projectPath);
287+
} catch (error) {
288+
console.error("Failed to list branches:", error);
289+
return [];
290+
}
291+
}, []);
292+
284293
const selectWorkspaceFromPalette = useCallback(
285294
(selection: {
286295
projectPath: string;
@@ -332,6 +341,7 @@ function AppInner() {
332341
streamingModels,
333342
onOpenNewWorkspaceModal: openNewWorkspaceFromPalette,
334343
onCreateWorkspace: createWorkspaceFromPalette,
344+
getBranchesForProject,
335345
onSelectWorkspace: selectWorkspaceFromPalette,
336346
onRemoveWorkspace: removeWorkspaceFromPalette,
337347
onRenameWorkspace: renameWorkspaceFromPalette,

src/components/CommandPalette.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,39 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
303303
}
304304
}, [activePrompt]);
305305

306+
const [selectOptions, setSelectOptions] = useState<
307+
Array<{ id: string; label: string; keywords?: string[] }>
308+
>([]);
309+
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
310+
306311
const currentField: PromptField | null = activePrompt
307312
? (activePrompt.fields[activePrompt.idx] ?? null)
308313
: null;
314+
315+
useEffect(() => {
316+
if (currentField && currentField.type === "select") {
317+
setIsLoadingOptions(true);
318+
const result = currentField.getOptions(activePrompt?.values ?? {});
319+
if (result instanceof Promise) {
320+
result
321+
.then((opts) => {
322+
setSelectOptions(opts);
323+
setIsLoadingOptions(false);
324+
})
325+
.catch(() => {
326+
setSelectOptions([]);
327+
setIsLoadingOptions(false);
328+
});
329+
} else {
330+
setSelectOptions(result);
331+
setIsLoadingOptions(false);
332+
}
333+
} else {
334+
setSelectOptions([]);
335+
setIsLoadingOptions(false);
336+
}
337+
}, [currentField, activePrompt]);
338+
309339
const isSlashQuery = !currentField && query.trim().startsWith("/");
310340
const shouldUseCmdkFilter = currentField ? currentField.type === "select" : !isSlashQuery;
311341

@@ -315,7 +345,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
315345
if (currentField) {
316346
const promptTitle = activePrompt?.title ?? currentField.label ?? "Provide details";
317347
if (currentField.type === "select") {
318-
const options = currentField.getOptions(activePrompt?.values ?? {});
348+
const options = selectOptions;
319349
groups = [
320350
{
321351
name: promptTitle,
@@ -328,7 +358,11 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
328358
})),
329359
},
330360
];
331-
emptyText = options.length ? undefined : "No options";
361+
emptyText = isLoadingOptions
362+
? "Loading options..."
363+
: options.length
364+
? undefined
365+
: "No options";
332366
} else {
333367
const typed = query.trim();
334368
const fallbackHint = currentField.placeholder ?? "Type value and press Enter";

src/components/NewWorkspaceModal.tsx

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ const FormGroup = styled.div`
4848
font-size: 14px;
4949
}
5050
51-
input {
51+
input,
52+
select {
5253
width: 100%;
5354
padding: 8px 12px;
5455
background: #2d2d2d;
@@ -67,6 +68,15 @@ const FormGroup = styled.div`
6768
cursor: not-allowed;
6869
}
6970
}
71+
72+
select {
73+
cursor: pointer;
74+
75+
option {
76+
background: #2d2d2d;
77+
color: #fff;
78+
}
79+
}
7080
`;
7181

7282
const ErrorMessage = styled.div`
@@ -141,7 +151,7 @@ interface NewWorkspaceModalProps {
141151
isOpen: boolean;
142152
projectPath: string;
143153
onClose: () => void;
144-
onAdd: (branchName: string) => Promise<void>;
154+
onAdd: (branchName: string, trunkBranch?: string) => Promise<void>;
145155
}
146156

147157
const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
@@ -151,15 +161,39 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
151161
onAdd,
152162
}) => {
153163
const [branchName, setBranchName] = useState("");
164+
const [trunkBranch, setTrunkBranch] = useState("");
165+
const [branches, setBranches] = useState<string[]>([]);
166+
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
154167
const [isLoading, setIsLoading] = useState(false);
155168
const [error, setError] = useState<string | null>(null);
156169

157170
const handleCancel = useCallback(() => {
158171
setBranchName("");
172+
setTrunkBranch("");
159173
setError(null);
160174
onClose();
161175
}, [onClose]);
162176

177+
// Load branches when modal opens
178+
useEffect(() => {
179+
if (!isOpen) return;
180+
181+
const loadBranches = async () => {
182+
setIsLoadingBranches(true);
183+
try {
184+
const branchList = await window.api.workspace.listBranches(projectPath);
185+
setBranches(branchList);
186+
} catch (err) {
187+
console.error("Failed to load branches:", err);
188+
setBranches([]);
189+
} finally {
190+
setIsLoadingBranches(false);
191+
}
192+
};
193+
194+
void loadBranches();
195+
}, [isOpen, projectPath]);
196+
163197
const handleSubmit = async (e: React.FormEvent) => {
164198
e.preventDefault();
165199
if (!branchName.trim()) {
@@ -171,8 +205,9 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
171205
setError(null);
172206

173207
try {
174-
await onAdd(branchName.trim());
208+
await onAdd(branchName.trim(), trunkBranch || undefined);
175209
setBranchName("");
210+
setTrunkBranch("");
176211
onClose();
177212
} catch (err) {
178213
const message = err instanceof Error ? err.message : "Failed to create workspace";
@@ -208,7 +243,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
208243

209244
<form onSubmit={(e) => void handleSubmit(e)}>
210245
<FormGroup>
211-
<label htmlFor="branchName">Branch Name:</label>
246+
<label htmlFor="branchName">Workspace Branch Name:</label>
212247
<input
213248
id="branchName"
214249
type="text"
@@ -225,6 +260,23 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
225260
{error && <ErrorMessage>{error}</ErrorMessage>}
226261
</FormGroup>
227262

263+
<FormGroup>
264+
<label htmlFor="trunkBranch">Trunk Branch (optional):</label>
265+
<select
266+
id="trunkBranch"
267+
value={trunkBranch}
268+
onChange={(e) => setTrunkBranch(e.target.value)}
269+
disabled={isLoading || isLoadingBranches}
270+
>
271+
<option value="">-- Select trunk branch (optional) --</option>
272+
{branches.map((branch) => (
273+
<option key={branch} value={branch}>
274+
{branch}
275+
</option>
276+
))}
277+
</select>
278+
</FormGroup>
279+
228280
<ModalInfo>
229281
<p>This will create a git worktree at:</p>
230282
<code>

src/constants/ipc-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const IPC_CHANNELS = {
2828
WORKSPACE_GET_INFO: "workspace:getInfo",
2929
WORKSPACE_EXECUTE_BASH: "workspace:executeBash",
3030
WORKSPACE_OPEN_TERMINAL: "workspace:openTerminal",
31+
WORKSPACE_LIST_BRANCHES: "workspace:listBranches",
3132

3233
// Dynamic channel prefixes
3334
WORKSPACE_CHAT_PREFIX: "workspace:chat:",

src/contexts/CommandRegistryContext.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,19 @@ export interface CommandAction {
2828
name: string;
2929
label?: string;
3030
placeholder?: string;
31-
getOptions: (values: Record<string, string>) => Array<{
32-
id: string;
33-
label: string;
34-
keywords?: string[];
35-
}>;
31+
getOptions: (values: Record<string, string>) =>
32+
| Array<{
33+
id: string;
34+
label: string;
35+
keywords?: string[];
36+
}>
37+
| Promise<
38+
Array<{
39+
id: string;
40+
label: string;
41+
keywords?: string[];
42+
}>
43+
>;
3644
}
3745
>;
3846
onSubmit: (values: Record<string, string>) => void | Promise<void>;

src/git.ts

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,30 @@ export interface WorktreeResult {
1212
error?: string;
1313
}
1414

15+
export interface CreateWorktreeOptions {
16+
trunkBranch?: string;
17+
}
18+
19+
export async function listLocalBranches(projectPath: string): Promise<string[]> {
20+
const { stdout } = await execAsync(
21+
`git -C "${projectPath}" for-each-ref --format="%(refname:short)" refs/heads`
22+
);
23+
return stdout
24+
.split("\n")
25+
.map((line) => line.trim())
26+
.filter((line) => line.length > 0)
27+
.sort((a, b) => a.localeCompare(b));
28+
}
29+
1530
export async function createWorktree(
1631
config: Config,
1732
projectPath: string,
18-
branchName: string
33+
branchName: string,
34+
options: CreateWorktreeOptions = {}
1935
): Promise<WorktreeResult> {
2036
try {
2137
const workspacePath = config.getWorkspacePath(projectPath, branchName);
38+
const { trunkBranch } = options;
2239

2340
// Create workspace directory if it doesn't exist
2441
if (!fs.existsSync(path.dirname(workspacePath))) {
@@ -33,23 +50,46 @@ export async function createWorktree(
3350
};
3451
}
3552

36-
// Check if branch exists
37-
const { stdout: branches } = await execAsync(`git -C "${projectPath}" branch -a`);
38-
const branchExists = branches
39-
.split("\n")
40-
.some(
41-
(b) =>
42-
b.trim() === branchName ||
43-
b.trim() === `* ${branchName}` ||
44-
b.trim() === `remotes/origin/${branchName}`
45-
);
53+
// If trunk branch is specified, always create a new branch from it
54+
if (trunkBranch) {
55+
// Check if branch already exists locally
56+
const localBranches = await listLocalBranches(projectPath);
57+
if (localBranches.includes(branchName)) {
58+
return {
59+
success: false,
60+
error: `Branch "${branchName}" already exists locally. Cannot create from trunk branch.`,
61+
};
62+
}
63+
64+
// Verify trunk branch exists locally
65+
if (!localBranches.includes(trunkBranch)) {
66+
return {
67+
success: false,
68+
error: `Trunk branch "${trunkBranch}" does not exist locally`,
69+
};
70+
}
4671

47-
if (branchExists) {
48-
// Branch exists, create worktree with existing branch
49-
await execAsync(`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`);
72+
// Create new branch from trunk
73+
await execAsync(
74+
`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${trunkBranch}"`
75+
);
5076
} else {
51-
// Branch doesn't exist, create new branch with worktree
52-
await execAsync(`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}"`);
77+
// No trunk branch specified - check if branch exists and handle accordingly
78+
const { stdout: branches } = await execAsync(`git -C "${projectPath}" branch -a`);
79+
const branchExists = branches
80+
.split("\n")
81+
.map((b) => b.trim().replace(/^(\*)\s+/, ""))
82+
.some((b) => b === branchName || b === `remotes/origin/${branchName}`);
83+
84+
if (branchExists) {
85+
// Branch exists, create worktree with existing branch
86+
await execAsync(`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`);
87+
} else {
88+
// Branch doesn't exist, create new branch from HEAD
89+
await execAsync(
90+
`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}"`
91+
);
92+
}
5393
}
5494

5595
return { success: true, path: workspacePath };

src/hooks/useWorkspaceManagement.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ export function useWorkspaceManagement({
4040
}
4141
};
4242

43-
const createWorkspace = async (projectPath: string, branchName: string) => {
44-
const result = await window.api.workspace.create(projectPath, branchName);
43+
const createWorkspace = async (projectPath: string, branchName: string, trunkBranch?: string) => {
44+
const result = await window.api.workspace.create(projectPath, branchName, trunkBranch);
4545
if (result.success) {
4646
// Update the project config with the new workspace
4747
const newProjects = new Map(projects);

src/preload.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ const api: IPCApi = {
3939
},
4040
workspace: {
4141
list: () => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST),
42-
create: (projectPath, branchName) =>
43-
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName),
42+
listBranches: (projectPath: string) =>
43+
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST_BRANCHES, projectPath),
44+
create: (projectPath, branchName, trunkBranch?: string) =>
45+
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch),
4446
remove: (workspaceId: string) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId),
4547
rename: (workspaceId: string, newName: string) =>
4648
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName),

0 commit comments

Comments
 (0)