diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index d9abff99..6aaabe0f 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -282,6 +282,12 @@ contextBridge.exposeInMainWorld("electronAPI", { }> => ipcRenderer.invoke("get-diff-stats", repoPath), getCurrentBranch: (repoPath: string): Promise => ipcRenderer.invoke("get-current-branch", repoPath), + getDefaultBranch: (repoPath: string): Promise => + ipcRenderer.invoke("get-default-branch", repoPath), + getAllBranches: (repoPath: string): Promise => + ipcRenderer.invoke("get-all-branches", repoPath), + createBranch: (repoPath: string, branchName: string): Promise => + ipcRenderer.invoke("create-branch", repoPath, branchName), discardFileChanges: ( repoPath: string, filePath: string, diff --git a/apps/array/src/main/services/git.ts b/apps/array/src/main/services/git.ts index 22dc5fb5..5c05c441 100644 --- a/apps/array/src/main/services/git.ts +++ b/apps/array/src/main/services/git.ts @@ -153,6 +153,36 @@ export const getDefaultBranch = async ( } }; +export const getAllBranches = async ( + directoryPath: string, +): Promise => { + try { + const { stdout } = await execAsync( + 'git branch --list --format="%(refname:short)"', + { + cwd: directoryPath, + }, + ); + return stdout + .trim() + .split("\n") + .filter(Boolean) + .map((branch) => branch.trim()) + .filter((branch) => !branch.startsWith("array/")); + } catch { + return []; + } +}; + +export const createBranch = async ( + directoryPath: string, + branchName: string, +): Promise => { + await execAsync(`git checkout -b "${branchName}"`, { + cwd: directoryPath, + }); +}; + const getChangedFiles = async (directoryPath: string): Promise> => { const changedFiles = new Set(); @@ -785,6 +815,37 @@ export function registerGitIpc( }, ); + ipcMain.handle( + "get-default-branch", + async ( + _event: IpcMainInvokeEvent, + directoryPath: string, + ): Promise => { + return getDefaultBranch(directoryPath); + }, + ); + + ipcMain.handle( + "get-all-branches", + async ( + _event: IpcMainInvokeEvent, + directoryPath: string, + ): Promise => { + return getAllBranches(directoryPath); + }, + ); + + ipcMain.handle( + "create-branch", + async ( + _event: IpcMainInvokeEvent, + directoryPath: string, + branchName: string, + ): Promise => { + return createBranch(directoryPath, branchName); + }, + ); + ipcMain.handle( "discard-file-changes", async ( diff --git a/apps/array/src/main/services/workspace/workspaceService.ts b/apps/array/src/main/services/workspace/workspaceService.ts index aec98269..9977cbe4 100644 --- a/apps/array/src/main/services/workspace/workspaceService.ts +++ b/apps/array/src/main/services/workspace/workspaceService.ts @@ -84,7 +84,8 @@ export class WorkspaceService { async createWorkspace( options: CreateWorkspaceOptions, ): Promise { - const { taskId, mainRepoPath, folderId, folderPath, mode } = options; + const { taskId, mainRepoPath, folderId, folderPath, mode, branch } = + options; log.info( `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode})`, ); @@ -117,6 +118,25 @@ export class WorkspaceService { // Root mode: skip worktree creation entirely if (mode === "root") { + // try to create the branch, if it already exists just switch to it + if (branch) { + try { + log.info(`Creating/switching to branch ${branch} for task ${taskId}`); + try { + await execAsync(`git checkout -b "${branch}"`, { cwd: folderPath }); + log.info(`Created and switched to new branch ${branch}`); + } catch (_error) { + await execAsync(`git checkout "${branch}"`, { cwd: folderPath }); + log.info(`Switched to existing branch ${branch}`); + } + } catch (error) { + log.error(`Failed to create/switch to branch ${branch}:`, error); + throw new Error( + `Failed to create/switch to branch ${branch}: ${String(error)}`, + ); + } + } + // Save task association without worktree const associations = getTaskAssociations(); const existingIndex = associations.findIndex((a) => a.taskId === taskId); @@ -219,7 +239,9 @@ export class WorkspaceService { let worktree: WorktreeInfo; try { - worktree = await worktreeManager.createWorktree(); + worktree = await worktreeManager.createWorktree({ + baseBranch: branch ?? undefined, + }); log.info( `Created worktree: ${worktree.worktreeName} at ${worktree.worktreePath}`, ); diff --git a/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx b/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx index 4e7d4366..53579373 100644 --- a/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx +++ b/apps/array/src/renderer/features/folder-picker/components/FolderPicker.tsx @@ -61,12 +61,7 @@ export function FolderPicker({ onClick={handleOpenFilePicker} > - + @@ -90,13 +86,8 @@ export function FolderPicker({ + + + ); + } + + return ( + + + + + + +
+ setSearchQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ + + {defaultBranch && filteredBranches.includes(defaultBranch) && ( + onChange(defaultBranch)}> + + + + {defaultBranch} + + + + )} + + {filteredBranches.filter((branch) => branch !== defaultBranch).length > + 0 && ( + <> + {defaultBranch && filteredBranches.includes(defaultBranch) && ( + + )} + {filteredBranches + .filter((branch) => branch !== defaultBranch) + .map((branch) => ( + onChange(branch)} + > + + + {branch} + + + ))} + + )} + + {hasMoreBranches && ( + + + Type to filter {branches.length - MAX_DISPLAYED_BRANCHES} more... + + + )} + + {filteredBranches.length === 0 && searchQuery && ( + + + No branches match "{searchQuery}" + + + )} + + + + + + + {searchQuery ? `Create "${searchQuery}"` : "Create new branch..."} + + + +
+
+ ); +} diff --git a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx index d0d5fa21..1588e382 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx @@ -11,6 +11,7 @@ import { useTaskDirectoryStore } from "@stores/taskDirectoryStore"; import { useEffect, useState } from "react"; import { useEditorSetup } from "../hooks/useEditorSetup"; import { useTaskCreation } from "../hooks/useTaskCreation"; +import { BranchSelect } from "./BranchSelect"; import { type RunMode, RunModeSelect } from "./RunModeSelect"; import { SuggestedTasks } from "./SuggestedTasks"; import { TaskInputEditor } from "./TaskInputEditor"; @@ -38,6 +39,7 @@ export function TaskInput() { ); const [localWorkspaceMode, setLocalWorkspaceMode] = useState(lastUsedLocalWorkspaceMode); + const [selectedBranch, setSelectedBranch] = useState(null); const { githubIntegration } = useRepositoryIntegration(); @@ -70,6 +72,7 @@ export function TaskInput() { selectedRepository, githubIntegrationId: githubIntegration?.id, workspaceMode: effectiveWorkspaceMode, + branch: selectedBranch, }); return ( @@ -140,6 +143,13 @@ export function TaskInput() { {import.meta.env.DEV && ( )} + {selectedDirectory && ( + + )}
Promise; // Operations @@ -165,6 +166,7 @@ const useWorkspaceStoreBase = create()((set, get) => { taskId: string, repoPath: string, mode: WorkspaceMode = "worktree", + branch?: string | null, ) => { // Return existing workspace if it exists const existing = get().workspaces[taskId]; @@ -189,7 +191,7 @@ const useWorkspaceStoreBase = create()((set, get) => { worktreePath: null, worktreeName: null, branchName: null, - baseBranch: null, + baseBranch: branch ?? null, createdAt: new Date().toISOString(), terminalSessionIds: [], hasStartScripts: false, @@ -206,6 +208,7 @@ const useWorkspaceStoreBase = create()((set, get) => { folderId: folder.id, folderPath: repoPath, mode: "cloud", + branch: branch ?? undefined, }); return cloudWorkspace; @@ -258,6 +261,7 @@ const useWorkspaceStoreBase = create()((set, get) => { folderId: folder.id, folderPath: repoPath, mode, + branch: branch ?? undefined, }); if (!workspaceInfo) { diff --git a/apps/array/src/renderer/stores/taskDirectoryStore.ts b/apps/array/src/renderer/stores/taskDirectoryStore.ts index a97e47ed..77080b40 100644 --- a/apps/array/src/renderer/stores/taskDirectoryStore.ts +++ b/apps/array/src/renderer/stores/taskDirectoryStore.ts @@ -10,6 +10,7 @@ interface TaskDirectoryState { getTaskDirectory: (taskId: string, repoKey?: string) => string | null; setRepoDirectory: (repoKey: string, directory: string) => void; clearRepoDirectory: (repoKey: string) => void; + validateLastUsedDirectory: () => Promise; } const isValidPath = (path: string): boolean => { @@ -54,6 +55,17 @@ export const useTaskDirectoryStore = create()( repoDirectories: omitKey(state.repoDirectories, repoKey), })); }, + + validateLastUsedDirectory: async () => { + const { lastUsedDirectory } = get(); + if (!lastUsedDirectory) return; + + const exists = + await window.electronAPI?.validateRepo(lastUsedDirectory); + if (!exists) { + set({ lastUsedDirectory: null }); + } + }, }), { name: "task-directory-mappings", @@ -84,6 +96,9 @@ export const useTaskDirectoryStore = create()( state.repoDirectories = cleanedRepoDirs; state.lastUsedDirectory = cleanedLastUsed; } + + // Validate that lastUsedDirectory still exists on disk + state.validateLastUsedDirectory(); }, }, ), diff --git a/apps/array/src/renderer/types/electron.d.ts b/apps/array/src/renderer/types/electron.d.ts index 375e60b2..07645224 100644 --- a/apps/array/src/renderer/types/electron.d.ts +++ b/apps/array/src/renderer/types/electron.d.ts @@ -195,6 +195,9 @@ declare global { linesRemoved: number; }>; getCurrentBranch: (repoPath: string) => Promise; + getDefaultBranch: (repoPath: string) => Promise; + getAllBranches: (repoPath: string) => Promise; + createBranch: (repoPath: string, branchName: string) => Promise; discardFileChanges: ( repoPath: string, filePath: string, diff --git a/apps/array/src/shared/types.ts b/apps/array/src/shared/types.ts index 97db44fc..61b81217 100644 --- a/apps/array/src/shared/types.ts +++ b/apps/array/src/shared/types.ts @@ -70,6 +70,7 @@ export interface CreateWorkspaceOptions { folderId: string; folderPath: string; mode: WorkspaceMode; + branch?: string; } export interface ScriptExecutionResult { diff --git a/packages/agent/src/worktree-manager.ts b/packages/agent/src/worktree-manager.ts index d27db5b4..e25e6b20 100644 --- a/packages/agent/src/worktree-manager.ts +++ b/packages/agent/src/worktree-manager.ts @@ -628,7 +628,9 @@ export class WorktreeManager { } } - async createWorktree(): Promise { + async createWorktree(options?: { + baseBranch?: string; + }): Promise { // Only modify .git/info/exclude when using in-repo storage if (!this.usesExternalPath()) { await this.ensureArrayDirIgnored(); @@ -644,7 +646,7 @@ export class WorktreeManager { const worktreeName = await this.generateUniqueWorktreeName(); const worktreePath = this.getWorktreePath(worktreeName); const branchName = `array/${worktreeName}`; - const baseBranch = await this.getDefaultBranch(); + const baseBranch = options?.baseBranch ?? (await this.getDefaultBranch()); this.logger.info("Creating worktree", { worktreeName,