Skip to content
Open
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
6 changes: 6 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export enum IPC {
GetFileDiff = 'get_file_diff',
GetFileDiffFromBranch = 'get_file_diff_from_branch',
GetGitignoredDirs = 'get_gitignored_dirs',
ListProjectEntries = 'list_project_entries',
ListImportableWorktrees = 'list_importable_worktrees',
GetWorktreeStatus = 'get_worktree_status',
CheckMergeStatus = 'check_merge_status',
Expand Down Expand Up @@ -100,6 +101,11 @@ export enum IPC {
CancelAskAboutCode = 'cancel_ask_about_code',
SetMinimaxApiKey = 'set_minimax_api_key',

// Setup / teardown commands (per-project, run on worktree create / remove)
RunSetupCommands = 'run_setup_commands',
RunTeardownCommands = 'run_teardown_commands',
CancelProjectCommands = 'cancel_project_commands',

// Docker
CheckDockerAvailable = 'check_docker_available',
CheckDockerImageExists = 'check_docker_image_exists',
Expand Down
46 changes: 38 additions & 8 deletions electron/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,21 +489,27 @@ export async function createWorktree(
if (baseBranch) worktreeArgs.push(baseBranch);
await exec('git', worktreeArgs, { cwd: repoRoot });

// Symlink selected directories. `.claude` is handled separately below — it
// can't be a symlink because Claude Code's bwrap sandbox binds specific
// entries inside it, and bwrap refuses to bind-mount at symlink paths.
// Symlink selected directories (or files). `.claude` is handled separately
// below — it can't be a symlink because Claude Code's bwrap sandbox binds
// specific entries inside it, and bwrap refuses to bind-mount at symlink
// paths. Nested paths like `packages/app/node_modules` are supported: the
// parent directory is created with mkdir -p before the symlink is placed.
for (const name of symlinkDirs) {
if (name === '.claude') continue;
// Reject names that could escape the worktree directory
if (name.includes('/') || name.includes('\\') || name.includes('..') || name === '.') continue;
const source = path.join(repoRoot, name);
const target = path.join(worktreePath, name);
if (path.isAbsolute(name) || name === '.' || name === '') continue;
// Reject names that would escape the worktree root after normalization
// (blocks both leading `..` and embedded segments like `foo/../..`).
const normalized = path.normalize(name);
if (normalized.startsWith('..') || normalized === '.' || path.isAbsolute(normalized)) continue;
const source = path.join(repoRoot, normalized);
const target = path.join(worktreePath, normalized);
try {
if (!fs.existsSync(source)) continue;
if (fs.existsSync(target)) continue;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.symlinkSync(source, target);
} catch (err) {
console.warn(`Failed to symlink directory '${name}' into worktree:`, err);
console.warn(`Failed to symlink '${name}' into worktree:`, err);
}
}

Expand Down Expand Up @@ -701,6 +707,30 @@ export async function removeWorktree(

// --- IPC command functions ---

/**
* List immediate children of `projectRoot`. Used by `PathSelector` to
* autocomplete symlink-dir candidates. The `.git` directory is filtered out;
* other dotfiles are kept so users can pick e.g. `.env` or `.venv`. Users
* who need nested paths type them freely into the input.
*/
export async function listProjectEntries(
projectRoot: string,
): Promise<Array<{ name: string; isDir: boolean }>> {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(projectRoot, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.name !== '.git')
.map((e) => ({ name: e.name, isDir: e.isDirectory() }))
.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
}

export async function getGitIgnoredDirs(projectRoot: string): Promise<string[]> {
const results: string[] = [];
for (const name of SYMLINK_CANDIDATES) {
Expand Down
32 changes: 32 additions & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { startStepsWatcher, stopStepsWatcher, readStepsForWorktree } from './ste
import { startRemoteServer } from '../remote/server.js';
import {
getGitIgnoredDirs,
listProjectEntries,
getMainBranch,
getCurrentBranch,
getChangedFiles,
Expand Down Expand Up @@ -61,6 +62,7 @@ import { saveAppState, loadAppState } from './persistence.js';
import { spawn } from 'child_process';
import { askAboutCode, cancelAskAboutCode } from './ask-code.js';
import { setMinimaxApiKey } from './ask-code-minimax.js';
import { runProjectCommands, cancelProjectCommands } from './setup.js';
import { getSystemMonospaceFonts } from './system-fonts.js';
import path from 'path';
import {
Expand Down Expand Up @@ -326,6 +328,10 @@ export function registerAllHandlers(win: BrowserWindow): void {
validatePath(args.projectRoot, 'projectRoot');
return getGitIgnoredDirs(args.projectRoot);
});
ipcMain.handle(IPC.ListProjectEntries, (_e, args) => {
validatePath(args.projectRoot, 'projectRoot');
return listProjectEntries(args.projectRoot);
});
ipcMain.handle(IPC.ListImportableWorktrees, (_e, args) => {
validatePath(args.projectRoot, 'projectRoot');
return listImportableWorktrees(args.projectRoot);
Expand Down Expand Up @@ -557,6 +563,32 @@ export function registerAllHandlers(win: BrowserWindow): void {
cancelAskAboutCode(args.requestId);
});

// --- Setup / teardown commands ---
const handleRunCommands = (_e: unknown, args: unknown) => {
const a = args as {
worktreePath: unknown;
projectRoot: unknown;
commands: unknown;
onOutput: { __CHANNEL_ID__: unknown };
};
validatePath(a.worktreePath, 'worktreePath');
validatePath(a.projectRoot, 'projectRoot');
assertStringArray(a.commands, 'commands');
assertString(a.onOutput?.__CHANNEL_ID__, 'channelId');
return runProjectCommands(win, {
worktreePath: a.worktreePath as string,
projectRoot: a.projectRoot as string,
commands: a.commands as string[],
channelId: a.onOutput.__CHANNEL_ID__ as string,
});
};
ipcMain.handle(IPC.RunSetupCommands, handleRunCommands);
ipcMain.handle(IPC.RunTeardownCommands, handleRunCommands);
ipcMain.handle(IPC.CancelProjectCommands, (_e, args) => {
assertString(args.channelId, 'channelId');
cancelProjectCommands(args.channelId);
});

// --- File links ---
ipcMain.handle(IPC.OpenPath, (_e, args) => {
validatePath(args.filePath, 'filePath');
Expand Down
119 changes: 119 additions & 0 deletions electron/ipc/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { spawn } from 'child_process';
import type { BrowserWindow } from 'electron';

// One AbortController per active setup/teardown channel. cancelProjectCommands
// aborts it; spawn({signal}) kills the child; close handler detects the abort
// via signal.aborted.
const controllers = new Map<string, AbortController>();

// Electron / Node internal env that must not leak into user shell commands.
// `NODE_OPTIONS=--inspect-brk` would silently open a debugger for `npm install`;
// `ELECTRON_RUN_AS_NODE=1` mis-directs child `node` processes; `LD_PRELOAD`
// is a common ptrace/injection hook and has no business in user scripts.
const STRIP_ENV_KEYS = [
'NODE_OPTIONS',
'ELECTRON_RUN_AS_NODE',
'ELECTRON_NO_ATTACH_CONSOLE',
'ELECTRON_DEFAULT_ERROR_MODE',
'ELECTRON_ENABLE_LOGGING',
'ELECTRON_ENABLE_STACK_DUMPING',
'LD_PRELOAD',
];

function cleanEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
const stripped = new Set<string>(STRIP_ENV_KEYS);
const env: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(process.env)) {
if (!stripped.has(k)) env[k] = v;
}
return { ...env, ...extra };
}

interface RunCommandsArgs {
worktreePath: string;
projectRoot: string;
commands: string[];
channelId: string;
}

/**
* Run a sequence of shell commands for setup or teardown. Aborts on the first
* non-zero exit or when the channel is cancelled.
*
* `$PROJECT_ROOT` and `$WORKTREE` are exposed as env vars rather than
* interpolated into the command string — this avoids shell-metacharacter
* injection when a path contains spaces, semicolons, or backticks.
*/
export async function runProjectCommands(win: BrowserWindow, args: RunCommandsArgs): Promise<void> {
const { worktreePath, projectRoot, commands, channelId } = args;

const controller = new AbortController();
controllers.set(channelId, controller);

const send = (msg: string) => {
if (!win.isDestroyed()) {
win.webContents.send(`channel:${channelId}`, msg);
}
};

try {
for (const cmd of commands) {
controller.signal.throwIfAborted();
send(`$ ${cmd}\n`);
await runOne(cmd, worktreePath, projectRoot, controller.signal, send);
}
} finally {
if (controllers.get(channelId) === controller) {
controllers.delete(channelId);
}
}
}

function runOne(
cmd: string,
cwd: string,
projectRoot: string,
signal: AbortSignal,
send: (msg: string) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn('sh', ['-c', cmd], {
cwd,
env: cleanEnv({ PROJECT_ROOT: projectRoot, WORKTREE: cwd }),
stdio: ['ignore', 'pipe', 'pipe'],
signal,
});

proc.stdout?.on('data', (c: Buffer) => send(c.toString('utf8')));
proc.stderr?.on('data', (c: Buffer) => send(c.toString('utf8')));

let settled = false;
proc.on('close', (code, sig) => {
if (settled) return;
settled = true;
if (signal.aborted) {
// Raise a typed AbortError so callers can distinguish cancellation
// from a genuine failure without string-matching.
const err = new Error('Aborted');
err.name = 'AbortError';
reject(err);
} else if (code === 0) {
resolve();
} else if (sig) {
reject(new Error(`Command "${cmd}" killed by ${sig}`));
} else {
reject(new Error(`Command "${cmd}" exited with code ${code}`));
}
});
proc.on('error', (err) => {
if (settled) return;
settled = true;
reject(err);
});
});
}

/** Abort the running command for this channel, if any. */
export function cancelProjectCommands(channelId: string): void {
controllers.get(channelId)?.abort();
}
5 changes: 5 additions & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const ALLOWED_CHANNELS = new Set([
'get_all_file_diffs',
'get_all_file_diffs_from_branch',
'get_gitignored_dirs',
'list_project_entries',
'list_importable_worktrees',
'get_worktree_status',
'commit_all',
Expand Down Expand Up @@ -98,6 +99,10 @@ const ALLOWED_CHANNELS = new Set([
'ask_about_code',
'cancel_ask_about_code',
'set_minimax_api_key',
// Setup / teardown
'run_setup_commands',
'run_teardown_commands',
'cancel_project_commands',
// System
'get_system_fonts',
// File links
Expand Down
Loading
Loading