From 449a4b48d25de1b5edebedb8ea3d2a5d4cf1f0c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 14:05:05 +0700 Subject: [PATCH] Support PowerShell and resolve workspaces by directory path --- .../session-context.service-status.test.ts | 25 ++++++ .../contexts/session-workspace-services.ts | 13 ++- .../app/src/utils/workspace-execution.test.ts | 41 ++++++++++ packages/app/src/utils/workspace-execution.ts | 39 ++++++++- .../src/server/worktree-session.test.ts | 50 ++++++++++++ .../server/src/server/worktree-session.ts | 7 -- .../src/utils/string-command-shell.test.ts | 36 +++++++++ .../server/src/utils/string-command-shell.ts | 34 ++++++++ .../utils/worktree-shell-selection.test.ts | 81 +++++++++++++++++++ packages/server/src/utils/worktree.ts | 14 ++-- 10 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 packages/server/src/utils/string-command-shell.test.ts create mode 100644 packages/server/src/utils/string-command-shell.ts create mode 100644 packages/server/src/utils/worktree-shell-selection.test.ts diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index e0851dec9..b1d9b8269 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -49,6 +49,31 @@ describe("patchWorkspaceServices", () => { expect(next.get("/repo/other")).toBe(other); }); + it("patches the matching workspace when the update uses workspace directory identity", () => { + const current = new Map([ + [ + "42", + workspace({ + id: "42", + services: [], + }), + ], + ]); + + current.set("42", { + ...current.get("42")!, + workspaceDirectory: "C:\\repo\\main\\", + }); + + const next = patchWorkspaceServices(current, { + workspaceId: "C:/repo/main", + services: [runningService], + }); + + expect(next).not.toBe(current); + expect(next.get("42")?.services).toEqual([runningService]); + }); + it("ignores updates for unknown workspaces", () => { const current = new Map([ ["/repo/main", workspace({ id: "/repo/main", services: [] })], diff --git a/packages/app/src/contexts/session-workspace-services.ts b/packages/app/src/contexts/session-workspace-services.ts index 3ba1885f5..9536a81f8 100644 --- a/packages/app/src/contexts/session-workspace-services.ts +++ b/packages/app/src/contexts/session-workspace-services.ts @@ -1,17 +1,26 @@ import type { ServiceStatusUpdateMessage } from "@server/shared/messages"; import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; export function patchWorkspaceServices( workspaces: Map, update: ServiceStatusUpdateMessage["payload"], ): Map { - const existing = workspaces.get(update.workspaceId); + const workspaceKey = resolveWorkspaceMapKeyByIdentity({ + workspaces, + workspaceIdentity: update.workspaceId, + }); + if (!workspaceKey) { + return workspaces; + } + + const existing = workspaces.get(workspaceKey); if (!existing) { return workspaces; } const next = new Map(workspaces); - next.set(update.workspaceId, { + next.set(workspaceKey, { ...existing, services: update.services.map((s) => ({ ...s })), }); diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts index 77121af04..019ea4a78 100644 --- a/packages/app/src/utils/workspace-execution.test.ts +++ b/packages/app/src/utils/workspace-execution.test.ts @@ -3,6 +3,7 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; import { getWorkspaceExecutionAuthority, requireWorkspaceExecutionAuthority, + resolveWorkspaceMapKeyByIdentity, resolveWorkspaceIdByExecutionDirectory, resolveWorkspaceRouteId, } from "./workspace-execution"; @@ -72,6 +73,46 @@ describe("resolveWorkspaceIdByExecutionDirectory", () => { }); }); +describe("resolveWorkspaceMapKeyByIdentity", () => { + it("returns the existing map key when the identity already matches a key", () => { + const workspaces = new Map([ + [ + "workspace-1", + createWorkspace({ + id: "workspace-1", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ], + ]); + + expect( + resolveWorkspaceMapKeyByIdentity({ + workspaces, + workspaceIdentity: "workspace-1", + }), + ).toBe("workspace-1"); + }); + + it("resolves a workspace directory identity to the canonical map key", () => { + const workspaces = new Map([ + [ + "workspace-1", + createWorkspace({ + id: "workspace-1", + workspaceDirectory: "C:\\repo\\feature\\", + }), + ], + ]); + + expect( + resolveWorkspaceMapKeyByIdentity({ + workspaces, + workspaceIdentity: "C:/repo/feature", + }), + ).toBe("workspace-1"); + }); +}); + describe("workspace execution authority", () => { it("returns an explicit failure when workspace id is missing", () => { expect( diff --git a/packages/app/src/utils/workspace-execution.ts b/packages/app/src/utils/workspace-execution.ts index 2d47e02a0..d1a2ca80c 100644 --- a/packages/app/src/utils/workspace-execution.ts +++ b/packages/app/src/utils/workspace-execution.ts @@ -44,6 +44,36 @@ export function resolveWorkspaceIdByExecutionDirectory(input: { return null; } +export function resolveWorkspaceMapKeyByIdentity(input: { + workspaces: Map | null | undefined; + workspaceIdentity: string | null | undefined; +}): string | null { + const normalizedWorkspaceIdentity = normalizeWorkspaceIdentity(input.workspaceIdentity); + if (!normalizedWorkspaceIdentity) { + return null; + } + + const workspaces = input.workspaces; + if (!workspaces) { + return null; + } + + if (workspaces.has(normalizedWorkspaceIdentity)) { + return normalizedWorkspaceIdentity; + } + + for (const [workspaceKey, workspace] of workspaces) { + if ( + normalizeWorkspaceIdentity(workspace.id) === normalizedWorkspaceIdentity || + normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceIdentity + ) { + return workspaceKey; + } + } + + return null; +} + export function getWorkspaceExecutionAuthority( input: | { @@ -58,11 +88,14 @@ export function getWorkspaceExecutionAuthority( "workspace" in input ? input.workspace : (() => { - const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId); - if (!normalizedWorkspaceId) { + const workspaceKey = resolveWorkspaceMapKeyByIdentity({ + workspaces: input.workspaces, + workspaceIdentity: input.workspaceId, + }); + if (!workspaceKey) { return null; } - return input.workspaces?.get(normalizedWorkspaceId) ?? null; + return input.workspaces?.get(workspaceKey) ?? null; })(); if ("workspaces" in input) { diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 5a1f8eb23..83e7484e9 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import type { SessionOutboundMessage } from "./messages.js"; import { ServiceRouteStore } from "./service-proxy.js"; +import * as worktreeBootstrap from "./worktree-bootstrap.js"; import { createPaseoWorktreeInBackground, handleCreatePaseoWorktreeRequest, @@ -732,6 +733,55 @@ describe("createPaseoWorktreeInBackground", () => { }); describe("handleCreatePaseoWorktreeRequest", () => { + test("invokes worktree creation once for a create request", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const emitted: SessionOutboundMessage[] = []; + const createAgentWorktreeSpy = vi.spyOn(worktreeBootstrap, "createAgentWorktree"); + + try { + await handleCreatePaseoWorktreeRequest( + { + paseoHome, + sessionLogger: createLogger(), + emit: (message) => emitted.push(message), + registerPendingWorktreeWorkspace: vi.fn(async (options) => ({ + workspaceId: options.worktreePath, + projectId: options.repoRoot, + })), + describeWorkspaceRecord: vi.fn(async (workspace) => ({ + id: workspace.workspaceId, + projectId: workspace.projectId, + projectDisplayName: path.basename(repoDir), + projectRootPath: repoDir, + projectKind: "git", + workspaceKind: "worktree", + name: path.basename(workspace.workspaceId), + status: "done", + activityAt: null, + })), + createPaseoWorktreeInBackground: vi.fn(async () => {}), + }, + { + type: "create_paseo_worktree_request", + cwd: repoDir, + worktreeSlug: "single-call", + requestId: "req-single-call", + }, + ); + + expect(createAgentWorktreeSpy).toHaveBeenCalledTimes(1); + const response = emitted.find( + (message): message is Extract => + message.type === "create_paseo_worktree_response", + ); + expect(response?.payload.error).toBeNull(); + } finally { + createAgentWorktreeSpy.mockRestore(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + test("creates the worktree before emitting the response", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index e17ab144a..fd1309e7a 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -605,13 +605,6 @@ export async function handleCreatePaseoWorktreeRequest( worktreePath: createdWorktree.worktree.worktreePath, branchName: createdWorktree.worktree.branchName, }); - await createAgentWorktree({ - cwd: repoRoot, - branchName: normalizedSlug, - baseBranch, - worktreeSlug: normalizedSlug, - paseoHome: dependencies.paseoHome, - }); const descriptor = await dependencies.describeWorkspaceRecord(workspace); dependencies.emit({ type: "create_paseo_worktree_response", diff --git a/packages/server/src/utils/string-command-shell.test.ts b/packages/server/src/utils/string-command-shell.test.ts new file mode 100644 index 000000000..c58078af8 --- /dev/null +++ b/packages/server/src/utils/string-command-shell.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { buildStringCommandShellInvocation } from "./string-command-shell.js"; + +describe("buildStringCommandShellInvocation", () => { + it("uses bash login-command semantics on unix platforms", () => { + expect( + buildStringCommandShellInvocation({ + command: 'echo "hello"', + platform: "darwin", + }), + ).toEqual({ + shell: "/bin/bash", + args: ["-lc", 'echo "hello"'], + }); + }); + + it("uses powershell command semantics on windows", () => { + expect( + buildStringCommandShellInvocation({ + command: "Write-Output 'hello'", + platform: "win32", + }), + ).toEqual({ + shell: "powershell", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Write-Output 'hello'", + ], + }); + }); +}); diff --git a/packages/server/src/utils/string-command-shell.ts b/packages/server/src/utils/string-command-shell.ts new file mode 100644 index 000000000..7d773296b --- /dev/null +++ b/packages/server/src/utils/string-command-shell.ts @@ -0,0 +1,34 @@ +export interface BuildStringCommandShellInvocationOptions { + command: string; + platform?: NodeJS.Platform; +} + +export interface StringCommandShellInvocation { + shell: string; + args: string[]; +} + +export function buildStringCommandShellInvocation( + options: BuildStringCommandShellInvocationOptions, +): StringCommandShellInvocation { + const platform = options.platform ?? process.platform; + + if (platform === "win32") { + return { + shell: "powershell", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + options.command, + ], + }; + } + + return { + shell: "/bin/bash", + args: ["-lc", options.command], + }; +} diff --git a/packages/server/src/utils/worktree-shell-selection.test.ts b/packages/server/src/utils/worktree-shell-selection.test.ts new file mode 100644 index 000000000..bf9d0a41e --- /dev/null +++ b/packages/server/src/utils/worktree-shell-selection.test.ts @@ -0,0 +1,81 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const execFileMock = vi.hoisted(() => vi.fn()); + +vi.mock("child_process", async () => { + const actual = await vi.importActual("child_process"); + return { + ...actual, + execFile: execFileMock, + }; +}); + +describe("worktree shell selection", () => { + const originalPlatform = process.platform; + + beforeEach(() => { + execFileMock.mockReset(); + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback?: (error: Error | null, stdout: string, stderr: string) => void) => { + callback?.(null, "", ""); + return {}; + }, + ); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + vi.resetModules(); + }); + + it("routes teardown command execution through powershell on win32", async () => { + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + + const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-")); + try { + mkdirSync(join(worktreePath, ".git"), { recursive: true }); + writeFileSync( + join(worktreePath, "paseo.json"), + JSON.stringify({ + worktree: { + teardown: ["Write-Output 'teardown'"], + }, + }), + "utf8", + ); + + const { runWorktreeTeardownCommands } = await import("./worktree.js"); + await runWorktreeTeardownCommands({ + worktreePath, + repoRootPath: worktreePath, + branchName: "main", + }); + + expect(execFileMock).toHaveBeenCalledTimes(1); + expect(execFileMock).toHaveBeenCalledWith( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Write-Output 'teardown'", + ], + expect.objectContaining({ cwd: worktreePath }), + expect.any(Function), + ); + } finally { + rmSync(worktreePath, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 62572476f..c386514cc 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,4 +1,4 @@ -import { exec, spawn } from "child_process"; +import { exec, execFile, spawn } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; @@ -7,6 +7,7 @@ import { createHash } from "node:crypto"; import * as pty from "node-pty"; import { createNameId } from "mnemonic-id"; import stripAnsi from "strip-ansi"; +import { buildStringCommandShellInvocation } from "./string-command-shell.js"; import { normalizeBaseRefName, readPaseoWorktreeMetadata, @@ -27,6 +28,7 @@ interface PaseoConfig { } const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0", @@ -292,11 +294,11 @@ async function execSetupCommand( options: { cwd: string; env: NodeJS.ProcessEnv }, ): Promise { const startedAt = Date.now(); + const shellInvocation = buildStringCommandShellInvocation({ command }); try { - const { stdout, stderr } = await execAsync(command, { + const { stdout, stderr } = await execFileAsync(shellInvocation.shell, shellInvocation.args, { cwd: options.cwd, env: options.env, - shell: "/bin/bash", }); return { command, @@ -389,7 +391,8 @@ async function execSetupCommandStreamed(options: { }); const spawnWithPipes = () => { - const child = spawn("/bin/bash", ["-lc", options.command], { + const shellInvocation = buildStringCommandShellInvocation({ command: options.command }); + const child = spawn(shellInvocation.shell, shellInvocation.args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], @@ -415,7 +418,8 @@ async function execSetupCommandStreamed(options: { try { ensureNodePtySpawnHelperExecutableForCurrentPlatform(); - const terminal = pty.spawn("/bin/bash", ["-lc", options.command], { + const shellInvocation = buildStringCommandShellInvocation({ command: options.command }); + const terminal = pty.spawn(shellInvocation.shell, shellInvocation.args, { cwd: options.cwd, env: options.env, name: "xterm-color",