Skip to content
Merged
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
44 changes: 44 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";

/**
* Custom ESLint plugin for zombie process prevention
* Enforces safe child_process patterns
*/
const localPlugin = {
rules: {
"no-unsafe-child-process": {
meta: {
type: "problem",
docs: {
description: "Prevent unsafe child_process usage that can cause zombie processes",
},
messages: {
unsafePromisifyExec:
"Do not use promisify(exec) directly. Use DisposableExec wrapper with 'using' declaration to prevent zombie processes.",
},
},
create(context) {
return {
CallExpression(node) {
// Ban promisify(exec)
if (
node.callee.type === "Identifier" &&
node.callee.name === "promisify" &&
node.arguments.length > 0 &&
node.arguments[0].type === "Identifier" &&
node.arguments[0].name === "exec"
) {
context.report({
node,
messageId: "unsafePromisifyExec",
});
}
},
};
},
},
},
};

export default defineConfig([
{
ignores: [
Expand Down Expand Up @@ -53,6 +93,7 @@ export default defineConfig([
plugins: {
react,
"react-hooks": reactHooks,
local: localPlugin,
},
settings: {
react: {
Expand Down Expand Up @@ -137,6 +178,9 @@ export default defineConfig([
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",

// Zombie process prevention
"local/no-unsafe-child-process": "error",

// Allow console for this app (it's a dev tool)
"no-console": "off",

Expand Down
1 change: 1 addition & 0 deletions src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as fs from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";

// eslint-disable-next-line local/no-unsafe-child-process -- Test file needs direct exec access for setup
const execAsync = promisify(exec);

describe("createWorktree", () => {
Expand Down
45 changes: 29 additions & 16 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { exec } from "child_process";
import { promisify } from "util";
import * as fs from "fs";
import * as path from "path";
import type { Config } from "./config";

const execAsync = promisify(exec);
import { execAsync } from "./utils/disposableExec";

export interface WorktreeResult {
success: boolean;
Expand All @@ -17,9 +14,10 @@ export interface CreateWorktreeOptions {
}

export async function listLocalBranches(projectPath: string): Promise<string[]> {
const { stdout } = await execAsync(
using proc = execAsync(
`git -C "${projectPath}" for-each-ref --format="%(refname:short)" refs/heads`
);
const { stdout } = await proc.result;
return stdout
.split("\n")
.map((line) => line.trim())
Expand All @@ -29,7 +27,8 @@ export async function listLocalBranches(projectPath: string): Promise<string[]>

async function getCurrentBranch(projectPath: string): Promise<string | null> {
try {
const { stdout } = await execAsync(`git -C "${projectPath}" rev-parse --abbrev-ref HEAD`);
using proc = execAsync(`git -C "${projectPath}" rev-parse --abbrev-ref HEAD`);
const { stdout } = await proc.result;
const branch = stdout.trim();
if (!branch || branch === "HEAD") {
return null;
Expand Down Expand Up @@ -108,19 +107,26 @@ export async function createWorktree(

// If branch already exists locally, reuse it instead of creating a new one
if (localBranches.includes(branchName)) {
await execAsync(`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`);
using proc = execAsync(
`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`
);
await proc.result;
return { success: true, path: workspacePath };
}

// Check if branch exists remotely (origin/<branchName>)
const { stdout: remoteBranchesRaw } = await execAsync(`git -C "${projectPath}" branch -a`);
using remoteBranchesProc = execAsync(`git -C "${projectPath}" branch -a`);
const { stdout: remoteBranchesRaw } = await remoteBranchesProc.result;
const branchExists = remoteBranchesRaw
.split("\n")
.map((b) => b.trim().replace(/^(\*)\s+/, ""))
.some((b) => b === branchName || b === `remotes/origin/${branchName}`);

if (branchExists) {
await execAsync(`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`);
using proc = execAsync(
`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`
);
await proc.result;
return { success: true, path: workspacePath };
}

Expand All @@ -131,9 +137,10 @@ export async function createWorktree(
};
}

await execAsync(
using proc = execAsync(
`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${normalizedTrunkBranch}"`
);
await proc.result;

return { success: true, path: workspacePath };
} catch (error) {
Expand All @@ -149,9 +156,10 @@ export async function removeWorktree(
): Promise<WorktreeResult> {
try {
// Remove the worktree (from the main repository context)
await execAsync(
using proc = execAsync(
`git -C "${projectPath}" worktree remove "${workspacePath}" ${options.force ? "--force" : ""}`
);
await proc.result;
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand All @@ -161,7 +169,8 @@ export async function removeWorktree(

export async function pruneWorktrees(projectPath: string): Promise<WorktreeResult> {
try {
await execAsync(`git -C "${projectPath}" worktree prune`);
using proc = execAsync(`git -C "${projectPath}" worktree prune`);
await proc.result;
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -190,7 +199,8 @@ export async function moveWorktree(
}

// Move the worktree using git (from the main repository context)
await execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
await proc.result;
return { success: true, path: newPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand All @@ -200,7 +210,8 @@ export async function moveWorktree(

export async function listWorktrees(projectPath: string): Promise<string[]> {
try {
const { stdout } = await execAsync(`git -C "${projectPath}" worktree list --porcelain`);
using proc = execAsync(`git -C "${projectPath}" worktree list --porcelain`);
const { stdout } = await proc.result;
const worktrees: string[] = [];
const lines = stdout.split("\n");

Expand All @@ -223,7 +234,8 @@ export async function listWorktrees(projectPath: string): Promise<string[]> {

export async function isGitRepository(projectPath: string): Promise<boolean> {
try {
await execAsync(`git -C "${projectPath}" rev-parse --git-dir`);
using proc = execAsync(`git -C "${projectPath}" rev-parse --git-dir`);
await proc.result;
return true;
} catch {
return false;
Expand All @@ -238,7 +250,8 @@ export async function isGitRepository(projectPath: string): Promise<boolean> {
export async function getMainWorktreeFromWorktree(worktreePath: string): Promise<string | null> {
try {
// Get the worktree list from the worktree itself
const { stdout } = await execAsync(`git -C "${worktreePath}" worktree list --porcelain`);
using proc = execAsync(`git -C "${worktreePath}" worktree list --porcelain`);
const { stdout } = await proc.result;
const lines = stdout.split("\n");

// The first worktree in the list is always the main worktree
Expand Down
41 changes: 25 additions & 16 deletions src/services/gitService.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { exec } from "child_process";
import { promisify } from "util";
import * as fs from "fs";
import * as fsPromises from "fs/promises";
import * as path from "path";
import type { Config } from "@/config";

const execAsync = promisify(exec);
import { execAsync } from "@/utils/disposableExec";

export interface WorktreeResult {
success: boolean;
Expand Down Expand Up @@ -35,7 +32,8 @@ export async function createWorktree(
}

// Check if branch exists
const { stdout: branches } = await execAsync(`git -C "${projectPath}" branch -a`);
using branchesProc = execAsync(`git -C "${projectPath}" branch -a`);
const { stdout: branches } = await branchesProc.result;
const branchExists = branches
.split("\n")
.some(
Expand All @@ -47,10 +45,16 @@ export async function createWorktree(

if (branchExists) {
// Branch exists, create worktree with existing branch
await execAsync(`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`);
using proc = execAsync(
`git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"`
);
await proc.result;
} else {
// Branch doesn't exist, create new branch with worktree
await execAsync(`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}"`);
using proc = execAsync(
`git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}"`
);
await proc.result;
}

return { success: true, path: workspacePath };
Expand All @@ -67,9 +71,8 @@ export async function createWorktree(
export async function isWorktreeClean(workspacePath: string): Promise<boolean> {
try {
// Check for uncommitted changes (staged or unstaged)
const { stdout: statusOutput } = await execAsync(
`git -C "${workspacePath}" status --porcelain`
);
using proc = execAsync(`git -C "${workspacePath}" status --porcelain`);
const { stdout: statusOutput } = await proc.result;
return statusOutput.trim() === "";
} catch {
// If git command fails, assume not clean (safer default)
Expand Down Expand Up @@ -98,9 +101,10 @@ export async function removeWorktree(
): Promise<WorktreeResult> {
try {
// Remove the worktree (from the main repository context)
await execAsync(
using proc = execAsync(
`git -C "${projectPath}" worktree remove "${workspacePath}" ${options.force ? "--force" : ""}`
);
await proc.result;
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand All @@ -110,7 +114,8 @@ export async function removeWorktree(

export async function pruneWorktrees(projectPath: string): Promise<WorktreeResult> {
try {
await execAsync(`git -C "${projectPath}" worktree prune`);
using proc = execAsync(`git -C "${projectPath}" worktree prune`);
await proc.result;
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -259,7 +264,8 @@ export async function moveWorktree(
}

// Move the worktree using git (from the main repository context)
await execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
await proc.result;
return { success: true, path: newPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand All @@ -269,7 +275,8 @@ export async function moveWorktree(

export async function listWorktrees(projectPath: string): Promise<string[]> {
try {
const { stdout } = await execAsync(`git -C "${projectPath}" worktree list --porcelain`);
using proc = execAsync(`git -C "${projectPath}" worktree list --porcelain`);
const { stdout } = await proc.result;
const worktrees: string[] = [];
const lines = stdout.split("\n");

Expand All @@ -292,7 +299,8 @@ export async function listWorktrees(projectPath: string): Promise<string[]> {

export async function isGitRepository(projectPath: string): Promise<boolean> {
try {
await execAsync(`git -C "${projectPath}" rev-parse --git-dir`);
using proc = execAsync(`git -C "${projectPath}" rev-parse --git-dir`);
await proc.result;
return true;
} catch {
return false;
Expand All @@ -307,7 +315,8 @@ export async function isGitRepository(projectPath: string): Promise<boolean> {
export async function getMainWorktreeFromWorktree(worktreePath: string): Promise<string | null> {
try {
// Get the worktree list from the worktree itself
const { stdout } = await execAsync(`git -C "${worktreePath}" worktree list --porcelain`);
using proc = execAsync(`git -C "${worktreePath}" worktree list --porcelain`);
const { stdout } = await proc.result;
const lines = stdout.split("\n");

// The first worktree in the list is always the main worktree
Expand Down
16 changes: 13 additions & 3 deletions src/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,16 +654,26 @@ export class IpcMain {
// macOS - try Ghostty first, fallback to Terminal.app
const terminal = this.findAvailableCommand(["ghostty", "terminal"]);
if (terminal === "ghostty") {
spawn("open", ["-a", "Ghostty", workspacePath], { detached: true });
const child = spawn("open", ["-a", "Ghostty", workspacePath], {
detached: true,
stdio: "ignore",
});
child.unref();
} else {
spawn("open", ["-a", "Terminal", workspacePath], { detached: true });
const child = spawn("open", ["-a", "Terminal", workspacePath], {
detached: true,
stdio: "ignore",
});
child.unref();
}
} else if (process.platform === "win32") {
// Windows
spawn("cmd", ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath], {
const child = spawn("cmd", ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath], {
detached: true,
shell: true,
stdio: "ignore",
});
child.unref();
} else {
// Linux - try terminal emulators in order of preference
// x-terminal-emulator is checked first as it respects user's system-wide preference
Expand Down
Loading