Skip to content
Closed
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
4 changes: 2 additions & 2 deletions docs/content/docs/contributing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pnpm run build
- `src/main/` – Electron main process, IPC handlers, services (Git, worktrees, PTY manager, DB, etc.)
- `src/renderer/` – React UI (Vite), hooks, components
- Local database – SQLite file created under the OS userData folder (see "Local DB" below)
- Worktrees – Git worktrees are created outside your repo root in a sibling `worktrees/` folder
- Worktrees – By default, Git worktrees are created outside your repo root in a sibling `worktrees/` folder (per-project overrides are supported)
- Logs – Agent terminal output and app logs are written to the OS userData folder (not inside repos)

## Development Workflow
Expand Down Expand Up @@ -106,7 +106,7 @@ feat(docs): add changelog tab with GitHub releases integration

### Git and worktrees

- The app creates worktrees in a sibling `../worktrees/` folder.
- The app uses `../worktrees/` by default, but each project can override the worktree base path.
- Do not delete worktree folders from Finder/Explorer; if you need cleanup, use:
- `git worktree prune` (from the main repo)
- or the in‑app workspace removal
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/tasks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The agent spawns in a terminal and starts working.

By default, each task creates a git worktree—a separate copy of your codebase on its own branch. This keeps agent changes isolated from your main branch and other tasks.

Worktrees are created in a sibling `worktrees/` directory outside your repo.
By default, worktrees are created in a sibling `worktrees/` directory outside your repo. You can override this per project (inside `.worktrees/`, `/tmp/emdash`, or a custom directory).

You can disable worktrees to work directly on your current branch. A warning appears since changes won't be isolated.

Expand Down
1 change: 1 addition & 0 deletions drizzle/0010_add_worktree_base_path_to_projects.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `projects` ADD COLUMN `worktree_base_path` text;
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
"when": 1738857600000,
"tag": "0009_add_ssh_support",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1765592430361,
"tag": "0010_add_worktree_base_path_to_projects",
"breakpoints": true
}
]
}
1 change: 1 addition & 0 deletions src/main/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const projects = sqliteTable(
gitRemote: text('git_remote'),
gitBranch: text('git_branch'),
baseRef: text('base_ref'),
worktreeBasePath: text('worktree_base_path'),
githubRepository: text('github_repository'),
githubConnected: integer('github_connected').notNull().default(0),
sshConnectionId: text('ssh_connection_id').references(() => sshConnections.id, {
Expand Down
28 changes: 27 additions & 1 deletion src/main/ipc/appIpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, clipboard, ipcMain, shell } from 'electron';
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from 'electron';
import { exec } from 'child_process';
import { readFile } from 'fs/promises';
import { join } from 'path';
Expand Down Expand Up @@ -191,6 +191,32 @@ export function registerAppIpc() {
}
});

ipcMain.handle(
'app:select-directory',
async (
event,
args?: {
title?: string;
defaultPath?: string;
}
) => {
try {
const parentWindow = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(parentWindow ?? undefined, {
title: args?.title || 'Select directory',
defaultPath: args?.defaultPath,
properties: ['openDirectory', 'createDirectory', 'promptToCreate'],
});
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
return { success: true, path: result.filePaths[0] };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
);

ipcMain.handle('app:clipboard-write-text', async (_event, text: string) => {
try {
if (typeof text !== 'string') throw new Error('Invalid clipboard text');
Expand Down
6 changes: 5 additions & 1 deletion src/main/ipc/githubIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ipcMain, app } from 'electron';
import { log } from '../lib/logger';
import { GitHubService } from '../services/GitHubService';
import { worktreeService } from '../services/WorktreeService';
import { projectSettingsService } from '../services/ProjectSettingsService';
import { githubCLIInstaller } from '../services/GitHubCLIInstaller';
import { exec } from 'child_process';
import { promisify } from 'util';
Expand Down Expand Up @@ -288,7 +289,10 @@ export function registerGithubIpc() {

await githubService.ensurePullRequestBranch(projectPath, prNumber, branchName);

const worktreesDir = path.resolve(projectPath, '..', 'worktrees');
const worktreesDir = await projectSettingsService.resolveProjectWorktreeBasePath(
projectId,
projectPath
);
const slug = slugify(taskName) || `pr-${prNumber}`;
let worktreePath = path.join(worktreesDir, slug);

Expand Down
38 changes: 28 additions & 10 deletions src/main/ipc/projectSettingsIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { projectSettingsService } from '../services/ProjectSettingsService';
import { worktreeService } from '../services/WorktreeService';

type ProjectSettingsArgs = { projectId: string };
type UpdateProjectSettingsArgs = { projectId: string; baseRef: string };
type UpdateProjectSettingsArgs = {
projectId: string;
baseRef?: string;
worktreeBasePath?: string | null;
};

const resolveProjectId = (input: ProjectSettingsArgs | string | undefined): string => {
if (!input) return '';
Expand Down Expand Up @@ -35,20 +39,34 @@ export function registerProjectSettingsIpc() {
async (_event, args: UpdateProjectSettingsArgs | undefined) => {
try {
const projectId = args?.projectId;
const baseRef = args?.baseRef;
if (!projectId) {
throw new Error('projectId is required');
}
if (typeof baseRef !== 'string') {
throw new Error('baseRef is required');
const updates: { baseRef?: string; worktreeBasePath?: string | null } = {};

if (args?.baseRef !== undefined) {
if (typeof args.baseRef !== 'string') {
throw new Error('baseRef must be a string');
}
const trimmed = args.baseRef.trim();
if (!trimmed) {
throw new Error('baseRef cannot be empty');
}
updates.baseRef = trimmed;
}
const trimmed = baseRef.trim();
if (!trimmed) {
throw new Error('baseRef cannot be empty');

if (args?.worktreeBasePath !== undefined) {
if (args.worktreeBasePath !== null && typeof args.worktreeBasePath !== 'string') {
throw new Error('worktreeBasePath must be a string or null');
}
updates.worktreeBasePath = args.worktreeBasePath;
}

if (Object.keys(updates).length === 0) {
throw new Error('At least one project setting must be provided');
}
const settings = await projectSettingsService.updateProjectSettings(projectId, {
baseRef: trimmed,
});

const settings = await projectSettingsService.updateProjectSettings(projectId, updates);
return { success: true, settings };
} catch (error) {
log.error('Failed to update project settings', error);
Expand Down
15 changes: 13 additions & 2 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
openProject: () => ipcRenderer.invoke('project:open'),
getProjectSettings: (projectId: string) =>
ipcRenderer.invoke('projectSettings:get', { projectId }),
updateProjectSettings: (args: { projectId: string; baseRef: string }) =>
ipcRenderer.invoke('projectSettings:update', args),
updateProjectSettings: (args: {
projectId: string;
baseRef?: string;
worktreeBasePath?: string | null;
}) => ipcRenderer.invoke('projectSettings:update', args),
fetchProjectBaseRef: (args: { projectId: string; projectPath: string }) =>
ipcRenderer.invoke('projectSettings:fetchBaseRef', args),
getGitInfo: (projectPath: string) => ipcRenderer.invoke('git:getInfo', projectPath),
Expand Down Expand Up @@ -326,6 +329,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
listRemoteBranches: (args: { projectPath: string; remote?: string }) =>
ipcRenderer.invoke('git:list-remote-branches', args),
openExternal: (url: string) => ipcRenderer.invoke('app:openExternal', url),
selectDirectory: (args?: { title?: string; defaultPath?: string }) =>
ipcRenderer.invoke('app:select-directory', args),
clipboardWriteText: (text: string) => ipcRenderer.invoke('app:clipboard-write-text', text),
// Telemetry (minimal, anonymous)
captureTelemetry: (event: string, properties?: Record<string, any>) =>
Expand Down Expand Up @@ -655,6 +660,12 @@ export interface ElectronAPI {
getVersion: () => Promise<string>;
getPlatform: () => Promise<string>;
clipboardWriteText: (text: string) => Promise<{ success: boolean; error?: string }>;
selectDirectory: (args?: { title?: string; defaultPath?: string }) => Promise<{
success: boolean;
path?: string;
canceled?: boolean;
error?: string;
}>;
listInstalledFonts: (args?: {
refresh?: boolean;
}) => Promise<{ success: boolean; fonts?: string[]; cached?: boolean; error?: string }>;
Expand Down
69 changes: 61 additions & 8 deletions src/main/services/DatabaseService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type sqlite3Type from 'sqlite3';
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, isNull, ne, or, sql, type SQL } from 'drizzle-orm';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { resolveDatabasePath, resolveMigrationsPath } from '../db/path';
import { getDrizzleClient } from '../db/drizzleClient';
Expand All @@ -25,6 +25,7 @@ export interface Project {
id: string;
name: string;
path: string;
worktreeBasePath?: string | null;
// Remote project fields (optional for backward compatibility)
isRemote?: boolean;
sshConnectionId?: string | null;
Expand Down Expand Up @@ -151,6 +152,8 @@ export class DatabaseService {
project.gitInfo.remote,
project.gitInfo.branch
);
const worktreeBasePath =
project.worktreeBasePath === undefined ? undefined : (project.worktreeBasePath ?? null);
const githubRepository = project.githubInfo?.repository ?? null;
const githubConnected = project.githubInfo?.connected ? 1 : 0;

Expand All @@ -171,6 +174,7 @@ export class DatabaseService {
id: project.id,
name: project.name,
path: project.path,
worktreeBasePath: worktreeBasePath ?? null,
gitRemote,
gitBranch,
baseRef: baseRef ?? null,
Expand All @@ -185,6 +189,7 @@ export class DatabaseService {
target: projectsTable.path,
set: {
name: project.name,
...(worktreeBasePath !== undefined ? { worktreeBasePath } : {}),
gitRemote,
gitBranch,
baseRef: baseRef ?? null,
Expand Down Expand Up @@ -252,14 +257,61 @@ export class DatabaseService {
const source = rows[0];
const normalized = this.computeBaseRef(trimmed, source.gitRemote, source.gitBranch);

await db
.update(projectsTable)
.set({
baseRef: normalized,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(projectsTable.id, projectId));
return this.updateProjectSettings(projectId, { baseRef: normalized });
}

async updateProjectSettings(
projectId: string,
updates: { baseRef?: string; worktreeBasePath?: string | null }
): Promise<Project | null> {
if (this.disabled) return null;
if (!projectId) {
throw new Error('projectId is required');
}
const hasBaseRef = typeof updates.baseRef === 'string';
const hasWorktreeBasePath = updates.worktreeBasePath !== undefined;
if (!hasBaseRef && !hasWorktreeBasePath) {
throw new Error('No project settings updates provided');
}
const { db } = await getDrizzleClient();

const next: {
baseRef?: string | null;
worktreeBasePath?: string | null;
updatedAt: SQL;
} = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};

if (hasBaseRef) {
const trimmed = (updates.baseRef || '').trim();
if (!trimmed) {
throw new Error('baseRef cannot be empty');
}

const rows = await db
.select({
id: projectsTable.id,
gitRemote: projectsTable.gitRemote,
gitBranch: projectsTable.gitBranch,
})
.from(projectsTable)
.where(eq(projectsTable.id, projectId))
.limit(1);

if (rows.length === 0) {
throw new Error(`Project not found: ${projectId}`);
}

const source = rows[0];
next.baseRef = this.computeBaseRef(trimmed, source.gitRemote, source.gitBranch);
}

if (hasWorktreeBasePath) {
next.worktreeBasePath = updates.worktreeBasePath ?? null;
}

await db.update(projectsTable).set(next).where(eq(projectsTable.id, projectId));
return this.getProjectById(projectId);
}

Expand Down Expand Up @@ -871,6 +923,7 @@ export class DatabaseService {
id: row.id,
name: row.name,
path: row.path,
worktreeBasePath: row.worktreeBasePath ?? null,
isRemote: row.isRemote === 1,
sshConnectionId: row.sshConnectionId ?? null,
remotePath: row.remotePath ?? null,
Expand Down
Loading