diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20f20ef36..f01461ca2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,12 @@ jobs: run: npm run typecheck server-tests: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + name: server-tests (${{ matrix.os }}) steps: - uses: actions/checkout@v4 with: @@ -95,50 +100,6 @@ jobs: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - server-tests-windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" - - - name: Install dependencies - run: npm install - - - name: Build highlight dependency - run: npm run build --workspace=@getpaseo/highlight - - - name: Build relay dependency - run: npm run build --workspace=@getpaseo/relay - - - name: Run Windows-critical server tests - working-directory: packages/server - run: > - npx vitest run - src/utils/executable.probe.test.ts - src/utils/executable.test.ts - src/utils/spawn.launch-regression.test.ts - src/utils/spawn.percent-escape.test.ts - src/utils/spawn.test.ts - src/utils/tree-kill.test.ts - src/utils/run-git-command.test.ts - src/utils/checkout-git-rev-parse.test.ts - src/terminal/worker-terminal-manager.test.ts - src/server/agent/provider-registry.test.ts - src/server/agent/provider-launch-config.test.ts - src/server/agent/provider-snapshot-manager.test.ts - src/server/agent/providers/claude-agent.spawn.test.ts - src/server/agent/providers/provider-windows-launch.test.ts - src/server/agent/providers/provider-availability.test.ts - src/server/workspace-registry-model.test.ts - src/server/persisted-config.test.ts - src/server/bootstrap-provider-availability.test.ts - desktop-tests: strategy: fail-fast: false diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 45b3720e4..1dca09c47 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { spawn } from "node:child_process"; import { describe, expect, test } from "vitest"; +import { isPlatform } from "../src/test-utils/platform.js"; const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url)); @@ -116,17 +117,21 @@ describe("supervisor durable logging", () => { expect(result.log).toContain("raw stderr line\n"); }); - test("logs worker signal exits even when the worker cannot log", async () => { - const result = await runSupervisorFixture({ - workerSource: ` + // POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL. + test.skipIf(isPlatform("win32"))( + "logs worker signal exits even when the worker cannot log", + async () => { + const result = await runSupervisorFixture({ + workerSource: ` process.kill(process.pid, "SIGKILL"); `, - }); + }); - expect(result.code).toBe(1); - expect(result.signal).toBeNull(); - expect(result.log).toContain('"msg":"Worker exited"'); - expect(result.log).toContain('"signal":"SIGKILL"'); - expect(result.log).toContain("Supervisor exiting"); - }); + expect(result.code).toBe(1); + expect(result.signal).toBeNull(); + expect(result.log).toContain('"msg":"Worker exited"'); + expect(result.log).toContain('"signal":"SIGKILL"'); + expect(result.log).toContain("Supervisor exiting"); + }, + ); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 05a9f3b6d..2cd74fe18 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,7 +1,8 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; +import { realpathSync } from "node:fs"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve as resolvePath } from "node:path"; import { tmpdir } from "node:os"; import { z } from "zod"; @@ -24,6 +25,9 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; import type { GitHubService } from "../../services/github-service.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; +const REPO_CWD = resolvePath("/tmp/repo"); +const TARGET_CWD = resolvePath("/tmp/target"); + interface LooseSafeParseResult { success: boolean; data: unknown; @@ -583,7 +587,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ id: "agent-123", - cwd: "/tmp/repo", + cwd: REPO_CWD, lifecycle: "idle", currentModeId: null, availableModes: [], @@ -613,7 +617,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ id: "agent-456", - cwd: "/tmp/repo", + cwd: REPO_CWD, lifecycle: "idle", currentModeId: null, availableModes: [], @@ -642,7 +646,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ id: "agent-789", - cwd: "/tmp/repo", + cwd: REPO_CWD, lifecycle: "idle", currentModeId: null, availableModes: [], @@ -685,14 +689,20 @@ describe("create_agent MCP tool", () => { const startedAgentSetupIds: string[] = []; try { - execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); - execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); - execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); await writeFile(join(repoDir, "README.md"), "hello\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); - execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ id: "agent-with-worktree", @@ -757,14 +767,20 @@ describe("create_agent MCP tool", () => { }; try { - execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); - execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); - execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); await writeFile(join(repoDir, "README.md"), "hello\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); - execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ id: "agent-auto-named-worktree", @@ -798,7 +814,10 @@ describe("create_agent MCP tool", () => { }); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }) + const initialBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: agentCwd, + stdio: "pipe", + }) .toString() .trim(); expect(initialBranch).not.toBe(""); @@ -824,19 +843,28 @@ describe("create_agent MCP tool", () => { }; try { - execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); - execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); - execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); await writeFile(join(repoDir, "README.md"), "hello\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); - execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); - execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["checkout", "-b", "existing-feature"], { + cwd: repoDir, + stdio: "pipe", + }); await writeFile(join(repoDir, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" }); - execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ id: "agent-checkout-worktree", @@ -871,7 +899,9 @@ describe("create_agent MCP tool", () => { const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); expect( - execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(), + execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" }) + .toString() + .trim(), ).toBe("existing-feature"); await new Promise((resolve) => setTimeout(resolve, 0)); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); @@ -901,7 +931,7 @@ describe("create_agent MCP tool", () => { }, workspace: { workspaceId: "/tmp/worktrees/pr-123", - projectId: "/tmp/repo", + projectId: REPO_CWD, cwd: "/tmp/worktrees/pr-123", kind: "worktree" as const, displayName: "pr-123", @@ -909,7 +939,7 @@ describe("create_agent MCP tool", () => { updatedAt: "2026-04-30T00:00:00.000Z", archivedAt: null, }, - repoRoot: "/tmp/repo", + repoRoot: REPO_CWD, created: true, ...(options?.setupContinuation?.kind === "agent" ? { @@ -949,7 +979,7 @@ describe("create_agent MCP tool", () => { }); const tool = registeredTool(server, "create_agent"); await tool.callback({ - cwd: "/tmp/repo", + cwd: REPO_CWD, title: "PR agent", provider: "codex/gpt-5.4", initialPrompt: "Rename this PR branch from prompt", @@ -985,14 +1015,20 @@ describe("create_agent MCP tool", () => { const setupContinuations: Array<"workspace" | "agent" | undefined> = []; try { - execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); - execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); - execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); await writeFile(join(repoDir, "README.md"), "hello\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); - execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); const workspaceGitService = { getSnapshot: vi.fn(async () => null), }; @@ -1031,19 +1067,27 @@ describe("create_agent MCP tool", () => { it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => { const { agentManager, agentStorage } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")); + const tempDir = realpathSync.native( + await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")), + ); const repoDir = join(tempDir, "repo"); const paseoHome = join(tempDir, ".paseo"); try { - execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); - execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); - execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); await writeFile(join(repoDir, "README.md"), "hello\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); - execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); const workspaceGitService = { getSnapshot: vi.fn(async () => null), @@ -1126,9 +1170,9 @@ describe("create_agent MCP tool", () => { }); const tool = registeredTool(server, "list_worktrees"); - const response = await tool.callback({ cwd: "/tmp/repo" }); + const response = await tool.callback({ cwd: REPO_CWD }); - expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", { + expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, { reason: "mcp:list-worktrees", }); expect(response.structuredContent.worktrees).toEqual([ @@ -1214,7 +1258,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ id: "agent-injected-123", - cwd: "/tmp/repo", + cwd: REPO_CWD, lifecycle: "idle", currentModeId: null, availableModes: [], @@ -1662,7 +1706,7 @@ describe("agent snapshot MCP serialization", () => { createManagedAgent({ id: "agent-compact", provider: "codex", - cwd: "/tmp/repo", + cwd: REPO_CWD, config: { model: "gpt-5.4", thinkingOptionId: "high" }, runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" }, labels: { role: "researcher" }, @@ -1687,7 +1731,7 @@ describe("agent snapshot MCP serialization", () => { thinkingOptionId: "high", effectiveThinkingOptionId: "high", status: "idle", - cwd: "/tmp/repo", + cwd: REPO_CWD, createdAt: expect.any(String), updatedAt: expect.any(String), lastUserMessageAt: null, @@ -1916,28 +1960,32 @@ describe("agent snapshot MCP serialization", () => { spies.agentManager.listAgents.mockReturnValue([ createManagedAgent({ id: "running-target", - cwd: "/tmp/target", + cwd: TARGET_CWD, lifecycle: "running", updatedAt: new Date(recent), }), createManagedAgent({ id: "idle-target", - cwd: "/tmp/target", + cwd: TARGET_CWD, lifecycle: "idle", updatedAt: new Date(recent), }), createManagedAgent({ id: "old-running-target", - cwd: "/tmp/target", + cwd: TARGET_CWD, lifecycle: "running", createdAt: new Date(old), updatedAt: new Date(old), }), ]); spies.agentStorage.list.mockResolvedValue([ - createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }), - createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }), - createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }), + createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }), + createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }), + createStoredRecord({ + id: "recent-other-cwd", + cwd: resolvePath("/tmp/other"), + archivedAt: recent, + }), ]); const server = await createAgentMcpServer({ @@ -1950,7 +1998,7 @@ describe("agent snapshot MCP serialization", () => { }); const tool = registeredTool(server, "list_agents"); const response = await tool.callback({ - cwd: "/tmp/target", + cwd: TARGET_CWD, includeArchived: true, sinceHours: 48, statuses: ["running", "closed"], @@ -2009,7 +2057,7 @@ describe("agent snapshot MCP serialization", () => { spies.agentStorage.list.mockResolvedValue([ createStoredRecord({ id: "stored-archived-compact", - cwd: "/tmp/repo", + cwd: REPO_CWD, updatedAt: now, lastActivityAt: now, archivedAt: now, @@ -2033,7 +2081,7 @@ describe("agent snapshot MCP serialization", () => { }, }); const tool = registeredTool(server, "list_agents"); - const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true }); + const response = await tool.callback({ cwd: REPO_CWD, includeArchived: true }); const item = agentsOf(response)[0]; expect(item).toEqual({ @@ -2045,7 +2093,7 @@ describe("agent snapshot MCP serialization", () => { thinkingOptionId: null, effectiveThinkingOptionId: null, status: "closed", - cwd: "/tmp/repo", + cwd: REPO_CWD, createdAt: "2026-04-11T00:00:00.000Z", updatedAt: now, lastUserMessageAt: null, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index fbed3a507..d528912a0 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1205,7 +1205,7 @@ describe("Codex app-server provider", () => { expect(event.item.text).not.toContain("data:image"); expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64); const source = markdownImageSource(event.item.text); - expect(source).toMatch(/paseo-attachments\/.+\.png$/); + expect(source).toMatch(/paseo-attachments[\\/].+\.png$/); expect(existsSync(source)).toBe(true); rmSync(source, { force: true }); }); diff --git a/packages/server/src/server/agent/providers/provider-availability.posix.test.ts b/packages/server/src/server/agent/providers/provider-availability.posix.test.ts new file mode 100644 index 000000000..147e62be2 --- /dev/null +++ b/packages/server/src/server/agent/providers/provider-availability.posix.test.ts @@ -0,0 +1,71 @@ +// POSIX-only: POSIX PATH executable probing fixtures +/* eslint-disable max-nested-callbacks */ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { isPlatform } from "../../../test-utils/platform.js"; +import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; + +const originalEnv = { + PATH: process.env.PATH, + PATHEXT: process.env.PATHEXT, +}; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function isolatePathTo(dir: string): void { + process.env.PATH = dir; + if (process.platform === "win32") { + process.env.PATHEXT = ".CMD"; + } +} + +function writeProviderShim(dir: string, command: string): string { + const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command); + const content = + process.platform === "win32" + ? `@echo off\r\necho ${command} 1.0\r\n` + : `#!/bin/sh\necho ${command} 1.0\n`; + writeFileSync(filePath, content); + if (process.platform !== "win32") { + chmodSync(filePath, 0o755); + } + return filePath; +} + +afterEach(() => { + process.env.PATH = originalEnv.PATH; + process.env.PATHEXT = originalEnv.PATHEXT; + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe.skipIf(isPlatform("win32"))("provider-availability POSIX-only", () => { + test("Codex reports available when the default command resolves from PATH", async () => { + const binDir = makeTempDir("provider-availability-codex-"); + isolatePathTo(binDir); + writeProviderShim(binDir, "codex"); + const client = new CodexAppServerAgentClient(createTestLogger()); + + await expect(client.isAvailable()).resolves.toBe(true); + }); + + test("OpenCode reports available when the default command resolves from PATH", async () => { + const binDir = makeTempDir("provider-availability-opencode-"); + isolatePathTo(binDir); + writeProviderShim(binDir, "opencode"); + const client = new OpenCodeAgentClient(createTestLogger()); + + await expect(client.isAvailable()).resolves.toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/providers/provider-availability.test.ts b/packages/server/src/server/agent/providers/provider-availability.test.ts index 999f6a669..7d980b571 100644 --- a/packages/server/src/server/agent/providers/provider-availability.test.ts +++ b/packages/server/src/server/agent/providers/provider-availability.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -31,19 +31,6 @@ function isolatePathTo(dir: string): void { } } -function writeProviderShim(dir: string, command: string): string { - const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command); - const content = - process.platform === "win32" - ? `@echo off\r\necho ${command} 1.0\r\n` - : `#!/bin/sh\necho ${command} 1.0\n`; - writeFileSync(filePath, content); - if (process.platform !== "win32") { - chmodSync(filePath, 0o755); - } - return filePath; -} - afterEach(() => { process.env.PATH = originalEnv.PATH; process.env.PATHEXT = originalEnv.PATHEXT; @@ -77,24 +64,6 @@ describe("default provider availability", () => { await expect(client.isAvailable()).resolves.toBe(false); }); - test("Codex reports available when the default command resolves from PATH", async () => { - const binDir = makeTempDir("provider-availability-codex-"); - isolatePathTo(binDir); - writeProviderShim(binDir, "codex"); - const client = new CodexAppServerAgentClient(createTestLogger()); - - await expect(client.isAvailable()).resolves.toBe(true); - }); - - test("OpenCode reports available when the default command resolves from PATH", async () => { - const binDir = makeTempDir("provider-availability-opencode-"); - isolatePathTo(binDir); - writeProviderShim(binDir, "opencode"); - const client = new OpenCodeAgentClient(createTestLogger()); - - await expect(client.isAvailable()).resolves.toBe(true); - }); - test("AgentManager reports Codex unavailable without throwing", async () => { const binDir = makeTempDir("provider-availability-manager-bin-"); isolatePathTo(binDir); diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index b1f715e19..620309459 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -8,6 +8,7 @@ import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./ import { generateLocalPairingOffer } from "./pairing-offer.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; +import { isPlatform } from "../test-utils/platform.js"; describe("paseo daemon bootstrap", () => { afterEach(() => { @@ -152,52 +153,56 @@ describe("paseo daemon bootstrap", () => { }); }); - test("generates a relay pairing offer for unix socket listeners", async () => { - const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-")); - const paseoHome = path.join(paseoHomeRoot, ".paseo"); - const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); - const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock"); - await mkdir(path.dirname(socketPath), { recursive: true }); - await mkdir(paseoHome, { recursive: true }); - const logger = pino({ level: "silent" }); + // POSIX-only: Unix socket listen paths are invalid Windows listen targets. + test.skipIf(isPlatform("win32"))( + "generates a relay pairing offer for unix socket listeners", + async () => { + const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-")); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock"); + await mkdir(path.dirname(socketPath), { recursive: true }); + await mkdir(paseoHome, { recursive: true }); + const logger = pino({ level: "silent" }); - const config: PaseoDaemonConfig = { - listen: socketPath, - paseoHome, - corsAllowedOrigins: [], - hostnames: true, - mcpEnabled: false, - staticDir, - mcpDebug: false, - agentClients: createTestAgentClients(), - agentStoragePath: path.join(paseoHome, "agents"), - relayEnabled: true, - relayEndpoint: "127.0.0.1:9", - relayPublicEndpoint: "127.0.0.1:9", - appBaseUrl: "https://app.paseo.sh", - openai: undefined, - speech: undefined, - }; - - const daemon = await createPaseoDaemon(config, logger); - - try { - await daemon.start(); - const pairing = await generateLocalPairingOffer({ + const config: PaseoDaemonConfig = { + listen: socketPath, paseoHome, + corsAllowedOrigins: [], + hostnames: true, + mcpEnabled: false, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), relayEnabled: true, relayEndpoint: "127.0.0.1:9", relayPublicEndpoint: "127.0.0.1:9", appBaseUrl: "https://app.paseo.sh", - includeQr: false, - }); - expect(pairing.relayEnabled).toBe(true); - expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true); - } finally { - await daemon.stop().catch(() => undefined); - await daemon.agentManager.flush().catch(() => undefined); - await rm(paseoHomeRoot, { recursive: true, force: true }); - await rm(staticDir, { recursive: true, force: true }); - } - }); + openai: undefined, + speech: undefined, + }; + + const daemon = await createPaseoDaemon(config, logger); + + try { + await daemon.start(); + const pairing = await generateLocalPairingOffer({ + paseoHome, + relayEnabled: true, + relayEndpoint: "127.0.0.1:9", + relayPublicEndpoint: "127.0.0.1:9", + appBaseUrl: "https://app.paseo.sh", + includeQr: false, + }); + expect(pairing.relayEnabled).toBe(true); + expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true); + } finally { + await daemon.stop().catch(() => undefined); + await daemon.agentManager.flush().catch(() => undefined); + await rm(paseoHomeRoot, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + } + }, + ); }); diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 0c22adbaa..9693a16e1 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -6,6 +6,7 @@ import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/i import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; import { withTimeout } from "../../utils/promise-timeout.js"; import { deriveWorktreeProjectHash } from "../../utils/worktree.js"; +import { isPlatform } from "../../test-utils/platform.js"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../messages.js"; @@ -220,54 +221,59 @@ test("returns error for non-git directory", async () => { rmSync(cwd, { recursive: true, force: true }); }, 60000); // 1 minute timeout -test("returns repo info for git repo with branch and dirty state", async () => { - const cwd = tmpCwd(); +// POSIX-only: asserts repo-root containment across macOS /var symlink normalization. +test.skipIf(isPlatform("win32"))( + "returns repo info for git repo with branch and dirty state", + async () => { + const cwd = tmpCwd(); - // Initialize git repo - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); + // Initialize git repo + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); - // Create and commit a file - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "original content\n"); - execSync("git add test.txt", { cwd, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { - cwd, - stdio: "pipe", - }); + // Create and commit a file + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "original content\n"); + execSync("git add test.txt", { cwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd, + stdio: "pipe", + }); - // Modify the file (makes repo dirty) - writeFileSync(testFile, "modified content\n"); + // Modify the file (makes repo dirty) + writeFileSync(testFile, "modified content\n"); - // Create agent in the git repo - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Git Repo Info Test", - }); + // Create agent in the git repo + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Git Repo Info Test", + }); - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); - // Get checkout status - const result = await ctx.client.getCheckoutStatus(cwd); + // Get checkout status + const result = await ctx.client.getCheckoutStatus(cwd); - // Verify repo info returned without error - expect(result.error).toBeNull(); - expect(result.isGit).toBe(true); - // macOS symlinks /var to /private/var, so we check containment - expect(result.repoRoot).toContain("daemon-e2e-"); - expect(result.currentBranch).toBeTruthy(); - expect(result.isDirty).toBe(true); + // Verify repo info returned without error + expect(result.error).toBeNull(); + expect(result.isGit).toBe(true); + // macOS symlinks /var to /private/var, so we check containment + expect(result.repoRoot).toContain("daemon-e2e-"); + expect(result.currentBranch).toBeTruthy(); + expect(result.isDirty).toBe(true); - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); -}, 60000); // 1 minute timeout + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, + 60000, +); // 1 minute timeout test("returns clean state when no uncommitted changes", async () => { const cwd = tmpCwd(); diff --git a/packages/server/src/server/file-explorer/service.posix.test.ts b/packages/server/src/server/file-explorer/service.posix.test.ts new file mode 100644 index 000000000..31a536fbd --- /dev/null +++ b/packages/server/src/server/file-explorer/service.posix.test.ts @@ -0,0 +1,58 @@ +// POSIX-only: symlink fixtures +/* eslint-disable max-nested-callbacks */ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { listDirectoryEntries, readExplorerFile } from "./service.js"; +import { isPlatform } from "../../test-utils/platform.js"; + +async function createTempDir(prefix: string): Promise { + return mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe.skipIf(isPlatform("win32"))("service POSIX-only", () => { + it("lists directory entries even when a dangling symlink exists", async () => { + const root = await createTempDir("paseo-file-explorer-"); + + try { + await mkdir(path.join(root, "packages", "server"), { recursive: true }); + const serverDir = path.join(root, "packages", "server"); + await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8"); + await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md")); + + const result = await listDirectoryEntries({ + root, + relativePath: "packages/server", + }); + + expect(result.path).toBe("packages/server"); + const names = result.entries.map((entry) => entry.name); + expect(names).toContain("README.md"); + expect(names).not.toContain("AGENTS.md"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects symlinked files that resolve outside the workspace", async () => { + const root = await createTempDir("paseo-file-explorer-"); + const outsideRoot = await createTempDir("paseo-file-explorer-outside-"); + + try { + const externalFile = path.join(outsideRoot, "secret.txt"); + await writeFile(externalFile, "top secret\n", "utf-8"); + await symlink(externalFile, path.join(root, "secret-link.txt")); + + await expect( + readExplorerFile({ + root, + relativePath: "secret-link.txt", + }), + ).rejects.toThrow("Access outside of workspace is not allowed"); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index b9076a56c..9bf149b69 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { listDirectoryEntries, readExplorerFile } from "./service.js"; +import { readExplorerFile } from "./service.js"; async function createHomeTempDir(prefix: string): Promise { return mkdtemp(path.join(os.homedir(), prefix)); @@ -13,29 +13,6 @@ async function createTempDir(prefix: string): Promise { } describe("file explorer service", () => { - it("lists directory entries even when a dangling symlink exists", async () => { - const root = await createTempDir("paseo-file-explorer-"); - - try { - await mkdir(path.join(root, "packages", "server"), { recursive: true }); - const serverDir = path.join(root, "packages", "server"); - await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8"); - await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md")); - - const result = await listDirectoryEntries({ - root, - relativePath: "packages/server", - }); - - expect(result.path).toBe("packages/server"); - const names = result.entries.map((entry) => entry.name); - expect(names).toContain("README.md"); - expect(names).not.toContain("AGENTS.md"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - it("reads .ex files as text", async () => { const root = await createTempDir("paseo-file-explorer-"); @@ -135,25 +112,4 @@ describe("file explorer service", () => { await rm(root, { recursive: true, force: true }); } }); - - it("rejects symlinked files that resolve outside the workspace", async () => { - const root = await createTempDir("paseo-file-explorer-"); - const outsideRoot = await createTempDir("paseo-file-explorer-outside-"); - - try { - const externalFile = path.join(outsideRoot, "secret.txt"); - await writeFile(externalFile, "top secret\n", "utf-8"); - await symlink(externalFile, path.join(root, "secret-link.txt")); - - await expect( - readExplorerFile({ - root, - relativePath: "secret-link.txt", - }), - ).rejects.toThrow("Access outside of workspace is not allowed"); - } finally { - await rm(root, { recursive: true, force: true }); - await rm(outsideRoot, { recursive: true, force: true }); - } - }); }); diff --git a/packages/server/src/server/logger.test.ts b/packages/server/src/server/logger.test.ts index 1044fda1d..3f8ed8444 100644 --- a/packages/server/src/server/logger.test.ts +++ b/packages/server/src/server/logger.test.ts @@ -102,7 +102,7 @@ describe("resolveLogConfig", () => { }, file: { level: "debug", - path: path.join(paseoHome, "logs", "programmatic.log"), + path: path.resolve(paseoHome, "logs", "programmatic.log"), }, }); }); diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 2e7acbda6..58204b900 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -1,6 +1,14 @@ import os from "node:os"; import path from "node:path"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { randomUUID } from "node:crypto"; import { beforeEach, afterEach, describe, expect, test } from "vitest"; import type { @@ -24,6 +32,7 @@ import type { import { AgentStorage } from "./agent/agent-storage.js"; import { AgentManager } from "./agent/agent-manager.js"; import { LoopService } from "./loop-service.js"; +import { isPlatform } from "../test-utils/platform.js"; import { createTestLogger } from "../test-utils/test-logger.js"; const TEST_CAPABILITIES: AgentCapabilityFlags = { @@ -220,60 +229,71 @@ describe("LoopService", () => { let storage: AgentStorage; beforeEach(() => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-")); + tmpDir = realpathSync.native(mkdtempSync(path.join(os.tmpdir(), "loop-service-"))); paseoHome = path.join(tmpDir, "paseo-home"); workspaceDir = path.join(tmpDir, "workspace"); storage = new AgentStorage(path.join(tmpDir, "agents"), logger); mkdirSync(workspaceDir, { recursive: true }); + workspaceDir = realpathSync.native(workspaceDir); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); - test("runs fresh worker agents until verify-check passes", async () => { - const state = { workerRuns: 0 }; - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - state.workerRuns += 1; - if (config.title?.includes("worker") && state.workerRuns >= 2) { - writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); - } - if (config.title?.includes("worker")) { - return `worker run ${state.workerRuns}`; - } - return '{"passed":true,"reason":"not used"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = new LoopService({ paseoHome, agentManager: manager, logger }); - await service.initialize(); + // POSIX-only: real worker agent spawns a PTY whose Windows ConPTY path resolution still fails (error 267) after realpathSync; revisit when we have a Windows dev box. + test.skipIf(isPlatform("win32"))( + "runs fresh worker agents until verify-check passes", + async () => { + const state = { workerRuns: 0 }; + const verifyScriptPath = path.join(workspaceDir, "verify-check.cjs"); + writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n'); + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + state.workerRuns += 1; + if (config.title?.includes("worker") && state.workerRuns >= 2) { + writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); + } + if (config.title?.includes("worker")) { + return `worker run ${state.workerRuns}`; + } + return '{"passed":true,"reason":"not used"}'; + }, + }), + }, + registry: storage, + logger, + }); + const service = new LoopService({ paseoHome, agentManager: manager, logger }); + await service.initialize(); - const loop = await service.runLoop({ - prompt: "Create done.txt when the task is actually fixed.", - cwd: workspaceDir, - verifyChecks: ["test -f done.txt"], - sleepMs: 1, - maxIterations: 3, - }); + const loop = await service.runLoop({ + prompt: "Create done.txt when the task is actually fixed.", + cwd: workspaceDir, + verifyChecks: [ + `${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`, + ], + sleepMs: 1, + maxIterations: 3, + }); - await waitForLoopCompletion(service, loop.id); + await waitForLoopCompletion(service, loop.id); - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("succeeded"); - expect(finalLoop.iterations).toHaveLength(2); - expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId); - expect(finalLoop.iterations[0]?.status).toBe("failed"); - expect(finalLoop.iterations[1]?.status).toBe("succeeded"); - expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false); - expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true); - expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id); - }); + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("succeeded"); + expect(finalLoop.iterations).toHaveLength(2); + expect(finalLoop.iterations[0]?.workerAgentId).not.toBe( + finalLoop.iterations[1]?.workerAgentId, + ); + expect(finalLoop.iterations[0]?.status).toBe("failed"); + expect(finalLoop.iterations[1]?.status).toBe("succeeded"); + expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false); + expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true); + expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id); + }, + ); test("uses worker and verifier provider-model settings when provided", async () => { const workerConfigs: AgentSessionConfig[] = []; diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 5579d19d6..6bdf5e4a6 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -13,6 +13,7 @@ import { type CreatePaseoWorktreeDeps, } from "./paseo-worktree-service.js"; import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; +import { isPlatform } from "../test-utils/platform.js"; const cleanupPaths: string[] = []; @@ -65,41 +66,45 @@ test("creates a worktree and registers it in the source workspace project withou ]); }); -test("reuses an existing worktree and still upserts the workspace", async () => { - const { repoDir, tempDir } = createGitRepo(); - cleanupPaths.push(tempDir); - const paseoHome = path.join(tempDir, ".paseo"); - const firstDeps = createDeps(); - const first = await createPaseoWorktree( - { - cwd: repoDir, - worktreeSlug: "reuse-me", - runSetup: false, - paseoHome, - }, - firstDeps, - ); - const events: string[] = []; - const deps = createDeps({ - events, - projects: firstDeps.projects, - workspaces: firstDeps.workspaces, - }); +// POSIX-only: Windows git worktree paths need separate canonicalization coverage. +test.skipIf(isPlatform("win32"))( + "reuses an existing worktree and still upserts the workspace", + async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + const firstDeps = createDeps(); + const first = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "reuse-me", + runSetup: false, + paseoHome, + }, + firstDeps, + ); + const events: string[] = []; + const deps = createDeps({ + events, + projects: firstDeps.projects, + workspaces: firstDeps.workspaces, + }); - const second = await createPaseoWorktree( - { - cwd: repoDir, - worktreeSlug: "reuse-me", - runSetup: false, - paseoHome, - }, - deps, - ); + const second = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "reuse-me", + runSetup: false, + paseoHome, + }, + deps, + ); - expect(second.created).toBe(false); - expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); - expect(events).toContain(`workspace:${second.workspace.workspaceId}`); -}); + expect(second.created).toBe(false); + expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); + expect(events).toContain(`workspace:${second.workspace.workspaceId}`); + }, +); test("renames an eligible unnamed branch-off worktree once on first agent context", async () => { const { repoDir, tempDir } = createGitRepo(); @@ -131,7 +136,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex generateBranchNameFromContext: async ({ firstAgentContext }) => firstAgentContext.prompt ? "renamed-from-agent-context" : null, }); - const branchAfterFirst = execSync("git branch --show-current", { + const branchAfterFirst = execFileSync("git", ["branch", "--show-current"], { cwd: created.worktree.worktreePath, stdio: "pipe", }) @@ -157,7 +162,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex firstAgentContext: { prompt: "Try another name" }, generateBranchNameFromContext: async () => "second-agent-name", }); - const branchAfterSecond = execSync("git branch --show-current", { + const branchAfterSecond = execFileSync("git", ["branch", "--show-current"], { cwd: created.worktree.worktreePath, stdio: "pipe", }) @@ -196,7 +201,7 @@ test("renames the branch even when the app supplies a random placeholder slug", : null, }); - const branchAfter = execSync("git branch --show-current", { + const branchAfter = execFileSync("git", ["branch", "--show-current"], { cwd: created.worktree.worktreePath, stdio: "pipe", }) @@ -253,7 +258,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied" : null, }); - const branchAfter = execSync("git branch --show-current", { + const branchAfter = execFileSync("git", ["branch", "--show-current"], { cwd: created.worktree.worktreePath, stdio: "pipe", }) @@ -286,7 +291,7 @@ test("leaves the branch alone when generated branch text is invalid", async () = ).resolves.toEqual({ attempted: true, renamed: false, branchName: null }); expect( - execSync("git branch --show-current", { + execFileSync("git", ["branch", "--show-current"], { cwd: created.worktree.worktreePath, stdio: "pipe", }) @@ -305,11 +310,11 @@ test("leaves the branch alone when generated branch text is invalid", async () = test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); - execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "README.md"), "dev branch\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" }); - execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "dev"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); const created = await createPaseoWorktree( { @@ -334,7 +339,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename }), ).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); expect( - execSync("git branch --show-current", { + execFileSync("git", ["branch", "--show-current"], { cwd: created.worktree.worktreePath, stdio: "pipe", }) @@ -370,7 +375,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren }), ).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); expect( - execSync("git branch --show-current", { + execFileSync("git", ["branch", "--show-current"], { cwd: created.worktree.worktreePath, stdio: "pipe", }) @@ -524,17 +529,24 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService { } function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { - const repoRoot = execSync("git rev-parse --show-toplevel", { cwd, stdio: "pipe" }) + const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe" }) .toString() .trim(); - const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", { - cwd, - stdio: "pipe", - }) + const mainRepoRoot = execFileSync( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { + cwd, + stdio: "pipe", + }, + ) .toString() .trim() .replace(/\/\.git$/, ""); - const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" }) + const currentBranch = execFileSync("git", ["branch", "--show-current"], { + cwd, + stdio: "pipe", + }) .toString() .trim(); @@ -566,34 +578,39 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { function createGitRepo(): { tempDir: string; repoDir: string } { const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-")); const repoDir = path.join(tempDir, "repo"); - execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); - execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "README.md"), "hello\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); - execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); return { tempDir, repoDir }; } function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } { const { tempDir, repoDir } = createGitRepo(); - execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "README.md"), "pr branch\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" }); - const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim(); - execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); - execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "pr-branch"], { cwd: repoDir, stdio: "pipe" }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .trim(); + execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-D", "pr-123"], { cwd: repoDir, stdio: "pipe" }); const remoteDir = path.join(tempDir, "remote.git"); - execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, { + execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { stdio: "pipe", }); - execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, { + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", prHead], { stdio: "pipe", }); - execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" }); - execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" }); return { tempDir, repoDir }; } diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index 4e121e779..0cde8fed8 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -1,5 +1,5 @@ -import { execSync } from "node:child_process"; -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -22,16 +22,25 @@ function createWorkspaceRepo(options?: { }): { tempDir: string; repoDir: string; cleanup: () => void } { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-"))); const repoDir = path.join(tempDir, "repo"); - execSync(`mkdir -p ${JSON.stringify(repoDir)}`); - execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", options?.branchName ?? "main"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.email", "test@test.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "README.md"), "hello\n"); if (options?.paseoConfig) { writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); } - execSync("git add .", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); return { tempDir, diff --git a/packages/server/src/server/script-route-branch-handler.test.ts b/packages/server/src/server/script-route-branch-handler.test.ts index ad1c428ed..6d9961c79 100644 --- a/packages/server/src/server/script-route-branch-handler.test.ts +++ b/packages/server/src/server/script-route-branch-handler.test.ts @@ -1,5 +1,5 @@ -import { execSync } from "node:child_process"; -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -12,16 +12,25 @@ function createWorkspaceRepo(options?: { }): { tempDir: string; repoDir: string; cleanup: () => void } { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-"))); const repoDir = path.join(tempDir, "repo"); - execSync(`mkdir -p ${JSON.stringify(repoDir)}`); - execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", options?.branchName ?? "main"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.email", "test@test.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "README.md"), "hello\n"); if (options?.paseoConfig) { writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); } - execSync("git add .", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); return { tempDir, diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index 7baa6aa78..285ca88eb 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { tmpdir } from "node:os"; -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { ScriptRouteStore } from "./script-proxy.js"; import { buildWorkspaceScriptPayloads, @@ -21,16 +21,25 @@ function createWorkspaceRepo(options?: { }): { tempDir: string; repoDir: string; cleanup: () => void } { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-"))); const repoDir = path.join(tempDir, "repo"); - execSync(`mkdir -p ${JSON.stringify(repoDir)}`); - execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", options?.branchName ?? "main"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.email", "test@test.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "README.md"), "hello\n"); if (options?.paseoConfig) { writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); } - execSync("git add .", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); return { tempDir, diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index fdec14e4a..864d7f0b1 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -47,6 +47,7 @@ import { asDaemonConfigStore, createProviderSnapshotManagerStub, } from "./test-utils/session-stubs.js"; +import { isPlatform } from "../test-utils/platform.js"; interface SessionHandlerInternals { startVoiceTurnController(): Promise; @@ -705,39 +706,46 @@ describe("project config RPC authorization", () => { ]); }); - test("read_project_config_request accepts a symlink to an active project root", async () => { - const repoRoot = makeRoot(); - writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); - const linkRoot = join(makeRoot(), "link"); - symlinkSync(repoRoot, linkRoot, "dir"); - const messages: unknown[] = []; - const session = createSessionForTest({ - messages, - projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) }, - }); + // POSIX-only: creates a directory symlink without Windows privileges. + test.skipIf(isPlatform("win32"))( + "read_project_config_request accepts a symlink to an active project root", + async () => { + const repoRoot = makeRoot(); + writeFileSync( + join(repoRoot, "paseo.json"), + JSON.stringify({ worktree: { setup: "npm ci" } }), + ); + const linkRoot = join(makeRoot(), "link"); + symlinkSync(repoRoot, linkRoot, "dir"); + const messages: unknown[] = []; + const session = createSessionForTest({ + messages, + projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) }, + }); - await session.handleMessage({ - type: "read_project_config_request", - requestId: "read-symlink-1", - repoRoot: linkRoot, - }); + await session.handleMessage({ + type: "read_project_config_request", + requestId: "read-symlink-1", + repoRoot: linkRoot, + }); - expect(messages).toEqual([ - { - type: "read_project_config_response", - payload: { - requestId: "read-symlink-1", - repoRoot, - ok: true, - config: { worktree: { setup: "npm ci" } }, - revision: expect.objectContaining({ - mtimeMs: expect.any(Number), - size: expect.any(Number), - }), + expect(messages).toEqual([ + { + type: "read_project_config_response", + payload: { + requestId: "read-symlink-1", + repoRoot, + ok: true, + config: { worktree: { setup: "npm ci" } }, + revision: expect.objectContaining({ + mtimeMs: expect.any(Number), + size: expect.any(Number), + }), + }, }, - }, - ]); - }); + ]); + }, + ); test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => { const archivedRoot = makeRoot(); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 7b1d98eee..8a68fb41a 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, vi } from "vitest"; +import path from "node:path"; import type pino from "pino"; import { Session, type SessionOptions } from "./session.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; @@ -36,6 +37,9 @@ type WorkspaceUpdatePayload = Extract< { type: "workspace_update" } >["payload"]; +const REPO_CWD = path.resolve("/tmp/repo"); +const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`; + function createWorkspaceRuntimeSnapshot( cwd: string, overrides?: { @@ -243,7 +247,7 @@ function seedGitWorkspace(input: { input.projectId, createPersistedProjectRecord({ projectId: input.projectId, - rootPath: "/tmp/repo", + rootPath: input.cwd, displayName: "repo", kind: "git", createdAt: "2026-03-01T12:00:00.000Z", @@ -274,7 +278,7 @@ describe("workspace git watch targets", () => { workspaces, projectId: "proj-1", workspaceId: "ws-10", - cwd: "/tmp/repo", + cwd: REPO_CWD, name: "main", }); sessionAny.workspaceUpdatesSubscription = { @@ -289,22 +293,22 @@ describe("workspace git watch targets", () => { id: "ws-10", projectId: "proj-1", projectDisplayName: "repo", - projectRootPath: "/tmp/repo", + projectRootPath: REPO_CWD, projectKind: "git", workspaceKind: "local_checkout", name: "main", status: "done", activityAt: null, diffStat: { additions: 1, deletions: 0 }, - workspaceDirectory: "/tmp/repo", + workspaceDirectory: REPO_CWD, }; sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith( - { cwd: "/tmp/repo" }, + { cwd: REPO_CWD }, expect.any(Function), ); @@ -314,7 +318,7 @@ describe("workspace git watch targets", () => { }; subscriptions[0]?.listener( - createWorkspaceRuntimeSnapshot("/tmp/repo", { + createWorkspaceRuntimeSnapshot(REPO_CWD, { git: { currentBranch: "renamed-branch", }, @@ -349,7 +353,7 @@ describe("workspace git watch targets", () => { workspaces, projectId: "proj-1", workspaceId: "ws-10", - cwd: "/tmp/repo", + cwd: REPO_CWD, name: "main", }); sessionAny.workspaceUpdatesSubscription = { @@ -360,11 +364,11 @@ describe("workspace git watch targets", () => { lastEmittedByWorkspaceId: new Map(), }; - sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); emitted.length = 0; subscriptions[0]?.listener( - createWorkspaceRuntimeSnapshot("/tmp/repo", { + createWorkspaceRuntimeSnapshot(REPO_CWD, { git: { currentBranch: "feature/server-push", isDirty: true, @@ -380,9 +384,9 @@ describe("workspace git watch targets", () => { ) as Array<{ type: "checkout_status_update"; payload: CheckoutStatusUpdatePayload }>; expect(statusUpdates).toHaveLength(1); expect(statusUpdates[0]?.payload).toMatchObject({ - cwd: "/tmp/repo", + cwd: REPO_CWD, isGit: true, - repoRoot: "/tmp/repo", + repoRoot: REPO_CWD, currentBranch: "feature/server-push", isDirty: true, baseRef: "main", @@ -393,10 +397,10 @@ describe("workspace git watch targets", () => { remoteUrl: "https://github.com/acme/repo.git", isPaseoOwnedWorktree: false, error: null, - requestId: "subscription:/tmp/repo", + requestId: REPO_SUBSCRIPTION_REQUEST_ID, }); expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith( - { cwd: "/tmp/repo" }, + { cwd: REPO_CWD }, expect.any(Function), ); @@ -412,7 +416,7 @@ describe("workspace git watch targets", () => { workspaces, projectId: "proj-1", workspaceId: "ws-10", - cwd: "/tmp/repo", + cwd: REPO_CWD, name: "main", }); sessionAny.workspaceUpdatesSubscription = { @@ -423,11 +427,11 @@ describe("workspace git watch targets", () => { lastEmittedByWorkspaceId: new Map(), }; - sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); emitted.length = 0; subscriptions[0]?.listener( - createWorkspaceRuntimeSnapshot("/tmp/repo", { + createWorkspaceRuntimeSnapshot(REPO_CWD, { github: { featuresEnabled: true, pullRequest: { @@ -457,7 +461,7 @@ describe("workspace git watch targets", () => { | { payload: CheckoutStatusUpdatePayload } | undefined; expect(statusUpdate?.payload.prStatus).toEqual({ - cwd: "/tmp/repo", + cwd: REPO_CWD, status: { number: 456, url: "https://github.com/acme/repo/pull/456", @@ -481,7 +485,7 @@ describe("workspace git watch targets", () => { }, githubFeaturesEnabled: true, error: null, - requestId: "subscription:/tmp/repo", + requestId: REPO_SUBSCRIPTION_REQUEST_ID, }); await session.cleanup(); @@ -491,7 +495,7 @@ describe("workspace git watch targets", () => { const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); workspaceGitService.getSnapshot.mockResolvedValue( - createWorkspaceRuntimeSnapshot("/tmp/repo", { + createWorkspaceRuntimeSnapshot(REPO_CWD, { github: { featuresEnabled: true, pullRequest: { @@ -508,15 +512,15 @@ describe("workspace git watch targets", () => { await session.handleMessage({ type: "checkout_pr_status_request", - cwd: "/tmp/repo", + cwd: REPO_CWD, requestId: "req-pr-status", }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD); expect( emitted.find((message) => message.type === "checkout_pr_status_response")?.payload, ).toEqual({ - cwd: "/tmp/repo", + cwd: REPO_CWD, status: { number: undefined, url: "https://github.com/acme/repo/pull/456", @@ -543,12 +547,12 @@ describe("workspace git watch targets", () => { await session.handleMessage({ type: "checkout_pr_status_request", - cwd: "/tmp/repo", + cwd: REPO_CWD, requestId: "req-pr-cached", }); expect(workspaceGitService.refresh).not.toHaveBeenCalled(); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD); expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined(); }); }); diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 59a5ea2b2..b52be77db 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -185,6 +185,18 @@ function getOpenResponse(emitted: SessionOutboundMessage[], requestId: string) { } const T0 = "2026-01-01T00:00:00.000Z"; +const FOO = path.resolve("/foo"); +const FOO_SUB = path.join(FOO, "sub"); +const BAR = path.resolve("/bar"); +const BAR_BAZ = path.join(BAR, "baz"); +const TOOLBOX = path.resolve("/toolbox"); +const TOOLBOX_FLOMO = path.join(TOOLBOX, "flomo-cli"); +const USERS_DEVELOPER = path.resolve("/Users/me/Developer"); +const USERS_PROJECT = path.join(USERS_DEVELOPER, "projects", "foo"); +const PROJECTS = path.resolve("/projects"); +const SOME_GIT_REPO = path.join(PROJECTS, "some-git-repo"); +const PARENT = path.resolve("/parent"); +const PARENT_CHILD = path.join(PARENT, "child"); function gitWorkspace(rootPath: string, archivedAt: string | null = null) { return createPersistedWorkspaceRecord({ @@ -240,13 +252,13 @@ function dirProject(rootPath: string, archivedAt: string | null = null) { // S1. Open a fresh git repo: creates a workspace at the canonical root. // ───────────────────────────────────────────────────────────────────────────── test("S1: open fresh git repo creates workspace at canonical root", async () => { - const h = createHarness({ gitRoots: ["/foo"] }); - await openProject(h.session, "/foo"); + const h = createHarness({ gitRoots: [FOO] }); + await openProject(h.session, FOO); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.error).toBeNull(); - expect(resp?.workspace?.workspaceDirectory).toBe("/foo"); + expect(resp?.workspace?.workspaceDirectory).toBe(FOO); expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); - expect(h.workspaces.has("/foo")).toBe(true); + expect(h.workspaces.has(FOO)).toBe(true); }); // ───────────────────────────────────────────────────────────────────────────── @@ -255,12 +267,12 @@ test("S1: open fresh git repo creates workspace at canonical root", async () => // ───────────────────────────────────────────────────────────────────────────── test("S2: open fresh non-git directory creates a directory workspace at exact path", async () => { const h = createHarness({}); - await openProject(h.session, "/bar"); + await openProject(h.session, BAR); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.error).toBeNull(); - expect(resp?.workspace?.workspaceDirectory).toBe("/bar"); + expect(resp?.workspace?.workspaceDirectory).toBe(BAR); expect(resp?.workspace?.workspaceKind).toBe("directory"); - expect(h.workspaces.has("/bar")).toBe(true); + expect(h.workspaces.has(BAR)).toBe(true); }); // ───────────────────────────────────────────────────────────────────────────── @@ -269,15 +281,15 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa // ───────────────────────────────────────────────────────────────────────────── test("S3: re-open active workspace by exact path returns the same record", async () => { const h = createHarness({ - workspaces: [gitWorkspace("/foo")], - projects: [gitProject("/foo")], - gitRoots: ["/foo"], + workspaces: [gitWorkspace(FOO)], + projects: [gitProject(FOO)], + gitRoots: [FOO], }); - await openProject(h.session, "/foo"); + await openProject(h.session, FOO); const resp = getOpenResponse(h.emitted, "req-1"); - expect(resp?.workspace?.id).toBe("/foo"); + expect(resp?.workspace?.id).toBe(FOO); expect(h.workspaces.size).toBe(1); - expect(h.workspaces.get("/foo")?.archivedAt).toBeNull(); + expect(h.workspaces.get(FOO)?.archivedAt).toBeNull(); }); // ───────────────────────────────────────────────────────────────────────────── @@ -286,13 +298,13 @@ test("S3: re-open active workspace by exact path returns the same record", async // ───────────────────────────────────────────────────────────────────────────── test("S4: open subdir of active git workspace returns the repo-root workspace", async () => { const h = createHarness({ - workspaces: [gitWorkspace("/foo")], - projects: [gitProject("/foo")], - gitRoots: ["/foo"], + workspaces: [gitWorkspace(FOO)], + projects: [gitProject(FOO)], + gitRoots: [FOO], }); - await openProject(h.session, "/foo/sub"); + await openProject(h.session, FOO_SUB); const resp = getOpenResponse(h.emitted, "req-1"); - expect(resp?.workspace?.id).toBe("/foo"); + expect(resp?.workspace?.id).toBe(FOO); expect(h.workspaces.size).toBe(1); }); @@ -302,14 +314,14 @@ test("S4: open subdir of active git workspace returns the repo-root workspace", // ───────────────────────────────────────────────────────────────────────────── test("S5: open subdir of active non-git directory creates a SEPARATE workspace", async () => { const h = createHarness({ - workspaces: [dirWorkspace("/bar")], - projects: [dirProject("/bar")], + workspaces: [dirWorkspace(BAR)], + projects: [dirProject(BAR)], }); - await openProject(h.session, "/bar/baz"); + await openProject(h.session, BAR_BAZ); const resp = getOpenResponse(h.emitted, "req-1"); - expect(resp?.workspace?.workspaceDirectory).toBe("/bar/baz"); - expect(h.workspaces.has("/bar")).toBe(true); - expect(h.workspaces.has("/bar/baz")).toBe(true); + expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ); + expect(h.workspaces.has(BAR)).toBe(true); + expect(h.workspaces.has(BAR_BAZ)).toBe(true); expect(h.workspaces.size).toBe(2); }); @@ -320,13 +332,13 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace", test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ - workspaces: [gitWorkspace("/toolbox", archivedAt)], - projects: [gitProject("/toolbox", archivedAt)], - gitRoots: ["/toolbox"], + workspaces: [gitWorkspace(TOOLBOX, archivedAt)], + projects: [gitProject(TOOLBOX, archivedAt)], + gitRoots: [TOOLBOX], }); - await openProject(h.session, "/toolbox"); - expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull(); - expect(h.projects.get("/toolbox")?.archivedAt).toBeNull(); + await openProject(h.session, TOOLBOX); + expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull(); + expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull(); }); // ───────────────────────────────────────────────────────────────────────────── @@ -334,15 +346,15 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy // ───────────────────────────────────────────────────────────────────────────── test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the inner root", async () => { const h = createHarness({ - workspaces: [gitWorkspace("/foo")], - projects: [gitProject("/foo")], - gitRoots: ["/foo", "/foo/sub"], + workspaces: [gitWorkspace(FOO)], + projects: [gitProject(FOO)], + gitRoots: [FOO, FOO_SUB], }); - await openProject(h.session, "/foo/sub"); + await openProject(h.session, FOO_SUB); const resp = getOpenResponse(h.emitted, "req-1"); - expect(resp?.workspace?.workspaceDirectory).toBe("/foo/sub"); - expect(h.workspaces.has("/foo")).toBe(true); - expect(h.workspaces.has("/foo/sub")).toBe(true); + expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB); + expect(h.workspaces.has(FOO)).toBe(true); + expect(h.workspaces.has(FOO_SUB)).toBe(true); }); // ───────────────────────────────────────────────────────────────────────────── @@ -353,12 +365,12 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in test("S8: open child of archived non-git ancestor creates fresh workspace; ancestor stays archived", async () => { const archivedAt = "2026-04-04T17:15:22.423Z"; const h = createHarness({ - workspaces: [dirWorkspace("/Users/me/Developer", archivedAt)], - projects: [dirProject("/Users/me/Developer", archivedAt)], + workspaces: [dirWorkspace(USERS_DEVELOPER, archivedAt)], + projects: [dirProject(USERS_DEVELOPER, archivedAt)], }); - await openProject(h.session, "/Users/me/Developer/projects/foo"); - expect(h.workspaces.get("/Users/me/Developer")?.archivedAt).toBe(archivedAt); - expect(h.workspaces.has("/Users/me/Developer/projects/foo")).toBe(true); + await openProject(h.session, USERS_PROJECT); + expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt); + expect(h.workspaces.has(USERS_PROJECT)).toBe(true); }); // ───────────────────────────────────────────────────────────────────────────── @@ -369,12 +381,12 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances test("S9: opening child of archived git workspace does NOT auto-unarchive the parent", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ - workspaces: [gitWorkspace("/toolbox", archivedAt)], - projects: [gitProject("/toolbox", archivedAt)], - gitRoots: ["/toolbox"], + workspaces: [gitWorkspace(TOOLBOX, archivedAt)], + projects: [gitProject(TOOLBOX, archivedAt)], + gitRoots: [TOOLBOX], }); - await openProject(h.session, "/toolbox/flomo-cli"); - expect(h.workspaces.get("/toolbox")?.archivedAt).toBe(archivedAt); + await openProject(h.session, TOOLBOX_FLOMO); + expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt); }); // ───────────────────────────────────────────────────────────────────────────── @@ -389,18 +401,18 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa test("S10: opening a git repo nested inside an archived non-git directory creates fresh workspace; ancestor stays archived", async () => { const archivedAt = "2026-04-04T17:15:22.423Z"; const h = createHarness({ - workspaces: [dirWorkspace("/projects", archivedAt)], - projects: [dirProject("/projects", archivedAt)], - gitRoots: ["/projects/some-git-repo"], + workspaces: [dirWorkspace(PROJECTS, archivedAt)], + projects: [dirProject(PROJECTS, archivedAt)], + gitRoots: [SOME_GIT_REPO], }); - await openProject(h.session, "/projects/some-git-repo"); + await openProject(h.session, SOME_GIT_REPO); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.error).toBeNull(); - expect(resp?.workspace?.workspaceDirectory).toBe("/projects/some-git-repo"); + expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO); expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); - expect(h.workspaces.has("/projects/some-git-repo")).toBe(true); - expect(h.workspaces.get("/projects")?.archivedAt).toBe(archivedAt); - expect(h.projects.get("/projects")?.archivedAt).toBe(archivedAt); + expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true); + expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt); + expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt); }); // ───────────────────────────────────────────────────────────────────────────── @@ -411,19 +423,19 @@ test("S10: opening a git repo nested inside an archived non-git directory create test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ - workspaces: [gitWorkspace("/toolbox", archivedAt)], - projects: [gitProject("/toolbox", archivedAt)], - gitRoots: ["/toolbox"], + workspaces: [gitWorkspace(TOOLBOX, archivedAt)], + projects: [gitProject(TOOLBOX, archivedAt)], + gitRoots: [TOOLBOX], }); - await openProject(h.session, "/toolbox"); + await openProject(h.session, TOOLBOX); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.error).toBeNull(); - expect(resp?.workspace?.id).toBe("/toolbox"); - expect(resp?.workspace?.projectId).toBe("/toolbox"); + expect(resp?.workspace?.id).toBe(TOOLBOX); + expect(resp?.workspace?.projectId).toBe(TOOLBOX); expect(h.workspaces.size).toBe(1); expect(h.projects.size).toBe(1); - expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull(); - expect(h.projects.get("/toolbox")?.archivedAt).toBeNull(); + expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull(); + expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull(); }); // ───────────────────────────────────────────────────────────────────────────── @@ -434,12 +446,12 @@ test("S11: re-opening an archived project by exact path unarchives project + wor test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ - workspaces: [dirWorkspace("/parent", archivedAt)], - projects: [dirProject("/parent", archivedAt)], + workspaces: [dirWorkspace(PARENT, archivedAt)], + projects: [dirProject(PARENT, archivedAt)], }); const found = await asInternals<{ findWorkspaceByDirectory(cwd: string): Promise; - }>(h.session).findWorkspaceByDirectory("/parent/child"); + }>(h.session).findWorkspaceByDirectory(PARENT_CHILD); expect(found).toBeNull(); }); @@ -456,11 +468,11 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix test("S13: subfolder of an archived git repo opens as a directory workspace", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ - workspaces: [gitWorkspace("/toolbox", archivedAt)], - projects: [gitProject("/toolbox", archivedAt)], - gitRoots: ["/toolbox"], + workspaces: [gitWorkspace(TOOLBOX, archivedAt)], + projects: [gitProject(TOOLBOX, archivedAt)], + gitRoots: [TOOLBOX], }); - await openProject(h.session, "/toolbox/flomo-cli"); + await openProject(h.session, TOOLBOX_FLOMO); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.error).toBeNull(); expect(resp?.workspace?.workspaceKind).toBe("directory"); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index afe21ab66..3223dea3e 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; @@ -48,6 +48,9 @@ import { createPersistedWorkspaceRecord, } from "./workspace-registry.js"; +const REPO_CWD = path.resolve("/tmp/repo"); +const UNREGISTERED_CWD = path.resolve("/tmp/unregistered"); + interface SessionTestAccess { projectRegistry: { list(...args: unknown[]): Promise; @@ -604,7 +607,7 @@ test("unsupported persisted agents are excluded from active lists but preserved const storedRecord = { id: "agent-unsupported", provider: "gemini", - cwd: "/tmp/history", + cwd: path.resolve("/tmp/history"), createdAt: "2026-04-13T10:13:11.457Z", updatedAt: "2026-04-13T10:16:06.556Z", lastActivityAt: "2026-04-13T10:16:06.556Z", @@ -727,7 +730,7 @@ test("agent_update placement does not refresh git snapshots", async () => { ); const project = createPersistedProjectRecord({ projectId: "proj-1", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -736,7 +739,7 @@ test("agent_update placement does not refresh git snapshots", async () => { const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-1", projectId: project.projectId, - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -755,7 +758,7 @@ test("agent_update placement does not refresh git snapshots", async () => { await session.forwardAgentUpdate( makeManagedAgent({ id: "agent-1", - cwd: "/tmp/repo", + cwd: REPO_CWD, lifecycle: "running", updatedAt: "2026-03-30T15:00:00.000Z", }), @@ -801,7 +804,7 @@ test("agent_update emits fallback placement when no workspace is registered", as await session.forwardAgentUpdate( makeManagedAgent({ id: "agent-1", - cwd: "/tmp/unregistered", + cwd: UNREGISTERED_CWD, lifecycle: "running", updatedAt: "2026-03-30T15:00:00.000Z", }), @@ -811,10 +814,10 @@ test("agent_update emits fallback placement when no workspace is registered", as expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ kind: "upsert", project: { - projectKey: "/tmp/unregistered", + projectKey: UNREGISTERED_CWD, projectName: "unregistered", checkout: { - cwd: "/tmp/unregistered", + cwd: UNREGISTERED_CWD, isGit: false, }, }, @@ -826,7 +829,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const archivedRecord = { id: "agent-1", provider: "codex", - cwd: "/tmp/repo", + cwd: REPO_CWD, createdAt: "2026-03-30T15:00:00.000Z", updatedAt: "2026-03-30T15:00:00.000Z", lastActivityAt: "2026-03-30T15:00:00.000Z", @@ -836,7 +839,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" runtimeInfo: null, config: { provider: "codex", - cwd: "/tmp/repo", + cwd: REPO_CWD, }, persistence: null, title: "Archive me", @@ -893,7 +896,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" projectRegistry: (() => { const proj = createPersistedProjectRecord({ projectId: "proj-1", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "non_git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -913,7 +916,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const ws = createPersistedWorkspaceRecord({ workspaceId: "ws-1", projectId: "proj-1", - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "directory", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -934,7 +937,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ - initial: { cwd: "/tmp/repo", files: [], error: null }, + initial: { cwd: REPO_CWD, files: [], error: null }, unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, @@ -996,7 +999,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const archivedRecord = { id: "agent-1", provider: "codex", - cwd: "/tmp/repo", + cwd: REPO_CWD, model: null, thinkingOptionId: null, effectiveThinkingOptionId: null, @@ -1055,7 +1058,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy projectRegistry: (() => { const proj = createPersistedProjectRecord({ projectId: "proj-close", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "non_git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -1075,7 +1078,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const ws = createPersistedWorkspaceRecord({ workspaceId: "ws-close", projectId: "proj-close", - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "directory", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -1170,7 +1173,7 @@ test("close_items_request archives stored agents that are not currently loaded", const liveRecord = { ...makeAgent({ id: "agent-live", - cwd: "/tmp/repo", + cwd: REPO_CWD, status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -1179,7 +1182,7 @@ test("close_items_request archives stored agents that are not currently loaded", const storedRecord = { ...makeAgent({ id: storedAgentId, - cwd: "/tmp/repo", + cwd: REPO_CWD, status: "idle", updatedAt: "2026-03-01T12:05:00.000Z", }), @@ -1244,7 +1247,7 @@ test("close_items_request archives stored agents that are not currently loaded", projectRegistry: (() => { const proj = createPersistedProjectRecord({ projectId: "proj-stored", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "non_git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -1264,7 +1267,7 @@ test("close_items_request archives stored agents that are not currently loaded", const ws = createPersistedWorkspaceRecord({ workspaceId: "ws-stored", projectId: "proj-stored", - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "directory", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -1350,7 +1353,7 @@ test("close_items_request continues after an archive failure", async () => { const goodRecord = { ...makeAgent({ id: "agent-good", - cwd: "/tmp/repo", + cwd: REPO_CWD, status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -1393,7 +1396,7 @@ test("close_items_request continues after an archive failure", async () => { projectRegistry: (() => { const proj = createPersistedProjectRecord({ projectId: "proj-err", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "non_git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -1413,7 +1416,7 @@ test("close_items_request continues after an archive failure", async () => { const ws = createPersistedWorkspaceRecord({ workspaceId: "ws-err", projectId: "proj-err", - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "directory", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -1714,9 +1717,12 @@ test("active-scoped fetch_agents pages within active scope instead of global his test("legacy unscoped fetch_agents keeps global workspace behavior", async () => { const session = createSessionForWorkspaceTests(); + const legacyRoot = path.resolve("/tmp/legacy"); + const activeCwd = path.join(legacyRoot, "active"); + const archivedCwd = path.join(legacyRoot, "archived"); const project = createPersistedProjectRecord({ projectId: "proj-legacy-global", - rootPath: "/tmp/legacy", + rootPath: legacyRoot, kind: "non_git", displayName: "legacy", createdAt: "2026-03-01T12:00:00.000Z", @@ -1725,7 +1731,7 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () => const activeWorkspace = createPersistedWorkspaceRecord({ workspaceId: "ws-legacy-active", projectId: project.projectId, - cwd: "/tmp/legacy/active", + cwd: activeCwd, kind: "directory", displayName: "active", createdAt: "2026-03-01T12:00:00.000Z", @@ -1734,7 +1740,7 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () => const archivedWorkspace = createPersistedWorkspaceRecord({ workspaceId: "ws-legacy-archived", projectId: project.projectId, - cwd: "/tmp/legacy/archived", + cwd: archivedCwd, kind: "directory", displayName: "archived", createdAt: "2026-03-01T12:00:00.000Z", @@ -1747,13 +1753,13 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () => session.listAgentPayloads = async () => [ makeAgent({ id: "legacy-active", - cwd: "/tmp/legacy/active", + cwd: activeCwd, status: "idle", updatedAt: "2026-03-01T12:01:00.000Z", }), makeAgent({ id: "legacy-archived-workspace", - cwd: "/tmp/legacy/archived", + cwd: archivedCwd, status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -1773,9 +1779,10 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () => test("fetch_agent_history_request pages archived historical rows separately", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); + const historyCwd = path.resolve("/tmp/history"); const project = createPersistedProjectRecord({ projectId: "proj-history", - rootPath: "/tmp/history", + rootPath: historyCwd, kind: "non_git", displayName: "history", createdAt: "2026-03-01T12:00:00.000Z", @@ -1784,7 +1791,7 @@ test("fetch_agent_history_request pages archived historical rows separately", as const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-history", projectId: project.projectId, - cwd: "/tmp/history", + cwd: historyCwd, kind: "directory", displayName: "history", createdAt: "2026-03-01T12:00:00.000Z", @@ -1801,7 +1808,7 @@ test("fetch_agent_history_request pages archived historical rows separately", as { ...makeAgent({ id: "history-archived", - cwd: "/tmp/history", + cwd: historyCwd, status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -1842,7 +1849,7 @@ test("fetch_agent_request still resolves archived historical agents", async () = const agent = { ...makeAgent({ id: "archived-history-agent", - cwd: "/tmp/history-detail", + cwd: path.resolve("/tmp/history-detail"), status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -1941,7 +1948,7 @@ test("branch/detached policies and dominant status bucket are deterministic", as createPersistedWorkspaceRecord({ workspaceId: "ws-repo-status", projectId: "proj-repo-status", - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -1951,19 +1958,19 @@ test("branch/detached policies and dominant status bucket are deterministic", as session.listAgentPayloads = async () => [ makeAgent({ id: "a1", - cwd: "/tmp/repo", + cwd: REPO_CWD, status: "running", updatedAt: "2026-03-01T12:00:00.000Z", }), makeAgent({ id: "a2", - cwd: "/tmp/repo", + cwd: REPO_CWD, status: "error", updatedAt: "2026-03-01T12:01:00.000Z", }), makeAgent({ id: "a3", - cwd: "/tmp/repo", + cwd: REPO_CWD, status: "idle", updatedAt: "2026-03-01T12:02:00.000Z", pendingPermissions: 1, @@ -1985,7 +1992,7 @@ test("subdirectory agents map to an existing parent workspace descriptor", async createPersistedWorkspaceRecord({ workspaceId: "ws-repo-subdir", projectId: "proj-repo-subdir", - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", @@ -2101,12 +2108,12 @@ test("workspace update stream keeps persisted workspace visible after agents sto session.buildWorkspaceDescriptorMap = async () => new Map([ [ - "/tmp/repo", + REPO_CWD, { id: "ws-repo-running", projectId: "proj-repo-running", projectDisplayName: "repo", - projectRootPath: "/tmp/repo", + projectRootPath: REPO_CWD, projectKind: "non_git", workspaceKind: "directory", name: "repo", @@ -2115,17 +2122,17 @@ test("workspace update stream keeps persisted workspace visible after agents sto }, ], ]); - await session.emitWorkspaceUpdateForCwd("/tmp/repo"); + await session.emitWorkspaceUpdateForCwd(REPO_CWD); session.buildWorkspaceDescriptorMap = async () => new Map([ [ - "/tmp/repo", + REPO_CWD, { id: "ws-repo-running", projectId: "proj-repo-running", projectDisplayName: "repo", - projectRootPath: "/tmp/repo", + projectRootPath: REPO_CWD, projectKind: "non_git", workspaceKind: "directory", name: "repo", @@ -2134,7 +2141,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto }, ], ]); - await session.emitWorkspaceUpdateForCwd("/tmp/repo"); + await session.emitWorkspaceUpdateForCwd(REPO_CWD); const workspaceUpdates = filterByType(emitted, "workspace_update"); expect(workspaceUpdates).toHaveLength(2); @@ -2145,7 +2152,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto id: "ws-repo-running", projectId: "proj-repo-running", projectDisplayName: "repo", - projectRootPath: "/tmp/repo", + projectRootPath: REPO_CWD, projectKind: "non_git", workspaceKind: "directory", name: "repo", @@ -2160,13 +2167,19 @@ test("create paseo worktree request returns a registered workspace descriptor", const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); const repoDir = path.join(tempDir, "repo"); const paseoHome = path.join(tempDir, "paseo-home"); - execSync(`mkdir -p ${repoDir}`); - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); const workspaceGitService = createNoopWorkspaceGitService(); workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => { if (cwd === repoDir) { @@ -2263,7 +2276,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async () createPersistedWorkspaceRecord({ workspaceId: "ws-repo-main", projectId: "proj-repo-main", - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", @@ -2296,7 +2309,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async () id: "ws-repo-main", projectId: "proj-repo-main", projectDisplayName: "repo", - projectRootPath: "/tmp/repo", + projectRootPath: REPO_CWD, projectKind: "git", workspaceKind: "local_checkout", name: "main", @@ -2310,7 +2323,7 @@ test("workspace update fanout for multiple cwd values is deduplicated", async () id: "ws-repo-feature", projectId: "proj-repo-main", projectDisplayName: "repo", - projectRootPath: "/tmp/repo", + projectRootPath: REPO_CWD, projectKind: "git", workspaceKind: "worktree", name: "feature", @@ -2376,14 +2389,14 @@ test("open_project_request registers a workspace before any agent exists", async await session.handleMessage({ type: "open_project_request", - cwd: "/tmp/repo", + cwd: REPO_CWD, requestId: "req-open", }); - expect(workspaces.get("/tmp/repo")).toBeTruthy(); + expect(workspaces.get(REPO_CWD)).toBeTruthy(); const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe("/tmp/repo"); + expect(response?.payload.workspace?.id).toBe(REPO_CWD); }); test("open_project_response returns immediately even when the GitHub fetch is slow", async () => { @@ -2391,7 +2404,7 @@ test("open_project_response returns immediately even when the GitHub fetch is sl const session = createSessionForWorkspaceTests(); const projects = new Map>(); const workspaces = new Map>(); - const cwd = "/tmp/slow-github-repo"; + const cwd = path.resolve("/tmp/slow-github-repo"); session.emit = (message) => { if (isSessionOutboundMessage(message)) emitted.push(message); @@ -2453,7 +2466,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the const session = createSessionForWorkspaceTests(); const projects = new Map>(); const workspaces = new Map>(); - const cwd = "/tmp/github-runtime-repo"; + const cwd = path.resolve("/tmp/github-runtime-repo"); const snapshot = createWorkspaceRuntimeSnapshot(cwd); let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null; @@ -2545,8 +2558,8 @@ test("open_project_request does not match a new child directory to an existing p const session = createSessionForWorkspaceTests(); const projects = new Map>(); const workspaces = new Map>(); - const home = "/Users/moboudra"; - const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea"; + const home = path.resolve("/Users/moboudra"); + const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea"); projects.set( home, @@ -2609,8 +2622,8 @@ test("open_project_request does not unarchive an archived parent workspace for a const session = createSessionForWorkspaceTests(); const projects = new Map>(); const workspaces = new Map>(); - const home = "/Users/moboudra"; - const worktree = "/Users/moboudra/.paseo/worktrees/project-config-lifecycle-textarea"; + const home = path.resolve("/Users/moboudra"); + const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea"); const archivedAt = "2026-04-24T08:00:00.000Z"; projects.set( @@ -2677,8 +2690,14 @@ test("open_project_request reclassifies an archived directory workspace when git const session = createSessionForWorkspaceTests(); const projects = new Map>(); const workspaces = new Map>(); - const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; - const repoRoot = "/Users/moboudra/dev/paseo"; + const repoRoot = path.resolve("/Users/moboudra/dev/paseo"); + const cwd = path.join( + path.resolve("/Users/moboudra"), + ".paseo", + "worktrees", + "orchestrate", + "desktop-daemon-settings", + ); const remoteProjectId = "remote:github.com/getpaseo/paseo"; const archivedAt = "2026-04-24T09:48:36.168Z"; @@ -2769,8 +2788,14 @@ test("open_project_request reclassifies an active directory workspace when git m const session = createSessionForWorkspaceTests(); const projects = new Map>(); const workspaces = new Map>(); - const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; - const repoRoot = "/Users/moboudra/dev/paseo"; + const repoRoot = path.resolve("/Users/moboudra/dev/paseo"); + const cwd = path.join( + path.resolve("/Users/moboudra"), + ".paseo", + "worktrees", + "orchestrate", + "desktop-daemon-settings", + ); projects.set( cwd, @@ -2879,8 +2904,14 @@ test("open_project_request groups a plain git worktree under an existing repo pr const session = createSessionForWorkspaceTests(); const projects = new Map>(); const workspaces = new Map>(); - const cwd = "/Users/moboudra/.paseo/worktrees/orchestrate/desktop-daemon-settings"; - const repoRoot = "/Users/moboudra/dev/paseo"; + const repoRoot = path.resolve("/Users/moboudra/dev/paseo"); + const cwd = path.join( + path.resolve("/Users/moboudra"), + ".paseo", + "worktrees", + "orchestrate", + "desktop-daemon-settings", + ); projects.set( repoRoot, @@ -2966,7 +2997,7 @@ test("open_project_request unarchives an existing archived workspace and project const projects = new Map>(); const workspaces = new Map>(); - const cwd = "/tmp/repo"; + const cwd = REPO_CWD; projects.set( cwd, createPersistedProjectRecord({ @@ -3030,7 +3061,7 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root const session = createSessionForWorkspaceTests(); const projects = new Map>(); const workspaces = new Map>(); - const repoRoot = "/tmp/repo"; + const repoRoot = REPO_CWD; const subdir = "/tmp/repo/packages/app"; session.emit = (message) => { @@ -3213,10 +3244,10 @@ test("open_in_editor_request launches the selected target", async () => { type: "open_in_editor_request", requestId: "req-open-editor", editorId: "vscode", - path: "/tmp/repo", + path: REPO_CWD, }); - expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]); + expect(calls).toEqual([{ editorId: "vscode", path: REPO_CWD }]); const response = emitted.find((message) => message.type === "open_in_editor_response") as | { payload: Record } | undefined; @@ -3229,7 +3260,7 @@ test("archive_workspace_request hides non-destructive workspace records", async const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-repo-archive", projectId: "proj-repo-archive", - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "directory", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -3271,7 +3302,7 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem const localProjectId = mainWorkspaceId; const remoteProjectId = "remote:github.com/zimakki/inkwell"; - execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); + mkdirSync(worktreeWorkspaceId, { recursive: true }); projects.set( localProjectId, @@ -3381,7 +3412,7 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w const oldProjectId = "remote:github.com/old-owner/inkwell"; const newProjectId = "remote:github.com/new-owner/inkwell"; - execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); + mkdirSync(worktreeWorkspaceId, { recursive: true }); projects.set( oldProjectId, @@ -3476,7 +3507,7 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi const subdirWorkspaceId = path.join(repoRoot, "packages", "app"); const projectId = "remote:github.com/acme/repo"; - execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`); + mkdirSync(subdirWorkspaceId, { recursive: true }); projects.set( projectId, @@ -3566,7 +3597,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc const session = createSessionForWorkspaceTests(); const project = createPersistedProjectRecord({ projectId: "proj-baseline", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -3575,7 +3606,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-baseline", projectId: project.projectId, - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", @@ -3627,7 +3658,7 @@ test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => const archivingAt = "2026-04-30T20:45:00.000Z"; const project = createPersistedProjectRecord({ projectId: "proj-archiving-map", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -3669,7 +3700,7 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u const archivingAt = "2026-04-30T20:45:00.000Z"; const project = createPersistedProjectRecord({ projectId: "proj-archiving-emit", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -3725,7 +3756,7 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { const emitted: SessionOutboundMessage[] = []; - const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { + const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, { git: { currentBranch: "runtime-branch", isDirty: true, @@ -3758,7 +3789,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git ); const project = createPersistedProjectRecord({ projectId: "proj-runtime-fetch", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -3767,7 +3798,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-runtime-fetch", projectId: project.projectId, - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", @@ -3803,7 +3834,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git | { type: "fetch_workspaces_response"; payload: Record } | undefined; - expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith("/tmp/repo"); + expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD); expect(response?.payload.entries).toEqual([ expect.objectContaining({ id: "ws-runtime-fetch", @@ -3856,7 +3887,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor ); const project = createPersistedProjectRecord({ projectId: "proj-fetch-boundary", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -3865,7 +3896,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-fetch-boundary", projectId: project.projectId, - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", @@ -3895,7 +3926,7 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor test("workspace_update includes updated runtime fields", async () => { const emitted: SessionOutboundMessage[] = []; - const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { + const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, { git: { currentBranch: "feature/runtime-payloads", isDirty: true, @@ -3922,7 +3953,7 @@ test("workspace_update includes updated runtime fields", async () => { ); const project = createPersistedProjectRecord({ projectId: "proj-runtime-update", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -3931,7 +3962,7 @@ test("workspace_update includes updated runtime fields", async () => { const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-runtime-update", projectId: project.projectId, - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", @@ -3966,11 +3997,11 @@ test("workspace_update includes updated runtime fields", async () => { }, }); - await session.emitWorkspaceUpdateForCwd("/tmp/repo", { + await session.emitWorkspaceUpdateForCwd(REPO_CWD, { skipReconcile: true, }); - expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith("/tmp/repo"); + expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD); expect(emitted).toContainEqual({ type: "workspace_update", payload: { @@ -3998,7 +4029,7 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho const session = createSessionForWorkspaceTests(); const gitProject = createPersistedProjectRecord({ projectId: "proj-git-subscribe", - rootPath: "/tmp/repo", + rootPath: REPO_CWD, kind: "git", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", @@ -4015,7 +4046,7 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho const gitWorkspace = createPersistedWorkspaceRecord({ workspaceId: "ws-git-subscribe", projectId: gitProject.projectId, - cwd: "/tmp/repo", + cwd: REPO_CWD, kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 5e4e51540..9179cf889 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -86,6 +86,15 @@ function createServer(agentManagerOverrides?: Record) { setAgentAttentionCallback: vi.fn(), getAgent: vi.fn(() => null), getLastAssistantMessage: vi.fn(async () => null), + getMetricsSnapshot: vi.fn(() => ({ + total: 0, + byLifecycle: {}, + withActiveForegroundTurn: 0, + timelineStats: { + totalItems: 0, + maxItemsPerAgent: 0, + }, + })), ...agentManagerOverrides, }; const daemonConfigStore = { diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index d57e1edad..cdfcf1203 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1,8 +1,8 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve as resolvePath } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createGitHubService, @@ -23,6 +23,9 @@ import { WorkspaceGitServiceImpl, type WorkspaceGitRuntimeSnapshot, } from "./workspace-git-service.js"; +import { isPlatform } from "../test-utils/platform.js"; + +const REPO_CWD = resolvePath("/tmp/repo"); function createLogger() { const logger = { @@ -230,11 +233,11 @@ function buildDefaultServiceDeps() { listBranchSuggestions: vi.fn(async () => []), listPaseoWorktrees: vi.fn(async () => []), github: createGitHubServiceStub(), - resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), + resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")), hasOriginRemote: vi.fn(async () => false), runGitFetch: vi.fn(async () => {}), runGitCommand: vi.fn(async () => ({ - stdout: "/tmp/repo\n", + stdout: `${REPO_CWD}\n`, stderr: "", truncated: false, exitCode: 0, @@ -273,9 +276,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { now: () => new Date(nowMs), }); - await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); nowMs += 1_000; - await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); @@ -292,7 +295,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getPullRequestStatus, }); - await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getCheckoutShortstat).toHaveBeenCalledTimes(1); @@ -307,22 +310,22 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const service = createService({ getCheckoutStatus }); const listener = vi.fn(); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect(getCheckoutStatus).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled(); - expect(service.peekSnapshot("/tmp/repo")).toBeNull(); + expect(service.peekSnapshot(REPO_CWD)).toBeNull(); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); - expect(service.peekSnapshot("/tmp/repo")).toBeNull(); + expect(service.peekSnapshot(REPO_CWD)).toBeNull(); - checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); + checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD)); - await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); - expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); + expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); subscription.unsubscribe(); service.dispose(); @@ -336,9 +339,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { now: () => new Date(nowMs), }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); nowMs = 1; - await service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + await service.getSnapshot(REPO_CWD, { force: true, reason: "test" }); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -348,15 +351,15 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { test("forced getSnapshot emits even when the fingerprint matches", async () => { const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ getCheckoutStatus }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); const listener = vi.fn(); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); - await service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + await service.getSnapshot(REPO_CWD, { force: true, reason: "test" }); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo")); + expect(listener).toHaveBeenCalledWith(createSnapshot(REPO_CWD)); subscription.unsubscribe(); service.dispose(); @@ -371,13 +374,13 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, now: () => new Date(nowMs), }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); const listener = vi.fn(); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); nowMs = 3_000; - await service.refresh("/tmp/repo"); + await service.refresh(REPO_CWD); expect(listener).not.toHaveBeenCalled(); @@ -390,17 +393,17 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); const service = createService({ getCheckoutStatus }); - const first = service.getSnapshot("/tmp/repo"); - const second = service.getSnapshot("/tmp/repo/."); + const first = service.getSnapshot(REPO_CWD); + const second = service.getSnapshot(join(REPO_CWD, ".")); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); - checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); + checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD)); await expect(Promise.all([first, second])).resolves.toEqual([ - createSnapshot("/tmp/repo"), - createSnapshot("/tmp/repo"), + createSnapshot(REPO_CWD), + createSnapshot(REPO_CWD), ]); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); @@ -424,24 +427,24 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { now: () => new Date(nowMs), }); - await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( - createSnapshot("/tmp/repo", { + await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual( + createSnapshot(REPO_CWD, { git: { diffStat: { additions: 4, deletions: 2 } }, }), ); nowMs += 16_000; - const first = service.getSnapshot("/tmp/repo"); + const first = service.getSnapshot(REPO_CWD); await flushPromises(); - const second = service.getSnapshot("/tmp/repo"); + const second = service.getSnapshot(REPO_CWD); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutShortstat).toHaveBeenCalledTimes(1); - refreshStatus.resolve(createCheckoutStatus("/tmp/repo", { currentBranch: "feature" })); + refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" })); - const freshSnapshot = createSnapshot("/tmp/repo", { + const freshSnapshot = createSnapshot(REPO_CWD, { git: { currentBranch: "feature", diffStat: { additions: 4, deletions: 2 }, @@ -461,11 +464,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, now: () => new Date(nowMs), }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); nowMs = 3_000; for (let index = 0; index < 5; index += 1) { - service.scheduleRefreshForCwd("/tmp/repo"); + service.scheduleRefreshForCwd(REPO_CWD); } await vi.advanceTimersByTimeAsync(500); await flushPromises(); @@ -480,20 +483,20 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const secondRefresh = createDeferred(); const getCheckoutStatus = vi .fn<() => Promise>() - .mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) + .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD)) .mockImplementationOnce(async () => secondRefresh.promise) - .mockImplementation(async () => createCheckoutStatus("/tmp/repo")); + .mockImplementation(async () => createCheckoutStatus(REPO_CWD)); const service = createService({ getCheckoutStatus, now: () => new Date(nowMs), }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); nowMs = 3_000; - const refreshPromise = service.refresh("/tmp/repo"); + const refreshPromise = service.refresh(REPO_CWD); await flushPromises(); - const forcedPromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); - const duplicateForcedPromise = service.getSnapshot("/tmp/repo", { + const forcedPromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" }); + const duplicateForcedPromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test", }); @@ -501,7 +504,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - secondRefresh.resolve(createCheckoutStatus("/tmp/repo")); + secondRefresh.resolve(createCheckoutStatus(REPO_CWD)); await Promise.all([refreshPromise, forcedPromise, duplicateForcedPromise]); expect(getCheckoutStatus).toHaveBeenCalledTimes(3); @@ -513,19 +516,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const forcedRefresh = createDeferred(); const getCheckoutStatus = vi .fn<() => Promise>() - .mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) + .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD)) .mockImplementationOnce(async () => forcedRefresh.promise); const service = createService({ getCheckoutStatus }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); - const first = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + const first = service.getSnapshot(REPO_CWD, { force: true, reason: "test" }); await flushPromises(); - const second = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + const second = service.getSnapshot(REPO_CWD, { force: true, reason: "test" }); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - forcedRefresh.resolve(createCheckoutStatus("/tmp/repo")); + forcedRefresh.resolve(createCheckoutStatus(REPO_CWD)); await Promise.all([first, second]); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -537,18 +540,18 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const forcedRefresh = createDeferred(); const getCheckoutStatus = vi .fn<() => Promise>() - .mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) + .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD)) .mockImplementationOnce(async () => forcedRefresh.promise); const service = createService({ getCheckoutStatus }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); - const forcePromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + const forcePromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" }); await flushPromises(); - service.scheduleRefreshForCwd("/tmp/repo"); + service.scheduleRefreshForCwd(REPO_CWD); await vi.advanceTimersByTimeAsync(500); await flushPromises(); - forcedRefresh.resolve(createCheckoutStatus("/tmp/repo")); + forcedRefresh.resolve(createCheckoutStatus(REPO_CWD)); await forcePromise; expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -563,12 +566,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, now: () => new Date(nowMs), }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); nowMs = 3_000; - await service.refresh("/tmp/repo"); + await service.refresh(REPO_CWD); nowMs = 3_001; - await service.refresh("/tmp/repo"); + await service.refresh(REPO_CWD); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -582,10 +585,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, now: () => new Date(nowMs), }); - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); nowMs = 16_000; - await service.getSnapshot("/tmp/repo"); + await service.getSnapshot(REPO_CWD); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -601,7 +604,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getPullRequestStatus, now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); nowMs = 60_000; @@ -609,7 +612,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - expect(getPullRequestStatus).toHaveBeenLastCalledWith("/tmp/repo", expect.anything(), { + expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), { force: false, reason: "self-heal-git", }); @@ -623,7 +626,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const resolveAbsoluteGitDir = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error("git dir temporarily unavailable")) - .mockResolvedValue("/tmp/repo/.git"); + .mockResolvedValue(join(REPO_CWD, ".git")); const watch = vi.fn(() => createWatcher() as never); const service = createService({ resolveAbsoluteGitDir, @@ -631,7 +634,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); @@ -642,7 +645,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await flushPromises(); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2); - expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith("/tmp/repo"); + expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith(REPO_CWD); subscription.unsubscribe(); service.dispose(); @@ -659,11 +662,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ getCheckoutStatus, - resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), + resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")), watch, }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); await vi.waitFor(() => { @@ -697,7 +700,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { github, }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); await vi.waitFor(() => { @@ -744,7 +747,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { github, now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); nowMs = 20_000; @@ -777,7 +780,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, github, }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled(); @@ -801,7 +804,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, github, }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); await vi.waitFor(() => { @@ -819,8 +822,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, now: () => new Date(nowMs), }); - const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); - const second = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn()); + const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn()); await flushPromises(); nowMs = 60_000; @@ -841,7 +844,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); subscription.unsubscribe(); nowMs = 60_000; @@ -860,7 +863,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getCheckoutStatus, now: () => new Date(nowMs), }); - service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); service.dispose(); nowMs = 60_000; @@ -875,24 +878,24 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const selfHealRefresh = createDeferred(); const getCheckoutStatus = vi .fn<() => Promise>() - .mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) + .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD)) .mockImplementationOnce(async () => selfHealRefresh.promise); const service = createService({ getCheckoutStatus, now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); nowMs = 60_000; await vi.advanceTimersByTimeAsync(60_000); await flushPromises(); - const directRead = service.getSnapshot("/tmp/repo"); + const directRead = service.getSnapshot(REPO_CWD); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - selfHealRefresh.resolve(createCheckoutStatus("/tmp/repo")); + selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD)); await directRead; expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -923,8 +926,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - const first = service.validateBranchRef("/tmp/repo", "feature"); - const second = service.validateBranchRef("/tmp/repo/.", "feature"); + const first = service.validateBranchRef(REPO_CWD, "feature"); + const second = service.validateBranchRef(join(REPO_CWD, "."), "feature"); await flushPromises(); expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); @@ -935,10 +938,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { ]); nowMs = 1_000; - await service.validateBranchRef("/tmp/repo", "feature"); + await service.validateBranchRef(REPO_CWD, "feature"); expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); - await service.validateBranchRef("/tmp/repo", "feature", { force: true, reason: "test" }); + await service.validateBranchRef(REPO_CWD, "feature", { force: true, reason: "test" }); expect(resolveBranchCheckout).toHaveBeenCalledTimes(2); service.dispose(); @@ -968,15 +971,15 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - const first = service.hasLocalBranch("/tmp/repo", "feature"); - const second = service.hasLocalBranch("/tmp/repo/.", "feature"); + const first = service.hasLocalBranch(REPO_CWD, "feature"); + const second = service.hasLocalBranch(join(REPO_CWD, "."), "feature"); await flushPromises(); expect(runGitCommand).toHaveBeenCalledTimes(1); expect(runGitCommand).toHaveBeenCalledWith( ["rev-parse", "--verify", "--quiet", "refs/heads/feature"], expect.objectContaining({ - cwd: "/tmp/repo", + cwd: REPO_CWD, acceptExitCodes: [0, 1], }), ); @@ -990,11 +993,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { await expect(Promise.all([first, second])).resolves.toEqual([true, true]); nowMs = 1_000; - await expect(service.hasLocalBranch("/tmp/repo", "feature")).resolves.toBe(true); + await expect(service.hasLocalBranch(REPO_CWD, "feature")).resolves.toBe(true); expect(runGitCommand).toHaveBeenCalledTimes(1); await expect( - service.hasLocalBranch("/tmp/repo", "feature", { force: true, reason: "test" }), + service.hasLocalBranch(REPO_CWD, "feature", { force: true, reason: "test" }), ).resolves.toBe(false); expect(runGitCommand).toHaveBeenCalledTimes(2); @@ -1013,18 +1016,18 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({ + await expect(service.validateBranchRef(REPO_CWD, "feature")).resolves.toEqual({ kind: "local", name: "feature-old", }); nowMs = 16_000; resolveBranchCheckout.mockClear(); - await expect(service.validateBranchRef("/tmp/repo", "feature")).rejects.toThrow("git is busy"); + await expect(service.validateBranchRef(REPO_CWD, "feature")).rejects.toThrow("git is busy"); expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); nowMs = 16_500; - await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({ + await expect(service.validateBranchRef(REPO_CWD, "feature")).resolves.toEqual({ kind: "local", name: "feature-old", }); @@ -1046,8 +1049,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - const first = service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 }); - const second = service.suggestBranchesForCwd("/tmp/repo/.", { + const first = service.suggestBranchesForCwd(REPO_CWD, { query: "feat", limit: 5 }); + const second = service.suggestBranchesForCwd(join(REPO_CWD, "."), { query: "feat", limit: 5, }); @@ -1058,11 +1061,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { await expect(Promise.all([first, second])).resolves.toEqual([suggestions, suggestions]); nowMs = 1_000; - await service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 }); + await service.suggestBranchesForCwd(REPO_CWD, { query: "feat", limit: 5 }); expect(listBranchSuggestions).toHaveBeenCalledTimes(1); await service.suggestBranchesForCwd( - "/tmp/repo", + REPO_CWD, { query: "feat", limit: 5 }, { force: true, reason: "test" }, ); @@ -1096,8 +1099,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - const first = service.listStashes("/tmp/repo", { paseoOnly: true }); - const second = service.listStashes("/tmp/repo/.", { paseoOnly: true }); + const first = service.listStashes(REPO_CWD, { paseoOnly: true }); + const second = service.listStashes(join(REPO_CWD, "."), { paseoOnly: true }); await flushPromises(); expect(runGitCommand).toHaveBeenCalledTimes(1); @@ -1114,10 +1117,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { ]); nowMs = 1_000; - await service.listStashes("/tmp/repo", { paseoOnly: true }); + await service.listStashes(REPO_CWD, { paseoOnly: true }); expect(runGitCommand).toHaveBeenCalledTimes(1); - await service.listStashes("/tmp/repo", { paseoOnly: true }, { force: true, reason: "test" }); + await service.listStashes(REPO_CWD, { paseoOnly: true }, { force: true, reason: "test" }); expect(runGitCommand).toHaveBeenCalledTimes(2); service.dispose(); @@ -1138,16 +1141,16 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - const first = service.listWorktrees("/tmp/repo"); - const second = service.listWorktrees("/tmp/repo/."); + const first = service.listWorktrees(REPO_CWD); + const second = service.listWorktrees(join(REPO_CWD, ".")); await expect(Promise.all([first, second])).resolves.toEqual([worktrees, worktrees]); expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); nowMs = 1_000; - await service.listWorktrees("/tmp/repo"); + await service.listWorktrees(REPO_CWD); expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); - await service.listWorktrees("/tmp/repo", { force: true, reason: "test" }); + await service.listWorktrees(REPO_CWD, { force: true, reason: "test" }); expect(listPaseoWorktrees).toHaveBeenCalledTimes(2); service.dispose(); @@ -1158,7 +1161,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { const repoDir = join(tempDir, "repo"); const nestedWorkspaceDir = join(repoDir, "packages", "app"); mkdirSync(nestedWorkspaceDir, { recursive: true }); - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); const worktrees = [ { @@ -1181,7 +1184,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); expect(listPaseoWorktrees).toHaveBeenCalledWith({ - cwd: repoDir, + cwd: realpathSync.native(repoDir).replace(/\\/g, "/"), paseoHome: "/tmp/paseo-test", }); } finally { @@ -1202,8 +1205,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - const first = service.resolveDefaultBranch("/tmp/repo"); - const second = service.resolveDefaultBranch("/tmp/repo/."); + const first = service.resolveDefaultBranch(REPO_CWD); + const second = service.resolveDefaultBranch(join(REPO_CWD, ".")); await flushPromises(); expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1); @@ -1211,10 +1214,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { await expect(Promise.all([first, second])).resolves.toEqual(["main", "main"]); nowMs = 1_000; - await service.resolveDefaultBranch("/tmp/repo"); + await service.resolveDefaultBranch(REPO_CWD); expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1); - await service.resolveDefaultBranch("/tmp/repo", { force: true, reason: "test" }); + await service.resolveDefaultBranch(REPO_CWD, { force: true, reason: "test" }); expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(2); service.dispose(); @@ -1226,25 +1229,25 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { const getCheckoutStatus = vi .fn() .mockImplementationOnce(async () => checkoutDeferred.promise) - .mockResolvedValue(createCheckoutStatus("/tmp/repo")); + .mockResolvedValue(createCheckoutStatus(REPO_CWD)); const service = createService({ getCheckoutStatus, now: () => new Date(nowMs), }); - const first = service.resolveRepoRoot("/tmp/repo"); - const second = service.resolveRepoRoot("/tmp/repo/."); + const first = service.resolveRepoRoot(REPO_CWD); + const second = service.resolveRepoRoot(join(REPO_CWD, ".")); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); - checkoutDeferred.resolve(createCheckoutStatus("/tmp/repo")); - await expect(Promise.all([first, second])).resolves.toEqual(["/tmp/repo", "/tmp/repo"]); + checkoutDeferred.resolve(createCheckoutStatus(REPO_CWD)); + await expect(Promise.all([first, second])).resolves.toEqual([REPO_CWD, REPO_CWD]); nowMs = 1_000; - await service.resolveRepoRoot("/tmp/repo"); + await service.resolveRepoRoot(REPO_CWD); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); - await service.resolveRepoRoot("/tmp/repo", { force: true, reason: "test" }); + await service.resolveRepoRoot(REPO_CWD, { force: true, reason: "test" }); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); service.dispose(); @@ -1262,11 +1265,11 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - await expect(service.resolveRepoRemoteUrl("/tmp/repo")).resolves.toBe( + await expect(service.resolveRepoRemoteUrl(REPO_CWD)).resolves.toBe( "https://github.com/getpaseo/paseo.git", ); nowMs = 1_000; - await expect(service.resolveRepoRemoteUrl("/tmp/repo/.")).resolves.toBe( + await expect(service.resolveRepoRemoteUrl(join(REPO_CWD, "."))).resolves.toBe( "https://github.com/getpaseo/paseo.git", ); @@ -1281,7 +1284,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { createCheckoutStatus(cwd, { currentBranch: "feature/service-metadata", remoteUrl: "https://github.com/getpaseo/paseo.git", - repoRoot: "/tmp/repo", + repoRoot: REPO_CWD, }), ); const service = createService({ @@ -1290,7 +1293,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { }); await expect( - service.getWorkspaceGitMetadata("/tmp/repo", { directoryName: "Local Repo" }), + service.getWorkspaceGitMetadata(REPO_CWD, { directoryName: "Local Repo" }), ).resolves.toEqual({ projectKind: "git", projectDisplayName: "getpaseo/paseo", @@ -1298,13 +1301,13 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { gitRemote: "https://github.com/getpaseo/paseo.git", isWorktree: false, projectSlug: "paseo", - repoRoot: "/tmp/repo", + repoRoot: REPO_CWD, currentBranch: "feature/service-metadata", remoteUrl: "https://github.com/getpaseo/paseo.git", }); nowMs = 1_000; - await service.getWorkspaceGitMetadata("/tmp/repo/.", { directoryName: "Local Repo" }); + await service.getWorkspaceGitMetadata(join(REPO_CWD, "."), { directoryName: "Local Repo" }); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); service.dispose(); @@ -1314,18 +1317,21 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-diff-"))); const repoDir = join(tempDir, "repo"); mkdirSync(repoDir, { recursive: true }); - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(join(repoDir, "tracked.txt"), "before\n"); - execSync("git add tracked.txt", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { + execFileSync("git", ["add", "tracked.txt"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe", }); writeFileSync(join(repoDir, "tracked.txt"), "before\nafter\n"); writeFileSync(join(repoDir, "staged.txt"), "staged\n"); - execSync("git add staged.txt", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "staged.txt"], { cwd: repoDir, stdio: "pipe" }); const service = createService({ getCheckoutDiff: getCheckoutDiffUncached as never, @@ -1357,8 +1363,8 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { .mockResolvedValue({ diff: "second" }); const service = createService({ getCheckoutDiff, now: () => new Date(0) }); - const first = service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" }); - const second = service.getCheckoutDiff("/tmp/repo/.", { mode: "uncommitted" }); + const first = service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); + const second = service.getCheckoutDiff(join(REPO_CWD, "."), { mode: "uncommitted" }); await flushPromises(); expect(getCheckoutDiff).toHaveBeenCalledTimes(1); @@ -1383,16 +1389,12 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ + await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({ diff: "first", }); nowMs = 1; await expect( - service.getCheckoutDiff( - "/tmp/repo", - { mode: "uncommitted" }, - { force: true, reason: "test" }, - ), + service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }, { force: true, reason: "test" }), ).resolves.toEqual({ diff: "forced" }); expect(getCheckoutDiff).toHaveBeenCalledTimes(2); @@ -1412,15 +1414,15 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ + await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({ diff: "first", }); nowMs = 16_000; - await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).rejects.toThrow( + await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).rejects.toThrow( "git is busy", ); nowMs = 16_500; - await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ + await expect(service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).resolves.toEqual({ diff: "first", }); @@ -1441,13 +1443,13 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { }); await expect( - service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "main" }), + service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }), ).resolves.toEqual({ diff: "main" }); await expect( - service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "release" }), + service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "release" }), ).resolves.toEqual({ diff: "release" }); await expect( - service.getCheckoutDiff("/tmp/repo", { + service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main", ignoreWhitespace: true, @@ -1459,47 +1461,51 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { service.dispose(); }); - test("Linux working tree walker excludes gitignored directories", async () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + // POSIX-only: this asserts Linux working-tree walker behavior around ignored directories. + test.skipIf(isPlatform("win32"))( + "Linux working tree walker excludes gitignored directories", + async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); - const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-ignored-"))); - const repoDir = join(tempDir, "repo"); - mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true }); - mkdirSync(join(repoDir, "kept"), { recursive: true }); - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); - writeFileSync(join(repoDir, ".gitignore"), "ignored/\n"); - writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n"); - writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n"); - writeFileSync(join(repoDir, "kept", "file.txt"), "keep\n"); + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-ignored-"))); + const repoDir = join(tempDir, "repo"); + mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true }); + mkdirSync(join(repoDir, "kept"), { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(join(repoDir, ".gitignore"), "ignored/\n"); + writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n"); + writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n"); + writeFileSync(join(repoDir, "kept", "file.txt"), "keep\n"); - const watchedPaths: string[] = []; - const watchSpy = (watchPath: string) => { - watchedPaths.push(watchPath); - return { close: vi.fn(), on: vi.fn().mockReturnThis() }; - }; + const watchedPaths: string[] = []; + const watchSpy = (watchPath: string) => { + watchedPaths.push(watchPath); + return { close: vi.fn(), on: vi.fn().mockReturnThis() }; + }; - const service = createService({ - watch: watchSpy as never, - readdir: readdir as never, - runGitCommand: runGitCommandReal as never, - getCheckoutStatus: getCheckoutStatusUncached as never, - resolveAbsoluteGitDir: resolveAbsoluteGitDirReal as never, - }); + const service = createService({ + watch: watchSpy as never, + readdir: readdir as never, + runGitCommand: runGitCommandReal as never, + getCheckoutStatus: getCheckoutStatusUncached as never, + resolveAbsoluteGitDir: resolveAbsoluteGitDirReal as never, + }); - try { - const subscription = await service.requestWorkingTreeWatch(repoDir, vi.fn()); + try { + const subscription = await service.requestWorkingTreeWatch(repoDir, vi.fn()); - const ignoredRoot = join(repoDir, "ignored"); - expect(watchedPaths.filter((path) => path.startsWith(ignoredRoot))).toEqual([]); - expect(watchedPaths).toContain(repoDir); - expect(watchedPaths).toContain(join(repoDir, "kept")); + const ignoredRoot = join(repoDir, "ignored"); + expect(watchedPaths.filter((path) => path.startsWith(ignoredRoot))).toEqual([]); + expect(watchedPaths).toContain(repoDir); + expect(watchedPaths).toContain(join(repoDir, "kept")); - subscription.unsubscribe(); - } finally { - service.dispose(); - rmSync(tempDir, { recursive: true, force: true }); - Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); - } - }); + subscription.unsubscribe(); + } finally { + service.dispose(); + rmSync(tempDir, { recursive: true, force: true }); + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } + }, + ); }); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 6d6c5a992..86f29d190 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import path from "node:path"; +import os from "node:os"; +import path, { join } from "node:path"; import type { FSWatcher } from "node:fs"; import type pino from "pino"; import type { GitHubService } from "../services/github-service.js"; @@ -8,6 +9,9 @@ import { WorkspaceGitServiceImpl, type WorkspaceGitRuntimeSnapshot, } from "./workspace-git-service.js"; +import { isPlatform } from "../test-utils/platform.js"; + +const REPO_CWD = path.resolve("/tmp/repo"); interface ServiceInternals { workingTreeWatchTargets: Map; @@ -202,11 +206,11 @@ function buildDefaultTestServiceDeps() { })), getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()), github: createGitHubServiceStub(), - resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), + resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")), hasOriginRemote: vi.fn(async () => false), runGitFetch: vi.fn(async () => {}), runGitCommand: vi.fn(async () => ({ - stdout: "/tmp/repo\n", + stdout: `${REPO_CWD}\n`, stderr: "", truncated: false, exitCode: 0, @@ -237,12 +241,12 @@ describe("WorkspaceGitServiceImpl", () => { const service = createService(); const listener = vi.fn(); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect("initial" in subscription).toBe(false); expect(listener).not.toHaveBeenCalled(); - expect(service.peekSnapshot("/tmp/repo")).toBeNull(); + expect(service.peekSnapshot(REPO_CWD)).toBeNull(); subscription.unsubscribe(); service.dispose(); @@ -267,8 +271,8 @@ describe("WorkspaceGitServiceImpl", () => { now: () => new Date("2026-04-12T02:03:04.000Z"), }); - await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( - createSnapshot("/tmp/repo", { + await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual( + createSnapshot(REPO_CWD, { github: { pullRequest: { url: "https://github.com/acme/repo/pull/999", @@ -304,10 +308,10 @@ describe("WorkspaceGitServiceImpl", () => { getCheckoutShortstat, }); - await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( - createSnapshot("/tmp/repo", { + await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual( + createSnapshot(REPO_CWD, { git: { - repoRoot: "/tmp/repo", + repoRoot: REPO_CWD, currentBranch: "feature/worktree", isPaseoOwnedWorktree: false, mainRepoRoot: "/tmp/main-repo", @@ -325,11 +329,11 @@ describe("WorkspaceGitServiceImpl", () => { now: () => new Date(nowMs), }); const listener = vi.fn(); - await service.getSnapshot("/tmp/repo"); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); + await service.getSnapshot(REPO_CWD); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); nowMs += 3_000; - await service.refresh("/tmp/repo"); + await service.refresh(REPO_CWD); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(listener).not.toHaveBeenCalled(); @@ -342,7 +346,7 @@ describe("WorkspaceGitServiceImpl", () => { const checkoutStatusDeferred = createDeferred(); const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); - const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); const service = createService({ getCheckoutStatus, @@ -350,27 +354,27 @@ describe("WorkspaceGitServiceImpl", () => { resolveAbsoluteGitDir, }); - const firstSnapshotPromise = service.getSnapshot("/tmp/repo"); - const secondSnapshotPromise = service.getSnapshot("/tmp/repo/."); + const firstSnapshotPromise = service.getSnapshot(REPO_CWD); + const secondSnapshotPromise = service.getSnapshot(join(REPO_CWD, ".")); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(0); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); - checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); + checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD)); await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([ - createSnapshot("/tmp/repo"), - createSnapshot("/tmp/repo"), + createSnapshot(REPO_CWD), + createSnapshot(REPO_CWD), ]); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); - expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); - await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1); @@ -379,7 +383,7 @@ describe("WorkspaceGitServiceImpl", () => { test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); - const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const service = createService({ @@ -388,8 +392,8 @@ describe("WorkspaceGitServiceImpl", () => { now: () => new Date(nowMs), }); - const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); - const second = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); expect(getPullRequestStatus).toHaveBeenCalledTimes(1); @@ -402,7 +406,7 @@ describe("WorkspaceGitServiceImpl", () => { test("equivalent cwd strings share one workspace target across service entry points", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); - const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const service = createService({ @@ -411,14 +415,18 @@ describe("WorkspaceGitServiceImpl", () => { now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn()); + const subscription = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn()); - await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo")); - expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual( + createSnapshot(REPO_CWD), + ); + expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); nowMs += 3_000; - await service.refresh("/tmp/repo"); - await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo")); + await service.refresh(REPO_CWD); + await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual( + createSnapshot(REPO_CWD), + ); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); @@ -430,7 +438,7 @@ describe("WorkspaceGitServiceImpl", () => { test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { const runGitFetch = vi.fn(async () => {}); const hasOriginRemote = vi.fn(async () => true); - const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); const service = createService({ resolveAbsoluteGitDir, @@ -438,8 +446,11 @@ describe("WorkspaceGitServiceImpl", () => { runGitFetch, }); - const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); - const second = service.registerWorkspace({ cwd: "/tmp/repo/packages/server" }, vi.fn()); + const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + const second = service.registerWorkspace( + { cwd: join(REPO_CWD, "packages", "server") }, + vi.fn(), + ); await vi.waitFor(() => { expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2); expect(runGitFetch).toHaveBeenCalledTimes(1); @@ -493,18 +504,18 @@ describe("WorkspaceGitServiceImpl", () => { }); const listener = vi.fn(); - const initialSnapshot = await service.getSnapshot("/tmp/repo"); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); + const initialSnapshot = await service.getSnapshot(REPO_CWD); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh"); - await service.refresh("/tmp/repo"); + await service.refresh(REPO_CWD); await flushPromises(); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith( - createSnapshot("/tmp/repo", { + createSnapshot(REPO_CWD, { github: { pullRequest: { url: "https://github.com/acme/repo/pull/123", @@ -525,9 +536,9 @@ describe("WorkspaceGitServiceImpl", () => { test("unchanged runtime snapshots do not emit duplicate updates", async () => { const getCheckoutStatus = vi .fn<() => Promise>() - .mockResolvedValueOnce(createCheckoutStatus("/tmp/repo", { remoteUrl: null })) + .mockResolvedValueOnce(createCheckoutStatus(REPO_CWD, { remoteUrl: null })) .mockResolvedValueOnce( - createCheckoutStatus("/tmp/repo", { + createCheckoutStatus(REPO_CWD, { currentBranch: "feature/runtime-payloads", remoteUrl: null, aheadBehind: { ahead: 2, behind: 0 }, @@ -535,7 +546,7 @@ describe("WorkspaceGitServiceImpl", () => { }), ) .mockResolvedValueOnce( - createCheckoutStatus("/tmp/repo", { + createCheckoutStatus(REPO_CWD, { currentBranch: "feature/runtime-payloads", remoteUrl: null, aheadBehind: { ahead: 2, behind: 0 }, @@ -563,22 +574,22 @@ describe("WorkspaceGitServiceImpl", () => { }); const listener = vi.fn(); - const initialSnapshot = await service.getSnapshot("/tmp/repo"); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); + const initialSnapshot = await service.getSnapshot(REPO_CWD); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); expect(initialSnapshot.git.currentBranch).toBe("main"); nowMs += 3_000; - await service.refresh("/tmp/repo"); + await service.refresh(REPO_CWD); await flushPromises(); nowMs += 3_000; - await service.refresh("/tmp/repo"); + await service.refresh(REPO_CWD); await flushPromises(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith( - createSnapshot("/tmp/repo", { + createSnapshot(REPO_CWD, { git: { currentBranch: "feature/runtime-payloads", remoteUrl: null, @@ -597,7 +608,7 @@ describe("WorkspaceGitServiceImpl", () => { }); test("forced snapshot refresh emits even when the fingerprint matches", async () => { - const getCheckoutStatus = vi.fn(async () => createCheckoutStatus("/tmp/repo")); + const getCheckoutStatus = vi.fn(async () => createCheckoutStatus(REPO_CWD)); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const service = createService({ @@ -607,23 +618,24 @@ describe("WorkspaceGitServiceImpl", () => { }); const listener = vi.fn(); - await service.getSnapshot("/tmp/repo"); - const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener); + await service.getSnapshot(REPO_CWD); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); - await service.getSnapshot("/tmp/repo", { + await service.getSnapshot(REPO_CWD, { force: true, reason: "test-force-emit", }); await flushPromises(); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo")); + expect(listener).toHaveBeenCalledWith(createSnapshot(REPO_CWD)); subscription.unsubscribe(); service.dispose(); }); - test("watches nested repository directories on Linux", async () => { + // POSIX-only: this asserts Linux recursive-watch fallback behavior. + test.skipIf(isPlatform("win32"))("watches nested repository directories on Linux", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, @@ -637,20 +649,20 @@ describe("WorkspaceGitServiceImpl", () => { return watcher; }); const readdir = vi.fn(async (directory: string) => { - if (directory === "/tmp/repo") { + if (directory === REPO_CWD) { return [ createDirent("packages", true), createDirent(".git", true), createDirent("README.md", false), ]; } - if (directory === path.join("/tmp/repo", "packages")) { + if (directory === path.join(REPO_CWD, "packages")) { return [createDirent("server", true), createDirent("app", true)]; } - if (directory === path.join("/tmp/repo", "packages", "server")) { + if (directory === path.join(REPO_CWD, "packages", "server")) { return [createDirent("src", true)]; } - if (directory === path.join("/tmp/repo", "packages", "server", "src")) { + if (directory === path.join(REPO_CWD, "packages", "server", "src")) { return [createDirent("server", true)]; } return []; @@ -658,19 +670,19 @@ describe("WorkspaceGitServiceImpl", () => { const service = createService({ watch, readdir }); const subscription = await service.requestWorkingTreeWatch( - path.join("/tmp/repo", "packages", "server"), + path.join(REPO_CWD, "packages", "server"), vi.fn(), ); - expect(subscription.repoRoot).toBe("/tmp/repo"); + expect(subscription.repoRoot).toBe(REPO_CWD); expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ - "/tmp/repo", - "/tmp/repo/.git", - "/tmp/repo/packages", - "/tmp/repo/packages/app", - "/tmp/repo/packages/server", - "/tmp/repo/packages/server/src", - "/tmp/repo/packages/server/src/server", + REPO_CWD, + join(REPO_CWD, ".git"), + join(REPO_CWD, "packages"), + join(REPO_CWD, "packages", "app"), + join(REPO_CWD, "packages", "server"), + join(REPO_CWD, "packages", "server", "src"), + join(REPO_CWD, "packages", "server", "src", "server"), ]); subscription.unsubscribe(); @@ -688,11 +700,11 @@ describe("WorkspaceGitServiceImpl", () => { const firstListener = vi.fn(); const secondListener = vi.fn(); - const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener); - const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener); + const first = await service.requestWorkingTreeWatch(REPO_CWD, firstListener); + const second = await service.requestWorkingTreeWatch(join(REPO_CWD, "."), secondListener); - expect(first.repoRoot).toBe("/tmp/repo"); - expect(second.repoRoot).toBe("/tmp/repo"); + expect(first.repoRoot).toBe(REPO_CWD); + expect(second.repoRoot).toBe(REPO_CWD); expect(watch).toHaveBeenCalledTimes(2); first.unsubscribe(); @@ -726,10 +738,8 @@ describe("WorkspaceGitServiceImpl", () => { .mockImplementationOnce(() => createWatcher()); const service = createService({ watch }); - const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn()); - const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get( - "/tmp/repo", - ); + const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn()); + const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(REPO_CWD); expect(target?.fallbackRefreshInterval).not.toBeNull(); @@ -749,19 +759,18 @@ describe("WorkspaceGitServiceImpl", () => { resolveAbsoluteGitDir, }); - const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn()); - const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get( - "/tmp/plain", - ); + const plainCwd = path.join(os.tmpdir(), "plain"); + const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn()); + const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(plainCwd); expect(subscription.repoRoot).toBeNull(); const expectedRecursive = process.platform !== "linux"; expect(watch).toHaveBeenCalledWith( - "/tmp/plain", + plainCwd, { recursive: expectedRecursive }, expect.any(Function), ); - expect(target?.repoWatchPath).toBe("/tmp/plain"); + expect(target?.repoWatchPath).toBe(plainCwd); expect(target?.fallbackRefreshInterval).not.toBeNull(); subscription.unsubscribe(); @@ -780,13 +789,13 @@ describe("WorkspaceGitServiceImpl", () => { const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh"); const listener = vi.fn(); - const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener); + const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener); expect(watchCallbacks).toHaveLength(2); watchCallbacks[0]?.(); expect(listener).toHaveBeenCalledTimes(1); - expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo", { + expect(refreshSpy).toHaveBeenCalledWith(REPO_CWD, { force: true, reason: "working-tree-watch", }); @@ -810,15 +819,12 @@ describe("WorkspaceGitServiceImpl", () => { const service = createService({ getCheckoutShortstat, watch }); const workspaceListener = vi.fn(); - const initialSnapshot = await service.getSnapshot("/tmp/repo"); - const workspaceSubscription = service.registerWorkspace( - { cwd: "/tmp/repo" }, - workspaceListener, - ); - const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn()); + const initialSnapshot = await service.getSnapshot(REPO_CWD); + const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, workspaceListener); + const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn()); expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 }); - const repoRootWatch = watchCallbacks.find((entry) => entry.path === "/tmp/repo"); + const repoRootWatch = watchCallbacks.find((entry) => entry.path === REPO_CWD); expect(repoRootWatch).toBeDefined(); repoRootWatch?.callback(); @@ -826,12 +832,12 @@ describe("WorkspaceGitServiceImpl", () => { await flushPromises(); expect(getCheckoutShortstat).toHaveBeenLastCalledWith( - "/tmp/repo", + REPO_CWD, { paseoHome: "/tmp/paseo-test" }, { force: true }, ); expect(workspaceListener).toHaveBeenCalledWith( - createSnapshot("/tmp/repo", { + createSnapshot(REPO_CWD, { git: { diffStat: { additions: 8, deletions: 3 } }, }), ); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 45a845e2d..7c85cc3cf 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -117,13 +117,13 @@ function createWorkspaceGitServiceStub( function createTempGitRepo(prefix: string): string { const raw = mkdtempSync(path.join(tmpdir(), prefix)); const dir = realpathSync(raw); - execSync("git init -b main", { cwd: dir, stdio: "ignore" }); - execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" }); - execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" }); - execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: dir, stdio: "ignore" }); writeFileSync(path.join(dir, "README.md"), "# Test\n"); - execSync("git add .", { cwd: dir, stdio: "ignore" }); - execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["add", "."], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" }); return dir; } @@ -253,12 +253,18 @@ describe("WorkspaceReconciliationService", () => { ); // Initialize as git repo - execSync("git init -b main", { cwd: resolved, stdio: "ignore" }); - execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" }); - execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" }); - execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" }); - execSync("git add .", { cwd: resolved, stdio: "ignore" }); - execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" }); + execFileSync("git", ["init", "-b", "main"], { cwd: resolved, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { + cwd: resolved, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: resolved, stdio: "ignore" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: resolved, + stdio: "ignore", + }); + execFileSync("git", ["add", "."], { cwd: resolved, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: resolved, stdio: "ignore" }); const service = new WorkspaceReconciliationService({ projectRegistry, @@ -311,7 +317,7 @@ describe("WorkspaceReconciliationService", () => { ); // Change the remote - execSync("git remote add origin git@github.com:new-owner/new-repo.git", { + execFileSync("git", ["remote", "add", "origin", "git@github.com:new-owner/new-repo.git"], { cwd: dir, stdio: "ignore", }); @@ -341,7 +347,7 @@ describe("WorkspaceReconciliationService", () => { const dir = createTempGitRepo("reconcile-branch-"); tempDirs.push(dir); - execSync("git checkout -b feature-branch", { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["checkout", "-b", "feature-branch"], { cwd: dir, stdio: "ignore" }); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 8c16280c9..d960b56b8 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -11,6 +11,9 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project"); +const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project"); + describe("bootstrapWorkspaceRegistries", () => { let tmpDir: string; let paseoHome: string; @@ -44,7 +47,7 @@ describe("bootstrapWorkspaceRegistries", () => { await agentStorage.upsert({ id: "agent-1", provider: "codex", - cwd: "/tmp/non-git-project", + cwd: NON_GIT_PROJECT, createdAt: "2026-03-01T00:00:00.000Z", updatedAt: "2026-03-02T00:00:00.000Z", lastActivityAt: "2026-03-02T00:00:00.000Z", @@ -61,7 +64,7 @@ describe("bootstrapWorkspaceRegistries", () => { await agentStorage.upsert({ id: "agent-2", provider: "codex", - cwd: "/tmp/non-git-project", + cwd: NON_GIT_PROJECT, createdAt: "2026-03-01T01:00:00.000Z", updatedAt: "2026-03-03T00:00:00.000Z", lastActivityAt: "2026-03-03T00:00:00.000Z", @@ -78,7 +81,7 @@ describe("bootstrapWorkspaceRegistries", () => { await agentStorage.upsert({ id: "agent-archived", provider: "codex", - cwd: "/tmp/archived-project", + cwd: ARCHIVED_PROJECT, createdAt: "2026-03-01T00:00:00.000Z", updatedAt: "2026-03-01T00:00:00.000Z", lastActivityAt: "2026-03-01T00:00:00.000Z", @@ -104,13 +107,13 @@ describe("bootstrapWorkspaceRegistries", () => { const workspaces = await workspaceRegistry.list(); expect(workspaces).toHaveLength(1); - expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project"); + expect(workspaces[0]?.workspaceId).toBe(NON_GIT_PROJECT); expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); const projects = await projectRegistry.list(); expect(projects).toHaveLength(1); - expect(projects[0]?.projectId).toBe("/tmp/non-git-project"); + expect(projects[0]?.projectId).toBe(NON_GIT_PROJECT); expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); }); diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts new file mode 100644 index 000000000..1294373fc --- /dev/null +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -0,0 +1,506 @@ +// POSIX-only: worktree setup shell and terminal service fixtures +/* eslint-disable max-nested-callbacks */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, + mkdirSync, +} from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; +import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js"; +import { ScriptRouteStore } from "./script-proxy.js"; +import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; +import { isPlatform } from "../test-utils/platform.js"; +import { + createWorktree as createWorktreePrimitive, + type WorktreeConfig, +} from "../utils/worktree.js"; +import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; +import type { TerminalSession } from "../terminal/terminal.js"; + +interface CreateAgentWorktreeTestOptions { + cwd: string; + branchName: string; + baseBranch: string; + worktreeSlug: string; + paseoHome?: string; +} + +interface CreateAgentWorktreeTestResult { + worktree: WorktreeConfig; + shouldBootstrap: boolean; +} + +async function cleanupTerminalManager(terminalManager: TerminalManager): Promise { + const terminalsByCwd = await Promise.all( + terminalManager.listDirectories().map((cwd) => terminalManager.getTerminals(cwd)), + ); + const terminals = terminalsByCwd.flat(); + await Promise.all(terminals.map((terminal) => killTerminal(terminalManager, terminal))); + terminalManager.killAll(); +} + +function killTerminal(terminalManager: TerminalManager, terminal: TerminalSession): Promise { + return terminalManager.killTerminalAndWait(terminal.id, { + gracefulTimeoutMs: 100, + forceTimeoutMs: 100, + }); +} + +async function createBootstrapWorktreeForTest( + options: CreateAgentWorktreeTestOptions, +): Promise { + const worktree = await createWorktreePrimitive({ + cwd: options.cwd, + worktreeSlug: options.worktreeSlug, + source: { + kind: "branch-off", + baseBranch: options.baseBranch, + branchName: options.branchName, + }, + runSetup: false, + paseoHome: options.paseoHome, + }); + return { worktree, shouldBootstrap: true }; +} + +describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { + describe("runAsyncWorktreeBootstrap", () => { + let tempDir: string; + let repoDir: string; + let paseoHome: string; + let realTerminalManagers: TerminalManager[]; + + async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (existsSync(targetPath)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for path: ${targetPath}`); + } + + function readEnvFile(path: string): Record { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Expected env file to contain a JSON object: ${path}`); + } + + const env: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "string") { + env[key] = value; + } + } + return env; + } + + beforeEach(() => { + realTerminalManagers = []; + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); + repoDir = join(tempDir, "repo"); + paseoHome = join(tempDir, "paseo-home"); + + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + }); + + afterEach(async () => { + await Promise.all(realTerminalManagers.map(cleanupTerminalManager)); + rmSync(tempDir, { recursive: true, force: true }); + }); + it("streams running setup updates live and persists only a final setup timeline row", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'], + }, + }), + ); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], { + cwd: repoDir, + stdio: "pipe", + }); + + const worktreeBootstrap = await createBootstrapWorktreeForTest({ + cwd: repoDir, + branchName: "feature-streaming-setup", + baseBranch: "main", + worktreeSlug: "feature-streaming-setup", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + const live: AgentTimelineItem[] = []; + + await runAsyncWorktreeBootstrap({ + agentId: "agent-test", + worktree: worktreeBootstrap.worktree, + shouldBootstrap: worktreeBootstrap.shouldBootstrap, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async (item: AgentTimelineItem) => { + live.push(item); + return true; + }, + }); + + const liveSetupItems = live.filter( + (item) => + item.type === "tool_call" && + item.name === "paseo_worktree_setup" && + item.status === "running", + ); + expect(liveSetupItems.length).toBeGreaterThan(0); + + const persistedSetupItems = persisted.filter( + (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup", + ); + expect(persistedSetupItems).toHaveLength(1); + expect(persistedSetupItems[0]?.type).toBe("tool_call"); + if (persistedSetupItems[0]?.type === "tool_call") { + expect(persistedSetupItems[0].status).toBe("completed"); + expect(persistedSetupItems[0].detail.type).toBe("worktree_setup"); + + if (persistedSetupItems[0].detail.type === "worktree_setup") { + expect(persistedSetupItems[0].detail.log).toContain( + '==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2', + ); + expect(persistedSetupItems[0].detail.log).toContain("line-one"); + expect(persistedSetupItems[0].detail.log).toContain("line-two"); + expect(persistedSetupItems[0].detail.log).toContain( + '==> [2/2] Running: echo "line-three"', + ); + expect(persistedSetupItems[0].detail.log).toContain("line-three"); + expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/); + expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/); + + expect(persistedSetupItems[0].detail.commands).toHaveLength(2); + expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({ + index: 1, + command: 'echo "line-one"; echo "line-two" 1>&2', + log: expect.stringContaining("line-one"), + status: "completed", + exitCode: 0, + }); + expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two"); + expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({ + index: 2, + command: 'echo "line-three"', + log: "line-three\n", + status: "completed", + exitCode: 0, + }); + expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe( + true, + ); + expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe( + true, + ); + } + } + + const liveCallIds = new Set( + liveSetupItems + .filter( + (item): item is Extract => + item.type === "tool_call", + ) + .map((item) => item.callId), + ); + expect(liveCallIds.size).toBe(1); + if (persistedSetupItems[0]?.type === "tool_call") { + expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true); + } + }); + + it("keeps only the final carriage-return-updated content in command logs", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + `node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`, + ], + }, + }), + ); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add carriage return setup"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); + + const worktreeBootstrap = await createBootstrapWorktreeForTest({ + cwd: repoDir, + branchName: "feature-carriage-return", + baseBranch: "main", + worktreeSlug: "feature-carriage-return", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + await runAsyncWorktreeBootstrap({ + agentId: "agent-carriage-return", + worktree: worktreeBootstrap.worktree, + shouldBootstrap: worktreeBootstrap.shouldBootstrap, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => true, + }); + + const persistedSetupItem = persisted.find( + (item): item is Extract => + item.type === "tool_call" && item.name === "paseo_worktree_setup", + ); + expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); + if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { + throw new Error("Expected worktree_setup tool detail"); + } + + expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n"); + expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n"); + expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n"); + expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n"); + }); + + it("shares the same worktree runtime port across setup and bootstrap terminals", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'], + terminals: [ + { + name: "Port Terminal", + command: "true", + }, + ], + }, + }), + ); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add port setup and terminals"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); + + const worktreeBootstrap = await createBootstrapWorktreeForTest({ + cwd: repoDir, + branchName: "feature-shared-runtime-port", + baseBranch: "main", + worktreeSlug: "feature-shared-runtime-port", + paseoHome, + }); + + const registeredEnvs: Array<{ cwd: string; env: Record }> = []; + const createTerminalEnvs: Record[] = []; + const persisted: AgentTimelineItem[] = []; + await runAsyncWorktreeBootstrap({ + agentId: "agent-shared-runtime-port", + worktree: worktreeBootstrap.worktree, + shouldBootstrap: worktreeBootstrap.shouldBootstrap, + terminalManager: { + async getTerminals() { + return []; + }, + async createTerminal(options) { + createTerminalEnvs.push(options.env ?? {}); + return { + id: "term-1", + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit: () => () => {}, + onCommandFinished: () => () => {}, + onTitleChange: () => () => {}, + getSize: () => ({ rows: 1, cols: 1 }), + getTitle: () => undefined, + getExitInfo: () => null, + getState: () => ({ + rows: 1, + cols: 1, + grid: [[{ char: "$" }]], + scrollback: [], + cursor: { row: 0, col: 0 }, + }), + kill: () => {}, + killAndWait: async () => {}, + }; + }, + registerCwdEnv(options) { + registeredEnvs.push({ cwd: options.cwd, env: options.env }); + }, + getTerminal() { + return undefined; + }, + killTerminal() {}, + async killTerminalAndWait() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => true, + }); + + const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt"); + await waitForPathExists(setupPortPath); + + const setupPort = readFileSync(setupPortPath, "utf8").trim(); + expect(setupPort.length).toBeGreaterThan(0); + expect(registeredEnvs).toHaveLength(1); + expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath); + expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort); + expect(createTerminalEnvs.length).toBeGreaterThan(0); + expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort); + + const terminalToolCall = persisted.find( + (item): item is Extract => + item.type === "tool_call" && + item.name === "paseo_worktree_terminals" && + item.status === "completed", + ); + expect(terminalToolCall?.status).toBe("completed"); + }); + + it("injects real peer service env into terminal-backed services", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + api: { + type: "service", + command: + "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + }, + web: { + type: "service", + command: + "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + }, + }, + }), + ); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add real peer env services"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const terminalManager = createTerminalManager(); + realTerminalManagers.push(terminalManager); + + await Promise.all( + ["api", "web"].map((scriptName) => + spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-peer-env", + scriptName, + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager, + }), + ), + ); + + const apiEnvPath = join(repoDir, "api-env.json"); + const webEnvPath = join(repoDir, "web-env.json"); + await waitForPathExists(apiEnvPath); + await waitForPathExists(webEnvPath); + + const apiEnv = readEnvFile(apiEnvPath); + const webEnv = readEnvFile(webEnvPath); + + expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); + expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); + expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/)); + expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/)); + expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL); + expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); + expect(apiEnv).not.toHaveProperty("PORT"); + + expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); + expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); + expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); + expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT); + expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL); + expect(webEnv.PASEO_PORT).toBe(webEnv.PASEO_SERVICE_WEB_PORT); + expect(webEnv).not.toHaveProperty("PORT"); + + const apiPort = Number(apiEnv.PASEO_SERVICE_API_PORT); + const webPort = Number(apiEnv.PASEO_SERVICE_WEB_PORT); + expect(Number.isInteger(apiPort)).toBe(true); + expect(Number.isInteger(webPort)).toBe(true); + expect(routeStore.listRoutes()).toEqual([ + { + hostname: "api.feature-peer-env.repo.localhost", + port: apiPort, + workspaceId: repoDir, + projectSlug: "repo", + scriptName: "api", + }, + { + hostname: "web.feature-peer-env.repo.localhost", + port: webPort, + workspaceId: repoDir, + projectSlug: "repo", + scriptName: "web", + }, + ]); + }); + }); +}); diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 5b31cf8fe..e36ef48c4 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { execSync } from "child_process"; -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { execFileSync } from "child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -14,7 +14,7 @@ import { createWorktree as createWorktreePrimitive, type WorktreeConfig, } from "../utils/worktree.js"; -import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; +import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; interface CreateAgentWorktreeTestOptions { @@ -69,30 +69,22 @@ describe("runAsyncWorktreeBootstrap", () => { let paseoHome: string; let realTerminalManagers: TerminalManager[]; - async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (existsSync(targetPath)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - throw new Error(`Timed out waiting for path: ${targetPath}`); - } - beforeEach(() => { realTerminalManagers = []; tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); repoDir = join(tempDir, "repo"); paseoHome = join(tempDir, "paseo-home"); - execSync(`mkdir -p ${repoDir}`); - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); - execSync("echo 'hello' > file.txt", { cwd: repoDir, stdio: "pipe" }); - execSync("git add .", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); }); afterEach(async () => { @@ -100,114 +92,6 @@ describe("runAsyncWorktreeBootstrap", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("streams running setup updates live and persists only a final setup timeline row", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'], - }, - }), - ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add setup'", { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-streaming-setup", - baseBranch: "main", - worktreeSlug: "feature-streaming-setup", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - const live: AgentTimelineItem[] = []; - - await runAsyncWorktreeBootstrap({ - agentId: "agent-test", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async (item: AgentTimelineItem) => { - live.push(item); - return true; - }, - }); - - const liveSetupItems = live.filter( - (item) => - item.type === "tool_call" && - item.name === "paseo_worktree_setup" && - item.status === "running", - ); - expect(liveSetupItems.length).toBeGreaterThan(0); - - const persistedSetupItems = persisted.filter( - (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItems).toHaveLength(1); - expect(persistedSetupItems[0]?.type).toBe("tool_call"); - if (persistedSetupItems[0]?.type === "tool_call") { - expect(persistedSetupItems[0].status).toBe("completed"); - expect(persistedSetupItems[0].detail.type).toBe("worktree_setup"); - - if (persistedSetupItems[0].detail.type === "worktree_setup") { - expect(persistedSetupItems[0].detail.log).toContain( - '==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2', - ); - expect(persistedSetupItems[0].detail.log).toContain("line-one"); - expect(persistedSetupItems[0].detail.log).toContain("line-two"); - expect(persistedSetupItems[0].detail.log).toContain('==> [2/2] Running: echo "line-three"'); - expect(persistedSetupItems[0].detail.log).toContain("line-three"); - expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/); - expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/); - - expect(persistedSetupItems[0].detail.commands).toHaveLength(2); - expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({ - index: 1, - command: 'echo "line-one"; echo "line-two" 1>&2', - log: expect.stringContaining("line-one"), - status: "completed", - exitCode: 0, - }); - expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two"); - expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({ - index: 2, - command: 'echo "line-three"', - log: "line-three\n", - status: "completed", - exitCode: 0, - }); - expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe( - true, - ); - expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe( - true, - ); - } - } - - const liveCallIds = new Set( - liveSetupItems - .filter( - (item): item is Extract => - item.type === "tool_call", - ) - .map((item) => item.callId), - ); - expect(liveCallIds.size).toBe(1); - if (persistedSetupItems[0]?.type === "tool_call") { - expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true); - } - }); - it("does not fail setup when live timeline emission throws", async () => { writeFileSync( join(repoDir, "paseo.json"), @@ -217,8 +101,8 @@ describe("runAsyncWorktreeBootstrap", () => { }, }), ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add setup'", { + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], { cwd: repoDir, stdio: "pipe", }); @@ -268,8 +152,8 @@ describe("runAsyncWorktreeBootstrap", () => { }, }), ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add large output setup'", { + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add large output setup"], { cwd: repoDir, stdio: "pipe", }); @@ -316,59 +200,6 @@ describe("runAsyncWorktreeBootstrap", () => { ); }); - it("keeps only the final carriage-return-updated content in command logs", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [ - `node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`, - ], - }, - }), - ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add carriage return setup'", { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-carriage-return", - baseBranch: "main", - worktreeSlug: "feature-carriage-return", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - await runAsyncWorktreeBootstrap({ - agentId: "agent-carriage-return", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => true, - }); - - const persistedSetupItem = persisted.find( - (item): item is Extract => - item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); - if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { - throw new Error("Expected worktree_setup tool detail"); - } - - expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n"); - expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n"); - expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n"); - expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n"); - }); - it("waits for terminal output before sending bootstrap commands", async () => { writeFileSync( join(repoDir, "paseo.json"), @@ -383,11 +214,15 @@ describe("runAsyncWorktreeBootstrap", () => { }, }), ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", { - cwd: repoDir, - stdio: "pipe", - }); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add terminal bootstrap config"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); const worktreeBootstrap = await createBootstrapWorktreeForTest({ cwd: repoDir, @@ -467,114 +302,6 @@ describe("runAsyncWorktreeBootstrap", () => { expect(sendAt).toBeGreaterThanOrEqual(readyAt); }); - it("shares the same worktree runtime port across setup and bootstrap terminals", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'], - terminals: [ - { - name: "Port Terminal", - command: "true", - }, - ], - }, - }), - ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add port setup and terminals'", { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-shared-runtime-port", - baseBranch: "main", - worktreeSlug: "feature-shared-runtime-port", - paseoHome, - }); - - const registeredEnvs: Array<{ cwd: string; env: Record }> = []; - const createTerminalEnvs: Record[] = []; - const persisted: AgentTimelineItem[] = []; - await runAsyncWorktreeBootstrap({ - agentId: "agent-shared-runtime-port", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: { - async getTerminals() { - return []; - }, - async createTerminal(options) { - createTerminalEnvs.push(options.env ?? {}); - return { - id: "term-1", - name: options.name ?? "Terminal", - cwd: options.cwd, - send: () => {}, - subscribe: () => () => {}, - onExit: () => () => {}, - onCommandFinished: () => () => {}, - onTitleChange: () => () => {}, - getSize: () => ({ rows: 1, cols: 1 }), - getTitle: () => undefined, - getExitInfo: () => null, - getState: () => ({ - rows: 1, - cols: 1, - grid: [[{ char: "$" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), - kill: () => {}, - killAndWait: async () => {}, - }; - }, - registerCwdEnv(options) { - registeredEnvs.push({ cwd: options.cwd, env: options.env }); - }, - getTerminal() { - return undefined; - }, - killTerminal() {}, - async killTerminalAndWait() {}, - listDirectories() { - return []; - }, - killAll() {}, - subscribeTerminalsChanged() { - return () => {}; - }, - }, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => true, - }); - - const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt"); - await waitForPathExists(setupPortPath); - - const setupPort = readFileSync(setupPortPath, "utf8").trim(); - expect(setupPort.length).toBeGreaterThan(0); - expect(registeredEnvs).toHaveLength(1); - expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath); - expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort); - expect(createTerminalEnvs.length).toBeGreaterThan(0); - expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort); - - const terminalToolCall = persisted.find( - (item): item is Extract => - item.type === "tool_call" && - item.name === "paseo_worktree_terminals" && - item.status === "completed", - ); - expect(terminalToolCall?.status).toBe("completed"); - }); - interface CreateTerminalCall { cwd: string; name?: string; @@ -679,21 +406,6 @@ describe("runAsyncWorktreeBootstrap", () => { }; } - function readEnvFile(path: string): Record { - const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error(`Expected env file to contain a JSON object: ${path}`); - } - - const env: Record = {}; - for (const [key, value] of Object.entries(parsed)) { - if (typeof value === "string") { - env[key] = value; - } - } - return env; - } - function assertServiceTerminalCallSelfEnv(params: { createTerminalCalls: CreateTerminalCall[]; terminalRecords: StubTerminalRecord[]; @@ -759,8 +471,8 @@ describe("runAsyncWorktreeBootstrap", () => { message = "add script config", ): void { writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts })); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync(`git -c commit.gpgsign=false commit -m ${JSON.stringify(message)}`, { + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd: repoDir, stdio: "pipe", }); @@ -1117,11 +829,15 @@ describe("runAsyncWorktreeBootstrap", () => { }, }), ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add respawn service script config'", { - cwd: repoDir, - stdio: "pipe", - }); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add respawn service script config"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); @@ -1216,11 +932,15 @@ describe("runAsyncWorktreeBootstrap", () => { }, }), ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add renamed service script config'", { - cwd: repoDir, - stdio: "pipe", - }); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add renamed service script config"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); @@ -1278,11 +998,15 @@ describe("runAsyncWorktreeBootstrap", () => { }, }), ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add colliding service config'", { - cwd: repoDir, - stdio: "pipe", - }); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add colliding service config"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); @@ -1350,97 +1074,6 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls[0]?.env).toHaveProperty("PASEO_SERVICE_WORKER_PORT"); }); - it("injects real peer service env into terminal-backed services", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - scripts: { - api: { - type: "service", - command: - "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", - }, - web: { - type: "service", - command: - "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", - }, - }, - }), - ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add real peer env services'", { - cwd: repoDir, - stdio: "pipe", - }); - - const routeStore = new ScriptRouteStore(); - const runtimeStore = new WorkspaceScriptRuntimeStore(); - const terminalManager = createTerminalManager(); - realTerminalManagers.push(terminalManager); - - await Promise.all( - ["api", "web"].map((scriptName) => - spawnWorkspaceScript({ - repoRoot: repoDir, - workspaceId: repoDir, - projectSlug: "repo", - branchName: "feature-peer-env", - scriptName, - daemonPort: 6767, - routeStore, - runtimeStore, - terminalManager, - }), - ), - ); - - const apiEnvPath = join(repoDir, "api-env.json"); - const webEnvPath = join(repoDir, "web-env.json"); - await waitForPathExists(apiEnvPath); - await waitForPathExists(webEnvPath); - - const apiEnv = readEnvFile(apiEnvPath); - const webEnv = readEnvFile(webEnvPath); - - expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); - expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); - expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/)); - expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/)); - expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL); - expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); - expect(apiEnv).not.toHaveProperty("PORT"); - - expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); - expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); - expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); - expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT); - expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL); - expect(webEnv.PASEO_PORT).toBe(webEnv.PASEO_SERVICE_WEB_PORT); - expect(webEnv).not.toHaveProperty("PORT"); - - const apiPort = Number(apiEnv.PASEO_SERVICE_API_PORT); - const webPort = Number(apiEnv.PASEO_SERVICE_WEB_PORT); - expect(Number.isInteger(apiPort)).toBe(true); - expect(Number.isInteger(webPort)).toBe(true); - expect(routeStore.listRoutes()).toEqual([ - { - hostname: "api.feature-peer-env.repo.localhost", - port: apiPort, - workspaceId: repoDir, - projectSlug: "repo", - scriptName: "api", - }, - { - hostname: "web.feature-peer-env.repo.localhost", - port: webPort, - workspaceId: repoDir, - projectSlug: "repo", - scriptName: "web", - }, - ]); - }); - it("binds services to the network when the daemon listens on a non-loopback host", async () => { writeFileSync( join(repoDir, "paseo.json"), @@ -1453,11 +1086,15 @@ describe("runAsyncWorktreeBootstrap", () => { }, }), ); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add remote service script config'", { - cwd: repoDir, - stdio: "pipe", - }); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add remote service script config"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); const routeStore = new ScriptRouteStore(); const runtimeStore = new WorkspaceScriptRuntimeStore(); diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts new file mode 100644 index 000000000..f328791f7 --- /dev/null +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -0,0 +1,611 @@ +// POSIX-only: git worktree reuse fixtures +/* eslint-disable max-nested-callbacks */ +import { execFileSync, spawnSync } from "node:child_process"; +import { + mkdirSync, + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, test, afterEach } from "vitest"; +import type { GitHubService } from "../services/github-service.js"; +import { UnknownBranchError } from "../utils/worktree.js"; +import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js"; +import { isPlatform } from "../test-utils/platform.js"; + +function createGitHubServiceStub(): GitHubService { + return { + listPullRequests: async () => [], + listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), + getPullRequest: async ({ number }) => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/repo/pull/${number}`, + state: "OPEN", + body: null, + baseRefName: "main", + headRefName: `pr-${number}`, + labels: [], + }), + getPullRequestHeadRef: async ({ number }) => `pr-${number}`, + getCurrentPullRequestStatus: async () => null, + createPullRequest: async () => ({ + number: 1, + url: "https://github.com/acme/repo/pull/1", + }), + isAuthenticated: async () => true, + invalidate: () => {}, + }; +} + +function createCoreDeps(options?: { github?: GitHubService }) { + return { + github: options?.github ?? createGitHubServiceStub(), + workspaceGitService: { + resolveRepoRoot: async (cwd: string) => cwd, + }, + resolveDefaultBranch: async () => "main", + }; +} + +function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-"))); + const repoDir = path.join(tempDir, "repo"); + const paseoHome = path.join(tempDir, ".paseo"); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + return { tempDir, repoDir, paseoHome }; +} + +function createGitRepoWithDevBranch(): { tempDir: string; repoDir: string; paseoHome: string } { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "dev branch\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "dev branch"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); + return { tempDir, repoDir, paseoHome }; +} + +function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; paseoHome: string } { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + const featureBranch = "feature/review-pr"; + execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "review branch\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "review branch"], { + cwd: repoDir, + stdio: "pipe", + }); + const featureSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .trim(); + execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" }); + + const remoteDir = path.join(tempDir, "remote.git"); + execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { + stdio: "pipe", + }); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", featureSha], { + stdio: "pipe", + }); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" }); + + return { tempDir, repoDir, paseoHome }; +} + +function createForkGitHubPrRemoteRepo(): { + tempDir: string; + repoDir: string; + headRemoteDir: string; + paseoHome: string; +} { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + const baseRemoteDir = path.join(tempDir, "base.git"); + const headRemoteDir = path.join(tempDir, "therainisme.git"); + const headCloneDir = path.join(tempDir, "therainisme-clone"); + + execFileSync("git", ["clone", "--bare", repoDir, baseRemoteDir], { + stdio: "pipe", + }); + execFileSync("git", ["clone", "--bare", repoDir, headRemoteDir], { + stdio: "pipe", + }); + execFileSync("git", ["remote", "add", "origin", baseRemoteDir], { + cwd: repoDir, + stdio: "pipe", + }); + + execFileSync("git", ["clone", headRemoteDir, headCloneDir], { + stdio: "pipe", + }); + execFileSync("git", ["config", "user.email", "test@test.com"], { + cwd: headCloneDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: headCloneDir, stdio: "pipe" }); + writeFileSync(path.join(headCloneDir, "README.md"), "fork pr main branch\n"); + execFileSync("git", ["add", "README.md"], { cwd: headCloneDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork pr main branch"], { + cwd: headCloneDir, + stdio: "pipe", + }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: headCloneDir, stdio: "pipe" }) + .toString() + .trim(); + execFileSync("git", ["push", "origin", "main"], { cwd: headCloneDir, stdio: "pipe" }); + execFileSync("git", [`--git-dir=${baseRemoteDir}`, "fetch", headRemoteDir, "main"], { + stdio: "pipe", + }); + execFileSync("git", [`--git-dir=${baseRemoteDir}`, "update-ref", "refs/pull/526/head", prHead], { + stdio: "pipe", + }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" }); + + return { tempDir, repoDir, headRemoteDir, paseoHome }; +} + +describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { + describe("createWorktreeCore", () => { + const cleanupPaths: string[] = []; + + afterEach(() => { + for (const target of cleanupPaths.splice(0)) { + rmSync(target, { recursive: true, force: true }); + } + }); + + test("creates the legacy RPC branch-off worktree from the repo default branch", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "legacy-rpc", + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + expect(result.intent).toEqual({ + kind: "branch-off", + baseBranch: "main", + branchName: "legacy-rpc", + }); + expect(result.created).toBe(true); + expect(result.worktree.branchName).toBe("legacy-rpc"); + expect(existsSync(result.worktree.worktreePath)).toBe(true); + }); + + test("creates a branch-off worktree with a mnemonic slug when no slug is supplied", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + expect(result.intent.kind).toBe("branch-off"); + expect(result.created).toBe(true); + expect(result.worktree.branchName).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); + expect(result.worktree.branchName).toBe(path.basename(result.worktree.worktreePath)); + expect(existsSync(result.worktree.worktreePath)).toBe(true); + }); + + test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => { + const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "review-pr-123", + githubPrNumber: 123, + refName: "feature/review-pr", + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + expect(result.intent).toEqual({ + kind: "checkout-github-pr", + githubPrNumber: 123, + headRef: "feature/review-pr", + baseRefName: "main", + }); + expect(result.worktree.branchName).toBe("feature/review-pr"); + }); + + test("uses the PR head ref as the default slug when no slug is supplied", async () => { + const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + githubPrNumber: 123, + refName: "feature/review-pr", + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + expect(path.basename(result.worktree.worktreePath)).toBe("feature-review-pr"); + expect(result.worktree.branchName).toBe("feature/review-pr"); + }); + + test("creates the MCP standalone worktree input shape", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "mcp-standalone", + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + expect(result.intent).toEqual({ + kind: "branch-off", + baseBranch: "main", + branchName: "mcp-standalone", + }); + expect(result.worktree.branchName).toBe("mcp-standalone"); + }); + + test("branches off an explicit refName base", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch(); + cleanupPaths.push(tempDir); + const devTip = execFileSync("git", ["rev-parse", "dev"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .trim(); + + const result = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "from-dev", + action: "branch-off", + refName: "dev", + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + const mergeBase = execFileSync("git", ["merge-base", "HEAD", devTip], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + expect(result.intent).toEqual({ + kind: "branch-off", + baseBranch: "dev", + branchName: "from-dev", + }); + expect(mergeBase).toBe(devTip); + }); + + test("checks out an explicit existing branch", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + action: "checkout", + refName: "dev", + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + const branch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + expect(result.intent).toEqual({ + kind: "checkout-branch", + branchName: "dev", + }); + expect(branch).toBe("dev"); + }); + + test("checks out an explicit GitHub PR target", async () => { + const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + action: "checkout", + githubPrNumber: 123, + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + expect(result.intent).toEqual({ + kind: "checkout-github-pr", + githubPrNumber: 123, + headRef: "pr-123", + baseRefName: "main", + }); + expect(result.worktree.branchName).toBe("pr-123"); + }); + + test("checks out a fork PR whose head branch collides with local main", async () => { + const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + const github = { + ...createGitHubServiceStub(), + getPullRequestCheckoutTarget: async () => ({ + number: 526, + baseRefName: "main", + headRefName: "main", + headOwnerLogin: "therainisme", + headRepositorySshUrl: headRemoteDir, + headRepositoryUrl: headRemoteDir, + isCrossRepository: true, + }), + }; + + const result = await createCoreWorktree( + { + cwd: repoDir, + action: "checkout", + githubPrNumber: 526, + refName: "main", + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + + const sourceBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: repoDir, + stdio: "pipe", + }) + .toString() + .trim(); + const worktreeBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8"); + writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "maintainer edit\n"); + execFileSync("git", ["add", "FOLLOWUP.md"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "maintainer edit"], { + cwd: result.worktree.worktreePath, + stdio: "pipe", + }); + const pushDryRunResult = spawnSync("git", ["push", "--dry-run"], { + cwd: result.worktree.worktreePath, + encoding: "utf8", + }); + const pushDryRun = `${pushDryRunResult.stdout}${pushDryRunResult.stderr}`; + + expect(sourceBranch).toBe("main"); + expect(result.intent).toEqual({ + kind: "checkout-github-pr", + githubPrNumber: 526, + headRef: "main", + baseRefName: "main", + localBranchName: "therainisme/main", + pushRemoteUrl: headRemoteDir, + }); + expect(result.worktree.branchName).toBe("therainisme/main"); + expect(path.basename(result.worktree.worktreePath)).toBe("therainisme-main"); + expect(worktreeBranch).toBe("therainisme/main"); + expect(readme.replace(/\r\n/g, "\n")).toBe("fork pr main branch\n"); + expect(pushDryRun).toContain("HEAD -> main"); + }); + + test("uses a unique local branch when the same fork PR branch already exists", async () => { + const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + const github = { + ...createGitHubServiceStub(), + getPullRequestCheckoutTarget: async () => ({ + number: 526, + baseRefName: "main", + headRefName: "main", + headOwnerLogin: "therainisme", + headRepositorySshUrl: headRemoteDir, + headRepositoryUrl: headRemoteDir, + isCrossRepository: true, + }), + }; + + const first = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "first-pr-worktree", + action: "checkout", + githubPrNumber: 526, + refName: "main", + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + const second = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "second-pr-worktree", + action: "checkout", + githubPrNumber: 526, + refName: "main", + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + + expect(first.worktree.branchName).toBe("therainisme/main"); + expect(second.worktree.branchName).toBe("therainisme/main-1"); + expect( + execFileSync("git", ["config", "--get", "remote.paseo-pr-526.push"], { + cwd: second.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("HEAD:refs/heads/main"); + }); + + test("throws a typed error for an unknown checkout branch", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + + await expect( + createCoreWorktree( + { + cwd: repoDir, + action: "checkout", + refName: "missing-branch", + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ), + ).rejects.toBeInstanceOf(UnknownBranchError); + }); + + test("creates the agent-create worktree input shape", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + + const result = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "agent-worktree", + paseoHome, + runSetup: false, + }, + createCoreDeps(), + ); + + expect(result.intent).toEqual({ + kind: "branch-off", + baseBranch: "main", + branchName: "agent-worktree", + }); + expect(result.worktree.branchName).toBe("agent-worktree"); + }); + + // POSIX-only: Windows git worktree paths need separate canonicalization coverage. + test("reuses an existing branch-off worktree for the same slug", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + const deps = createCoreDeps(); + + const first = await createCoreWorktree( + { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false }, + deps, + ); + const second = await createCoreWorktree( + { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false }, + deps, + ); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.worktree).toEqual(first.worktree); + }); + + // POSIX-only: Windows git worktree paths need separate canonicalization coverage. + test("reuses an existing GitHub PR worktree for the resolved slug", async () => { + const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + const deps = createCoreDeps(); + const input = { + cwd: repoDir, + githubPrNumber: 123, + refName: "feature/review-pr", + paseoHome, + runSetup: false, + }; + + const first = await createCoreWorktree(input, deps); + const second = await createCoreWorktree(input, deps); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.worktree).toEqual(first.worktree); + }); + + test("uses an injectable GitHubService dependency for missing PR head refs", async () => { + const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + const headRefLookups: Array<{ cwd: string; number: number }> = []; + const github: GitHubService = { + ...createGitHubServiceStub(), + getPullRequestHeadRef: async ({ cwd, number }) => { + headRefLookups.push({ cwd, number }); + return "feature/from-service"; + }, + }; + + const result = await createCoreWorktree( + { + cwd: repoDir, + worktreeSlug: "stubbed-github", + githubPrNumber: 123, + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + + expect(headRefLookups).toEqual([{ cwd: repoDir, number: 123 }]); + expect(result.intent).toEqual({ + kind: "checkout-github-pr", + githubPrNumber: 123, + headRef: "feature/from-service", + baseRefName: "main", + }); + expect(result.worktree.branchName).toBe("feature/from-service"); + }); + }); +}); diff --git a/packages/server/src/server/worktree-core.test.ts b/packages/server/src/server/worktree-core.test.ts deleted file mode 100644 index 648713e20..000000000 --- a/packages/server/src/server/worktree-core.test.ts +++ /dev/null @@ -1,615 +0,0 @@ -import { execSync } from "node:child_process"; -import { - existsSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { describe, expect, test, afterEach, vi } from "vitest"; - -import type { GitHubService } from "../services/github-service.js"; -import { UnknownBranchError } from "../utils/worktree.js"; -import { - createWorktreeCore as createCoreWorktree, - resolveWorktreeRepoRoot, -} from "./worktree-core.js"; - -function createGitHubServiceStub(): GitHubService { - return { - listPullRequests: async () => [], - listIssues: async () => [], - searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), - getPullRequest: async ({ number }) => ({ - number, - title: `PR ${number}`, - url: `https://github.com/acme/repo/pull/${number}`, - state: "OPEN", - body: null, - baseRefName: "main", - headRefName: `pr-${number}`, - labels: [], - }), - getPullRequestHeadRef: async ({ number }) => `pr-${number}`, - getCurrentPullRequestStatus: async () => null, - createPullRequest: async () => ({ - number: 1, - url: "https://github.com/acme/repo/pull/1", - }), - isAuthenticated: async () => true, - invalidate: () => {}, - }; -} - -function createCoreDeps(options?: { github?: GitHubService }) { - return { - github: options?.github ?? createGitHubServiceStub(), - workspaceGitService: { - resolveRepoRoot: async (cwd: string) => cwd, - }, - resolveDefaultBranch: async () => "main", - }; -} - -function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } { - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-"))); - const repoDir = path.join(tempDir, "repo"); - const paseoHome = path.join(tempDir, ".paseo"); - execSync(`mkdir -p ${JSON.stringify(repoDir)}`); - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); - writeFileSync(path.join(repoDir, "README.md"), "hello\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); - return { tempDir, repoDir, paseoHome }; -} - -function createGitRepoWithDevBranch(): { tempDir: string; repoDir: string; paseoHome: string } { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" }); - writeFileSync(path.join(repoDir, "README.md"), "dev branch\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'dev branch'", { - cwd: repoDir, - stdio: "pipe", - }); - execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); - return { tempDir, repoDir, paseoHome }; -} - -function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; paseoHome: string } { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - const featureBranch = "feature/review-pr"; - execSync(`git checkout -b ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" }); - writeFileSync(path.join(repoDir, "README.md"), "review branch\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'review branch'", { - cwd: repoDir, - stdio: "pipe", - }); - const featureSha = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }) - .toString() - .trim(); - execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); - execSync(`git branch -D ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" }); - - const remoteDir = path.join(tempDir, "remote.git"); - execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, { - stdio: "pipe", - }); - execSync( - `git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`, - { stdio: "pipe" }, - ); - execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" }); - execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" }); - - return { tempDir, repoDir, paseoHome }; -} - -function createForkGitHubPrRemoteRepo(): { - tempDir: string; - repoDir: string; - headRemoteDir: string; - paseoHome: string; -} { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - const baseRemoteDir = path.join(tempDir, "base.git"); - const headRemoteDir = path.join(tempDir, "therainisme.git"); - const headCloneDir = path.join(tempDir, "therainisme-clone"); - - execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(baseRemoteDir)}`, { - stdio: "pipe", - }); - execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(headRemoteDir)}`, { - stdio: "pipe", - }); - execSync(`git remote add origin ${JSON.stringify(baseRemoteDir)}`, { - cwd: repoDir, - stdio: "pipe", - }); - - execSync(`git clone ${JSON.stringify(headRemoteDir)} ${JSON.stringify(headCloneDir)}`, { - stdio: "pipe", - }); - execSync("git config user.email 'test@test.com'", { cwd: headCloneDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: headCloneDir, stdio: "pipe" }); - writeFileSync(path.join(headCloneDir, "README.md"), "fork pr main branch\n"); - execSync("git add README.md", { cwd: headCloneDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'fork pr main branch'", { - cwd: headCloneDir, - stdio: "pipe", - }); - const prHead = execSync("git rev-parse HEAD", { cwd: headCloneDir, stdio: "pipe" }) - .toString() - .trim(); - execSync("git push origin main", { cwd: headCloneDir, stdio: "pipe" }); - execSync( - `git --git-dir=${JSON.stringify(baseRemoteDir)} fetch ${JSON.stringify(headRemoteDir)} main`, - { stdio: "pipe" }, - ); - execSync( - `git --git-dir=${JSON.stringify(baseRemoteDir)} update-ref refs/pull/526/head ${prHead}`, - { stdio: "pipe" }, - ); - execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" }); - - return { tempDir, repoDir, headRemoteDir, paseoHome }; -} - -describe.skipIf(process.platform === "win32")("createWorktreeCore", () => { - const cleanupPaths: string[] = []; - - afterEach(() => { - for (const target of cleanupPaths.splice(0)) { - rmSync(target, { recursive: true, force: true }); - } - }); - - test("creates the legacy RPC branch-off worktree from the repo default branch", async () => { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - cleanupPaths.push(tempDir); - - const result = await createCoreWorktree( - { - cwd: repoDir, - worktreeSlug: "legacy-rpc", - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - expect(result.intent).toEqual({ - kind: "branch-off", - baseBranch: "main", - branchName: "legacy-rpc", - }); - expect(result.created).toBe(true); - expect(result.worktree.branchName).toBe("legacy-rpc"); - expect(existsSync(result.worktree.worktreePath)).toBe(true); - }); - - test("creates a branch-off worktree with a mnemonic slug when no slug is supplied", async () => { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - cleanupPaths.push(tempDir); - - const result = await createCoreWorktree( - { - cwd: repoDir, - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - expect(result.intent.kind).toBe("branch-off"); - expect(result.created).toBe(true); - expect(result.worktree.branchName).toMatch(/^[a-z0-9]+-[a-z0-9]+$/); - expect(result.worktree.branchName).toBe(path.basename(result.worktree.worktreePath)); - expect(existsSync(result.worktree.worktreePath)).toBe(true); - }); - - test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => { - const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - - const result = await createCoreWorktree( - { - cwd: repoDir, - worktreeSlug: "review-pr-123", - githubPrNumber: 123, - refName: "feature/review-pr", - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - expect(result.intent).toEqual({ - kind: "checkout-github-pr", - githubPrNumber: 123, - headRef: "feature/review-pr", - baseRefName: "main", - }); - expect(result.worktree.branchName).toBe("feature/review-pr"); - }); - - test("uses the PR head ref as the default slug when no slug is supplied", async () => { - const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - - const result = await createCoreWorktree( - { - cwd: repoDir, - githubPrNumber: 123, - refName: "feature/review-pr", - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - expect(path.basename(result.worktree.worktreePath)).toBe("feature-review-pr"); - expect(result.worktree.branchName).toBe("feature/review-pr"); - }); - - test("creates the MCP standalone worktree input shape", async () => { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - cleanupPaths.push(tempDir); - - const result = await createCoreWorktree( - { - cwd: repoDir, - worktreeSlug: "mcp-standalone", - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - expect(result.intent).toEqual({ - kind: "branch-off", - baseBranch: "main", - branchName: "mcp-standalone", - }); - expect(result.worktree.branchName).toBe("mcp-standalone"); - }); - - test("branches off an explicit refName base", async () => { - const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch(); - cleanupPaths.push(tempDir); - const devTip = execSync("git rev-parse dev", { cwd: repoDir, stdio: "pipe" }).toString().trim(); - - const result = await createCoreWorktree( - { - cwd: repoDir, - worktreeSlug: "from-dev", - action: "branch-off", - refName: "dev", - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - const mergeBase = execSync(`git merge-base HEAD ${JSON.stringify(devTip)}`, { - cwd: result.worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(); - expect(result.intent).toEqual({ - kind: "branch-off", - baseBranch: "dev", - branchName: "from-dev", - }); - expect(mergeBase).toBe(devTip); - }); - - test("checks out an explicit existing branch", async () => { - const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch(); - cleanupPaths.push(tempDir); - - const result = await createCoreWorktree( - { - cwd: repoDir, - action: "checkout", - refName: "dev", - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - const branch = execSync("git branch --show-current", { - cwd: result.worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(); - expect(result.intent).toEqual({ - kind: "checkout-branch", - branchName: "dev", - }); - expect(branch).toBe("dev"); - }); - - test("checks out an explicit GitHub PR target", async () => { - const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - - const result = await createCoreWorktree( - { - cwd: repoDir, - action: "checkout", - githubPrNumber: 123, - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - expect(result.intent).toEqual({ - kind: "checkout-github-pr", - githubPrNumber: 123, - headRef: "pr-123", - baseRefName: "main", - }); - expect(result.worktree.branchName).toBe("pr-123"); - }); - - test("checks out a fork PR whose head branch collides with local main", async () => { - const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - const github = { - ...createGitHubServiceStub(), - getPullRequestCheckoutTarget: async () => ({ - number: 526, - baseRefName: "main", - headRefName: "main", - headOwnerLogin: "therainisme", - headRepositorySshUrl: headRemoteDir, - headRepositoryUrl: headRemoteDir, - isCrossRepository: true, - }), - }; - - const result = await createCoreWorktree( - { - cwd: repoDir, - action: "checkout", - githubPrNumber: 526, - refName: "main", - paseoHome, - runSetup: false, - }, - createCoreDeps({ github }), - ); - - const sourceBranch = execSync("git branch --show-current", { cwd: repoDir, stdio: "pipe" }) - .toString() - .trim(); - const worktreeBranch = execSync("git branch --show-current", { - cwd: result.worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(); - const readme = readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8"); - writeFileSync(path.join(result.worktree.worktreePath, "FOLLOWUP.md"), "maintainer edit\n"); - execSync("git add FOLLOWUP.md", { cwd: result.worktree.worktreePath, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'maintainer edit'", { - cwd: result.worktree.worktreePath, - stdio: "pipe", - }); - const pushDryRun = execSync("git push --dry-run 2>&1", { - cwd: result.worktree.worktreePath, - stdio: "pipe", - }).toString(); - - expect(sourceBranch).toBe("main"); - expect(result.intent).toEqual({ - kind: "checkout-github-pr", - githubPrNumber: 526, - headRef: "main", - baseRefName: "main", - localBranchName: "therainisme/main", - pushRemoteUrl: headRemoteDir, - }); - expect(result.worktree.branchName).toBe("therainisme/main"); - expect(path.basename(result.worktree.worktreePath)).toBe("therainisme-main"); - expect(worktreeBranch).toBe("therainisme/main"); - expect(readme).toBe("fork pr main branch\n"); - expect(pushDryRun).toContain("HEAD -> main"); - }); - - test("uses a unique local branch when the same fork PR branch already exists", async () => { - const { tempDir, repoDir, headRemoteDir, paseoHome } = createForkGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - const github = { - ...createGitHubServiceStub(), - getPullRequestCheckoutTarget: async () => ({ - number: 526, - baseRefName: "main", - headRefName: "main", - headOwnerLogin: "therainisme", - headRepositorySshUrl: headRemoteDir, - headRepositoryUrl: headRemoteDir, - isCrossRepository: true, - }), - }; - - const first = await createCoreWorktree( - { - cwd: repoDir, - worktreeSlug: "first-pr-worktree", - action: "checkout", - githubPrNumber: 526, - refName: "main", - paseoHome, - runSetup: false, - }, - createCoreDeps({ github }), - ); - const second = await createCoreWorktree( - { - cwd: repoDir, - worktreeSlug: "second-pr-worktree", - action: "checkout", - githubPrNumber: 526, - refName: "main", - paseoHome, - runSetup: false, - }, - createCoreDeps({ github }), - ); - - expect(first.worktree.branchName).toBe("therainisme/main"); - expect(second.worktree.branchName).toBe("therainisme/main-1"); - expect( - execSync("git config --get remote.paseo-pr-526.push", { - cwd: second.worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(), - ).toBe("HEAD:refs/heads/main"); - }); - - test("throws a typed error for an unknown checkout branch", async () => { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - cleanupPaths.push(tempDir); - - await expect( - createCoreWorktree( - { - cwd: repoDir, - action: "checkout", - refName: "missing-branch", - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ), - ).rejects.toBeInstanceOf(UnknownBranchError); - }); - - test("creates the agent-create worktree input shape", async () => { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - cleanupPaths.push(tempDir); - - const result = await createCoreWorktree( - { - cwd: repoDir, - worktreeSlug: "agent-worktree", - paseoHome, - runSetup: false, - }, - createCoreDeps(), - ); - - expect(result.intent).toEqual({ - kind: "branch-off", - baseBranch: "main", - branchName: "agent-worktree", - }); - expect(result.worktree.branchName).toBe("agent-worktree"); - }); - - test("reuses an existing branch-off worktree for the same slug", async () => { - const { tempDir, repoDir, paseoHome } = createGitRepo(); - cleanupPaths.push(tempDir); - const deps = createCoreDeps(); - - const first = await createCoreWorktree( - { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false }, - deps, - ); - const second = await createCoreWorktree( - { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false }, - deps, - ); - - expect(first.created).toBe(true); - expect(second.created).toBe(false); - expect(second.worktree).toEqual(first.worktree); - }); - - test("reuses an existing GitHub PR worktree for the resolved slug", async () => { - const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - const deps = createCoreDeps(); - const input = { - cwd: repoDir, - githubPrNumber: 123, - refName: "feature/review-pr", - paseoHome, - runSetup: false, - }; - - const first = await createCoreWorktree(input, deps); - const second = await createCoreWorktree(input, deps); - - expect(first.created).toBe(true); - expect(second.created).toBe(false); - expect(second.worktree).toEqual(first.worktree); - }); - - test("uses an injectable GitHubService dependency for missing PR head refs", async () => { - const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - const headRefLookups: Array<{ cwd: string; number: number }> = []; - const github: GitHubService = { - ...createGitHubServiceStub(), - getPullRequestHeadRef: async ({ cwd, number }) => { - headRefLookups.push({ cwd, number }); - return "feature/from-service"; - }, - }; - - const result = await createCoreWorktree( - { - cwd: repoDir, - worktreeSlug: "stubbed-github", - githubPrNumber: 123, - paseoHome, - runSetup: false, - }, - createCoreDeps({ github }), - ); - - expect(headRefLookups).toEqual([{ cwd: repoDir, number: 123 }]); - expect(result.intent).toEqual({ - kind: "checkout-github-pr", - githubPrNumber: 123, - headRef: "feature/from-service", - baseRefName: "main", - }); - expect(result.worktree.branchName).toBe("feature/from-service"); - }); -}); - -describe("resolveWorktreeRepoRoot", () => { - test("resolves repository roots through the workspace git service", async () => { - const workspaceGitService = { - resolveRepoRoot: vi.fn().mockResolvedValue("/tmp/main-repo"), - }; - - await expect( - resolveWorktreeRepoRoot( - { cwd: "/tmp/main-repo/worktrees/feature", paseoHome: "/tmp/paseo-home" }, - workspaceGitService, - ), - ).resolves.toBe("/tmp/main-repo"); - - expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledTimes(1); - expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith( - "/tmp/main-repo/worktrees/feature", - ); - }); -}); diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 621a017ad..ff4226a04 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -1,5 +1,6 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { + mkdirSync, existsSync, mkdtempSync, readFileSync, @@ -41,6 +42,7 @@ import { } from "./paseo-worktree-service.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { isPlatform } from "../test-utils/platform.js"; interface LegacyCreateWorktreeTestOptions { branchName: string; @@ -484,49 +486,49 @@ function createWorkspaceArchivingDeps() { } function createGitRepo(options?: { paseoConfig?: Record }) { - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-"))); + const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-session-test-"))); const repoDir = path.join(tempDir, "repo"); - execSync(`mkdir -p ${JSON.stringify(repoDir)}`); - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "README.md"), "hello\n"); if (options?.paseoConfig) { writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); } - execSync("git add .", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); return { tempDir, repoDir }; } function createGitHubPrRemoteRepo() { const { tempDir, repoDir } = createGitRepo(); const featureBranch = "feature/review-pr"; - execSync(`git checkout -b ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["checkout", "-b", featureBranch], { cwd: repoDir, stdio: "pipe" }); writeFileSync(path.join(repoDir, "README.md"), "review branch\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'review branch'", { + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "review branch"], { cwd: repoDir, stdio: "pipe", }); - const featureSha = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }) + const featureSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" }) .toString() .trim(); - execSync("git checkout main", { cwd: repoDir, stdio: "pipe" }); - execSync(`git branch -D ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-D", featureBranch], { cwd: repoDir, stdio: "pipe" }); const remoteDir = path.join(tempDir, "remote.git"); - execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, { + execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { stdio: "pipe", }); - execSync( - `git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`, - { - stdio: "pipe", - }, - ); - execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" }); - execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", featureSha], { + stdio: "pipe", + }); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" }); return { tempDir, repoDir }; } @@ -645,8 +647,8 @@ describe("runWorktreeSetupInBackground", () => { cleanupPaths.push(tempDir); writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n"); - execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'broken config'", { + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "broken config"], { cwd: repoDir, stdio: "pipe", }); @@ -713,118 +715,126 @@ describe("runWorktreeSetupInBackground", () => { expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath); }); - test("emits running setup snapshots before completed for real setup commands", async () => { - const { tempDir, repoDir } = createGitRepo({ - paseoConfig: { - worktree: { - setup: ["sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\""], + // POSIX-only: setup command is hardcoded to sh, printf, and sleep. + test.skipIf(isPlatform("win32"))( + "emits running setup snapshots before completed for real setup commands", + async () => { + const { tempDir, repoDir } = createGitRepo({ + paseoConfig: { + worktree: { + setup: ["sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\""], + }, }, - }, - }); - cleanupPaths.push(tempDir); + }); + cleanupPaths.push(tempDir); - const paseoHome = path.join(tempDir, ".paseo"); - const createdWorktree = await createLegacyWorktreeForTest({ - branchName: "feature-running-setup", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "feature-running-setup", - runSetup: false, - paseoHome, - }); - const worktreePath = createdWorktree.worktreePath; - const emitted: SessionOutboundMessage[] = []; - const snapshots = new Map(); - const logger = createLogger(); - const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); - - await runWorktreeSetupInBackground( - { + const paseoHome = path.join(tempDir, ".paseo"); + const createdWorktree = await createLegacyWorktreeForTest({ + branchName: "feature-running-setup", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "feature-running-setup", + runSetup: false, paseoHome, - emitWorkspaceUpdateForCwd, - cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => - snapshots.set(workspaceId, snapshot), - emit: (message) => emitted.push(message), - sessionLogger: logger, - terminalManager: null, - archiveWorkspaceRecord, - }, - { - requestCwd: repoDir, - repoRoot: repoDir, - workspaceId: "43", - worktree: { - branchName: "feature-running-setup", + }); + const worktreePath = createdWorktree.worktreePath; + const emitted: SessionOutboundMessage[] = []; + const snapshots = new Map(); + const logger = createLogger(); + const emitWorkspaceUpdateForCwd = vi.fn(async () => {}); + const archiveWorkspaceRecord = vi.fn(async () => {}); + + await runWorktreeSetupInBackground( + { + paseoHome, + emitWorkspaceUpdateForCwd, + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => + snapshots.set(workspaceId, snapshot), + emit: (message) => emitted.push(message), + sessionLogger: logger, + terminalManager: null, + archiveWorkspaceRecord, + }, + { + requestCwd: repoDir, + repoRoot: repoDir, + workspaceId: "43", + worktree: { + branchName: "feature-running-setup", + worktreePath, + }, + shouldBootstrap: true, + slug: "feature-running-setup", worktreePath, }, - shouldBootstrap: true, - slug: "feature-running-setup", - worktreePath, - }, - ); + ); - const progressMessages = emitted.filter( - (message): message is Extract => - message.type === "workspace_setup_progress", - ); - expect(progressMessages.length).toBeGreaterThan(1); - expect(progressMessages[0]?.payload).toMatchObject({ - workspaceId: "43", - status: "running", - error: null, - detail: { - type: "worktree_setup", - worktreePath, - branchName: "feature-running-setup", - log: "", - commands: [], - }, - }); - expect(progressMessages.at(-1)?.payload.status).toBe("completed"); + const progressMessages = emitted.filter( + ( + message, + ): message is Extract => + message.type === "workspace_setup_progress", + ); + expect(progressMessages.length).toBeGreaterThan(1); + expect(progressMessages[0]?.payload).toMatchObject({ + workspaceId: "43", + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath, + branchName: "feature-running-setup", + log: "", + commands: [], + }, + }); + expect(progressMessages.at(-1)?.payload.status).toBe("completed"); - const runningMessages = progressMessages.filter( - (message) => message.payload.status === "running", - ); - expect(runningMessages.length).toBeGreaterThan(0); - expect( - progressMessages.findIndex((message) => message.payload.status === "running"), - ).toBeLessThan(progressMessages.findIndex((message) => message.payload.status === "completed")); + const runningMessages = progressMessages.filter( + (message) => message.payload.status === "running", + ); + expect(runningMessages.length).toBeGreaterThan(0); + expect( + progressMessages.findIndex((message) => message.payload.status === "running"), + ).toBeLessThan( + progressMessages.findIndex((message) => message.payload.status === "completed"), + ); - const setupOutputMessage = runningMessages.find((message) => - message.payload.detail.commands[0]?.log.includes("phase-one"), - ); - expect(setupOutputMessage?.payload.detail.log).toContain("phase-one"); - expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({ - index: 1, - command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", - log: expect.stringContaining("phase-one"), - status: "running", - }); + const setupOutputMessage = runningMessages.find((message) => + message.payload.detail.commands[0]?.log.includes("phase-one"), + ); + expect(setupOutputMessage?.payload.detail.log).toContain("phase-one"); + expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({ + index: 1, + command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", + log: expect.stringContaining("phase-one"), + status: "running", + }); - expect(progressMessages.at(-1)?.payload).toMatchObject({ - workspaceId: "43", - status: "completed", - error: null, - detail: { - type: "worktree_setup", - worktreePath, - branchName: "feature-running-setup", - }, - }); - expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two"); - expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({ - index: 1, - command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", - log: expect.stringContaining("phase-two"), - status: "completed", - exitCode: 0, - }); - expect(snapshots.get("43")).toMatchObject({ - status: "completed", - error: null, - }); - }); + expect(progressMessages.at(-1)?.payload).toMatchObject({ + workspaceId: "43", + status: "completed", + error: null, + detail: { + type: "worktree_setup", + worktreePath, + branchName: "feature-running-setup", + }, + }); + expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two"); + expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({ + index: 1, + command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", + log: expect.stringContaining("phase-two"), + status: "completed", + exitCode: 0, + }); + expect(snapshots.get("43")).toMatchObject({ + status: "completed", + error: null, + }); + }, + ); test("emits completed when reusing an existing worktree without bootstrapping or auto-starting scripts", async () => { const { tempDir, repoDir } = createGitRepo({ @@ -1199,7 +1209,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { return; } - const branch = execSync("git branch --show-current", { cwd: worktreePath, stdio: "pipe" }) + const branch = execFileSync("git", ["branch", "--show-current"], { + cwd: worktreePath, + stdio: "pipe", + }) .toString() .trim(); expect(branch).toBe("feature/review-pr"); @@ -1248,7 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { expect(result.sessionConfig.cwd).toContain("agent-review-pr-123"); expect(events.some((event) => event.startsWith("workspace:"))).toBe(true); - const branch = execSync("git branch --show-current", { + const branch = execFileSync("git", ["branch", "--show-current"], { cwd: result.sessionConfig.cwd, stdio: "pipe", }) @@ -1445,7 +1458,9 @@ describe("handleCreatePaseoWorktreeRequest", () => { baseBranch: "main", branchName: "resolver-feature", }); - expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir); + const resolvedCwd = resolveDefaultBranch.mock.calls[0]?.[0]; + expect(resolvedCwd).toBeDefined(); + expect(realpathSync.native(resolvedCwd ?? "")).toBe(realpathSync.native(repoDir)); }); }); @@ -1577,10 +1592,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { cwd: registeredWorktreePath, }), ); - expect(backgroundWork).toHaveBeenCalledWith( + const backgroundInput = backgroundWork.mock.calls[0]?.[0]; + expect(backgroundInput).toEqual( expect.objectContaining({ requestCwd: repoDir, - repoRoot: repoDir, worktree: { branchName: "response-after-create", worktreePath: registeredWorktreePath, @@ -1588,6 +1603,9 @@ describe("handleCreatePaseoWorktreeRequest", () => { shouldBootstrap: true, }), ); + expect(realpathSync.native(backgroundInput?.repoRoot ?? "")).toBe( + realpathSync.native(repoDir), + ); } finally { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index f10a16cec..304b4650d 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -1,9 +1,14 @@ import { it, expect, afterEach } from "vitest"; +import { isPlatform } from "../test-utils/platform.js"; import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { + process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; +} + async function waitForCondition( predicate: () => boolean, timeoutMs: number, @@ -19,47 +24,52 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } -async function withShell(shell: string, run: () => Promise): Promise { - const originalShell = process.env.SHELL; - process.env.SHELL = shell; - try { - return await run(); - } finally { - if (originalShell === undefined) { - delete process.env.SHELL; - } else { - process.env.SHELL = originalShell; - } - } -} - let manager: TerminalManager; const temporaryDirs: string[] = []; -afterEach(() => { +afterEach(async () => { if (manager) { + const terminalsByCwd = await Promise.all( + manager.listDirectories().map((cwd) => manager.getTerminals(cwd)), + ); + await Promise.all( + terminalsByCwd + .flat() + .map((terminal) => + manager.killTerminalAndWait(terminal.id, { gracefulTimeoutMs: 50, forceTimeoutMs: 50 }), + ), + ); manager.killAll(); } + await new Promise((resolve) => setTimeout(resolve, 50)); while (temporaryDirs.length > 0) { const dir = temporaryDirs.pop(); if (dir) { - rmSync(dir, { recursive: true, force: true }); + try { + rmSync(dir, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBUSY") { + throw error; + } + } } } }); it("returns empty list for new cwd", async () => { manager = createTerminalManager(); - const terminals = await manager.getTerminals("/tmp"); + const cwd = realpathSync(tmpdir()); + const terminals = await manager.getTerminals(cwd); expect(terminals).toHaveLength(0); }); it("returns existing terminals on subsequent calls", async () => { manager = createTerminalManager(); - const created = await manager.createTerminal({ cwd: "/tmp" }); - const first = await manager.getTerminals("/tmp"); - const second = await manager.getTerminals("/tmp"); + const cwd = realpathSync(tmpdir()); + const created = await manager.createTerminal({ cwd }); + const first = await manager.getTerminals(cwd); + const second = await manager.getTerminals(cwd); expect(first.length).toBe(1); expect(first[0].id).toBe(created.id); @@ -79,8 +89,11 @@ it("accepts Windows absolute paths", async () => { it("creates separate terminals for different cwds", async () => { manager = createTerminalManager(); - const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })]; - const homeTerminals = [await manager.createTerminal({ cwd: "/home" })]; + const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-first-")); + const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-second-")); + temporaryDirs.push(firstCwd, secondCwd); + const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd })]; + const homeTerminals = [await manager.createTerminal({ cwd: secondCwd })]; expect(tmpTerminals.length).toBe(1); expect(homeTerminals.length).toBe(1); @@ -89,29 +102,31 @@ it("creates separate terminals for different cwds", async () => { it("creates additional terminal with auto-incrementing name", async () => { manager = createTerminalManager(); - await manager.createTerminal({ cwd: "/tmp" }); - const second = await manager.createTerminal({ cwd: "/tmp" }); + const cwd = realpathSync(tmpdir()); + await manager.createTerminal({ cwd }); + const second = await manager.createTerminal({ cwd }); expect(second.name).toBe("Terminal 2"); - const terminals = await manager.getTerminals("/tmp"); + const terminals = await manager.getTerminals(cwd); expect(terminals.length).toBe(2); }); it("uses custom name when provided", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); + const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()), name: "Dev Server" }); expect(session.name).toBe("Dev Server"); }); it("creates first terminal if none exist", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp" }); + const cwd = realpathSync(tmpdir()); + const session = await manager.createTerminal({ cwd }); expect(session.name).toBe("Terminal 1"); - const terminals = await manager.getTerminals("/tmp"); + const terminals = await manager.getTerminals(cwd); expect(terminals.length).toBe(1); expect(terminals[0].id).toBe(session.id); }); @@ -123,71 +138,67 @@ it("throws for relative paths", async () => { it("does not reject Windows absolute paths as relative", async () => { manager = createTerminalManager(); - // Should pass path validation (not throw "cwd must be absolute path"). - // The terminal may or may not spawn successfully on non-Windows hosts, - // so we only assert the validation error is absent. + const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-absolute-path-")); + temporaryDirs.push(cwd); + try { - await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" }); + await manager.createTerminal({ cwd }); } catch (error) { expect((error as Error).message).not.toBe("cwd must be absolute path"); } }); it("inherits registered env for the worktree root cwd", async () => { - await withShell("/bin/sh", async () => { - manager = createTerminalManager(); - const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-")); - temporaryDirs.push(cwd); - const markerPath = join(cwd, "root-port.txt"); + manager = createTerminalManager(); + const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-")); + temporaryDirs.push(cwd); + const markerPath = join(cwd, "root-port.txt"); - manager.registerCwdEnv({ - cwd, - env: { PASEO_WORKTREE_PORT: "45678" }, - }); - const session = await manager.createTerminal({ cwd }); - for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { - session.send({ - type: "input", - data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`, - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - await waitForCondition(() => existsSync(markerPath), 10000); - expect(readFileSync(markerPath, "utf8")).toBe("45678"); + manager.registerCwdEnv({ + cwd, + env: { PASEO_WORKTREE_PORT: "45678" }, }); + await manager.createTerminal({ + cwd, + command: process.execPath, + args: [ + "-e", + `require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`, + ], + }); + + await waitForCondition(() => existsSync(markerPath), 10000); + expect(readFileSync(markerPath, "utf8")).toBe("45678"); }); it("inherits registered env for subdirectories within the worktree", async () => { - await withShell("/bin/sh", async () => { - manager = createTerminalManager(); - const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-")); - const subdirCwd = join(rootCwd, "packages", "app"); - mkdirSync(subdirCwd, { recursive: true }); - temporaryDirs.push(rootCwd); - const markerPath = join(subdirCwd, "subdir-port.txt"); + manager = createTerminalManager(); + const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-")); + const subdirCwd = join(rootCwd, "packages", "app"); + mkdirSync(subdirCwd, { recursive: true }); + temporaryDirs.push(rootCwd); + const markerPath = join(subdirCwd, "subdir-port.txt"); - manager.registerCwdEnv({ - cwd: rootCwd, - env: { PASEO_WORKTREE_PORT: "45679" }, - }); - const session = await manager.createTerminal({ cwd: subdirCwd }); - for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { - session.send({ - type: "input", - data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`, - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - await waitForCondition(() => existsSync(markerPath), 10000); - expect(readFileSync(markerPath, "utf8")).toBe("45679"); + manager.registerCwdEnv({ + cwd: rootCwd, + env: { PASEO_WORKTREE_PORT: "45679" }, }); + await manager.createTerminal({ + cwd: subdirCwd, + command: process.execPath, + args: [ + "-e", + `require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`, + ], + }); + + await waitForCondition(() => existsSync(markerPath), 10000); + expect(readFileSync(markerPath, "utf8")).toBe("45679"); }); it("returns terminal by id", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp" }); + const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); const found = manager.getTerminal(session.id); expect(found).toBe(session); @@ -202,7 +213,7 @@ it("returns undefined for unknown id", () => { it("removes terminal from manager", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp" }); + const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); const id = session.id; manager.killTerminal(id); @@ -212,24 +223,26 @@ it("removes terminal from manager", async () => { it("removes cwd entry when last terminal is killed", async () => { manager = createTerminalManager(); - const created = await manager.createTerminal({ cwd: "/tmp" }); + const cwd = realpathSync(tmpdir()); + const created = await manager.createTerminal({ cwd }); manager.killTerminal(created.id); - const remaining = await manager.getTerminals("/tmp"); + const remaining = await manager.getTerminals(cwd); expect(remaining).toHaveLength(0); - expect(manager.listDirectories()).not.toContain("/tmp"); + expect(manager.listDirectories()).not.toContain(cwd); }); it("keeps cwd entry when other terminals remain", async () => { manager = createTerminalManager(); - await manager.createTerminal({ cwd: "/tmp" }); - const second = await manager.createTerminal({ cwd: "/tmp" }); + const cwd = realpathSync(tmpdir()); + await manager.createTerminal({ cwd }); + const second = await manager.createTerminal({ cwd }); - const terminals = await manager.getTerminals("/tmp"); + const terminals = await manager.getTerminals(cwd); manager.killTerminal(terminals[0].id); - expect(manager.listDirectories()).toContain("/tmp"); - const remaining = await manager.getTerminals("/tmp"); + expect(manager.listDirectories()).toContain(cwd); + const remaining = await manager.getTerminals(cwd); expect(remaining.length).toBe(1); expect(remaining[0].id).toBe(second.id); }); @@ -241,7 +254,8 @@ it("is no-op for unknown id", () => { it("auto-removes terminal when shell exits", async () => { manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp" }); + const cwd = realpathSync(tmpdir()); + const session = await manager.createTerminal({ cwd }); const exitedId = session.id; session.kill(); @@ -249,7 +263,7 @@ it("auto-removes terminal when shell exits", async () => { expect(manager.getTerminal(exitedId)).toBeUndefined(); - const remaining = await manager.getTerminals("/tmp"); + const remaining = await manager.getTerminals(cwd); expect(remaining).toHaveLength(0); }); @@ -260,19 +274,25 @@ it("returns empty array initially", () => { it("returns all cwds with active terminals", async () => { manager = createTerminalManager(); - await manager.createTerminal({ cwd: "/tmp" }); - await manager.createTerminal({ cwd: "/home" }); + const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-first-")); + const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-second-")); + temporaryDirs.push(firstCwd, secondCwd); + await manager.createTerminal({ cwd: firstCwd }); + await manager.createTerminal({ cwd: secondCwd }); const dirs = manager.listDirectories(); - expect(dirs).toContain("/tmp"); - expect(dirs).toContain("/home"); + expect(dirs).toContain(firstCwd); + expect(dirs).toContain(secondCwd); expect(dirs.length).toBe(2); }); it("kills all terminals and clears state", async () => { manager = createTerminalManager(); - const tmpSession = await manager.createTerminal({ cwd: "/tmp" }); - const homeSession = await manager.createTerminal({ cwd: "/home" }); + const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-first-")); + const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-second-")); + temporaryDirs.push(firstCwd, secondCwd); + const tmpSession = await manager.createTerminal({ cwd: firstCwd }); + const homeSession = await manager.createTerminal({ cwd: secondCwd }); const tmpId = tmpSession.id; const homeId = homeSession.id; @@ -285,6 +305,7 @@ it("kills all terminals and clears state", async () => { it("emits cwd snapshots when terminals are created", async () => { manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = []; const unsubscribe = manager.subscribeTerminalsChanged((input) => { snapshots.push({ @@ -296,15 +317,15 @@ it("emits cwd snapshots when terminals are created", async () => { }); }); - await manager.createTerminal({ cwd: "/tmp" }); - await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); + await manager.createTerminal({ cwd }); + await manager.createTerminal({ cwd, name: "Dev Server" }); expect(snapshots).toContainEqual({ - cwd: "/tmp", + cwd, terminals: [{ name: "Terminal 1" }], }); expect(snapshots).toContainEqual({ - cwd: "/tmp", + cwd, terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }], }); @@ -330,24 +351,26 @@ function hasLogsTitle(sessionId: string) { } it("emits updated terminal titles after debounced title changes", async () => { - await withShell("/bin/sh", async () => { - manager = createTerminalManager(); - const snapshots: TerminalTitleEntry[][] = []; - const unsubscribe = manager.subscribeTerminalsChanged((input) => { - snapshots.push(input.terminals.map(toTitleEntry)); - }); - - const session = await manager.createTerminal({ cwd: "/tmp" }); - session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" }); - - await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000); - - unsubscribe(); + manager = createTerminalManager(); + const snapshots: TerminalTitleEntry[][] = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push(input.terminals.map(toTitleEntry)); }); + + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + command: process.execPath, + args: ["-e", "process.stdout.write('\\x1b]0;Logs\\x07'); setTimeout(() => {}, 10000);"], + }); + + await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000); + + unsubscribe(); }, 10000); it("emits empty snapshot when last terminal is removed", async () => { manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); const snapshots: Array<{ cwd: string; terminalCount: number }> = []; const unsubscribe = manager.subscribeTerminalsChanged((input) => { snapshots.push({ @@ -356,11 +379,11 @@ it("emits empty snapshot when last terminal is removed", async () => { }); }); - const session = await manager.createTerminal({ cwd: "/tmp" }); + const session = await manager.createTerminal({ cwd }); manager.killTerminal(session.id); expect(snapshots).toContainEqual({ - cwd: "/tmp", + cwd, terminalCount: 0, }); diff --git a/packages/server/src/terminal/terminal.posix.test.ts b/packages/server/src/terminal/terminal.posix.test.ts new file mode 100644 index 000000000..616b179a3 --- /dev/null +++ b/packages/server/src/terminal/terminal.posix.test.ts @@ -0,0 +1,1171 @@ +// POSIX-only: node-pty + POSIX shell assertions +/* eslint-disable max-nested-callbacks */ +import { describe, it, expect, afterEach } from "vitest"; +import { isPlatform } from "../test-utils/platform.js"; +import { + buildTerminalEnvironment, + createTerminal, + resolveZshShellIntegrationDir, + type TerminalSession, +} from "./terminal.js"; +import { + chmodSync, + cpSync, + existsSync, + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const hasZsh = existsSync("/bin/zsh"); + +if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { + process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; +} + +type TerminalRow = ReturnType["grid"][number]; + +function rowToText(row: TerminalRow): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +// Extract text from a single row +function getRowText(state: ReturnType, rowIndex: number): string { + return rowToText(state.grid[rowIndex]); +} + +// Extract all visible lines as array (trimmed, empty lines included) +function getLines(state: ReturnType): string[] { + return state.grid.map(rowToText); +} + +// Wait for terminal state to match expected lines +async function waitForLines( + session: TerminalSession, + expectedLines: string[], + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const lines = getLines(session.getState()); + let matches = true; + for (let i = 0; i < expectedLines.length; i++) { + if (lines[i] !== expectedLines[i]) { + matches = false; + break; + } + } + if (matches) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const actual = getLines(session.getState()).slice(0, expectedLines.length); + throw new Error( + `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, + ); +} + +async function waitForState( + session: TerminalSession, + predicate: (state: ReturnType) => boolean, + timeoutMs = 5000, +): Promise> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const state = session.getState(); + if (predicate(state)) { + return state; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + throw new Error("Timeout waiting for terminal state predicate to match"); +} + +async function waitForTitle( + session: TerminalSession, + predicate: (title: string | undefined) => boolean, + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const title = session.getTitle(); + if (predicate(title)) { + return title; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + throw new Error("Timeout waiting for terminal title predicate to match"); +} + +const sessions: TerminalSession[] = []; + +const temporaryDirs: string[] = []; + +afterEach(async () => { + for (const session of sessions) { + session.kill(); + } + sessions.length = 0; + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function trackSession(session: TerminalSession): TerminalSession { + sessions.push(session); + return session; +} + +const DA_HELPER_SCRIPT = `process.stdin.setRawMode(true); +process.stdin.resume(); +let buf = ""; +const timer = setTimeout(() => { + process.stdout.write("DA_TIMEOUT\\n"); + process.exit(2); +}, 2500); +process.stdin.on("data", (chunk) => { + buf += chunk.toString("binary"); + const m = buf.match(/\\x1b\\[\\?[\\d;]+c/); + if (m) { + clearTimeout(timer); + process.stdout.write("DA_OK:" + m[0].slice(1) + "\\n"); + process.exit(0); + } +}); +process.stdout.write("\\x1b[c"); +`; + +const DSR_HELPER_SCRIPT = `process.stdin.setRawMode(true); +process.stdin.resume(); +const mode = process.argv[2] || "cursor"; +const query = mode === "private-cursor" ? "\\x1b[?6n" : mode === "status" ? "\\x1b[5n" : "\\x1b[6n"; +const pattern = mode === "private-cursor" ? /\\x1b\\[\\?(\\d+);(\\d+)R/ : mode === "status" ? /\\x1b\\[0n/ : /\\x1b\\[(\\d+);(\\d+)R/; +let buf = ""; +const timer = setTimeout(() => { + process.stdout.write("DSR_TIMEOUT\\n"); + process.exit(2); +}, 2500); +process.stdin.on("data", (chunk) => { + buf += chunk.toString("binary"); + const match = buf.match(pattern); + if (!match) { + return; + } + clearTimeout(timer); + if (mode === "status") { + process.stdout.write("DSR_OK:status\\n"); + } else { + process.stdout.write("DSR_OK:" + match[1] + ":" + match[2] + "\\n"); + } + process.exit(0); +}); +process.stdout.write(query); +`; + +function writeDaHelper(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + const path = join(dir, "helper.cjs"); + writeFileSync(path, DA_HELPER_SCRIPT); + return path; +} + +function writeDsrHelper(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + const path = join(dir, "helper.cjs"); + writeFileSync(path, DSR_HELPER_SCRIPT); + return path; +} + +function isDaOkLine(line: string): boolean { + return line.startsWith("DA_OK:"); +} + +function isDsrOkLine(line: string): boolean { + return line.startsWith("DSR_OK:"); +} + +function hasDaOkLine(state: ReturnType): boolean { + return getLines(state).some(isDaOkLine); +} + +function hasDsrOkLine(state: ReturnType): boolean { + return getLines(state).some(isDsrOkLine); +} + +function lastNonEmptyLineIsPrompt(state: ReturnType): boolean { + const last = + getLines(state) + .toReversed() + .find((line) => line.length > 0) ?? ""; + return last === "$"; +} + +describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { + it("sets zsh wrapper env when spawning zsh", () => { + const resolvedEnv = buildTerminalEnvironment({ + shell: "/bin/zsh", + env: { + HOME: "/tmp/paseo-home", + ZDOTDIR: "/tmp/paseo-zdotdir", + }, + }); + + expect(resolvedEnv.TERM).toBe("xterm-256color"); + expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); + expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir"); + expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true); + expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true); + }); + + describe("send input", () => { + it("executes a simple echo command", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + // Wait for initial prompt, then send command + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo hello\r" }); + + // After running "echo hello", terminal should show: + // Line 0: "$ echo hello" + // Line 1: "hello" + // Line 2: "$" + await waitForLines(session, ["$ echo hello", "hello", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ echo hello"); + expect(getRowText(state, 1)).toBe("hello"); + expect(getRowText(state, 2)).toBe("$"); + }); + + it("executes multiple commands sequentially", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo first\r" }); + await waitForLines(session, ["$ echo first", "first", "$"]); + + session.send({ type: "input", data: "echo second\r" }); + await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ echo first"); + expect(getRowText(state, 1)).toBe("first"); + expect(getRowText(state, 2)).toBe("$ echo second"); + expect(getRowText(state, 3)).toBe("second"); + expect(getRowText(state, 4)).toBe("$"); + }); + + it("captures output from pwd in specified cwd", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "pwd\r" }); + + await waitForLines(session, ["$ pwd", "/tmp", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ pwd"); + expect(getRowText(state, 1)).toBe("/tmp"); + expect(getRowText(state, 2)).toBe("$"); + }); + }); + + describe("terminal title", () => { + it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + mkdirSync(realZdotdir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n"); + + const session = trackSession( + await createTerminal({ + cwd: homeDir, + command: "/bin/zsh", + args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + const exitInfo = await new Promise>>( + (resolve) => { + session.onExit((info) => resolve(info)); + }, + ); + + expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]); + }); + + it("emits the initial title from command args to title listeners", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "npm-cli.js"); + writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + command: process.execPath, + args: [scriptPath, "run", "dev"], + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForTitle(session, (title) => title === "npm run dev"); + await waitForState(session, (state) => state.title === "npm run dev"); + + expect(seenTitles).toContain("npm run dev"); + expect(session.getTitle()).toBe("npm run dev"); + expect(session.getState().title).toBe("npm run dev"); + + unsubscribeTitle(); + }); + + it("emits OSC title updates to title listeners", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + expect(seenTitles).toContain("Build Log"); + expect(session.getTitle()).toBe("Build Log"); + expect(session.getState().title).toBe("Build Log"); + + unsubscribeTitle(); + }); + + it("keeps preset titles instead of applying OSC title updates", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + title: "typecheck", + }), + ); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(session.getTitle()).toBe("typecheck"); + expect(session.getState().title).toBe("typecheck"); + }); + + it("emits command completion from VS Code OSC 633 without visible output", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-command-finished.sh"); + writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([7]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + + unsubscribeCommandFinished(); + }); + + it("ignores malformed VS Code OSC 633 command completion payloads", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh"); + writeFileSync( + scriptPath, + "#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n", + ); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([3]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + + unsubscribeCommandFinished(); + }); + + it("debounces rapid title changes and emits only the final title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const seenMessages: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + const unsubscribeMessages = session.subscribe((message) => { + if (message.type === "titleChange") { + seenMessages.push(message.title); + } + }); + + await waitForLines(session, ["$"]); + session.send({ + type: "input", + data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", + }); + + await waitForTitle(session, (title) => title === "Final"); + + expect(seenTitles).toEqual(["Final"]); + expect(seenMessages).toEqual(["Final"]); + + unsubscribeMessages(); + unsubscribeTitle(); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + await waitForLines(session, ["$"]); + await waitForTitle(session, (title) => title === "~/dev/faro"); + + session.send({ type: "input", data: "sleep 1\r" }); + + await waitForTitle(session, (title) => title === "sleep 1"); + await waitForTitle(session, (title) => title === "~/dev/faro", 4000); + }); + + it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-")); + temporaryDirs.push(homeDir); + writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n"); + + const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources"); + const inaccessiblePackagedIntegrationDir = join( + fakeAppRoot, + "app.asar", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + const unpackedIntegrationDir = join( + fakeAppRoot, + "app.asar.unpacked", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + mkdirSync(unpackedIntegrationDir, { recursive: true }); + cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true }); + writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder"); + + const env = buildTerminalEnvironment({ + shell: "/bin/zsh", + env: { + HOME: homeDir, + }, + zshShellIntegrationDir: inaccessiblePackagedIntegrationDir, + }); + + const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], { + cwd: homeDir, + env, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> "); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "false\r" }); + + await waitForState(session, () => commandCompletions.includes(1)); + + expect(commandCompletions).toEqual([1]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + + unsubscribeCommandFinished(); + }); + }); + + describe("colors", () => { + it("captures ANSI 16 color codes (mode 1)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[31m = ANSI red (color 1) + session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fg).toBe(1); // ANSI red = 1 + expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors + + // The "$" after RED should have default color + expect(outputRow[3].char).toBe("$"); + expect(outputRow[3].fg).toBe(undefined); + expect(outputRow[3].fgMode).toBe(undefined); + }); + + it("captures 256 color codes (mode 2)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[38;5;208m = 256-color orange (color 208) + session.send({ type: "input", data: "printf '\\033[38;5;208mORG\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + // Check O cell + expect(outputRow[0].char).toBe("O"); + expect(outputRow[0].fg).toBe(208); // 256-color index + expect(outputRow[0].fgMode).toBe(2); // Mode 2 = 256 colors + }); + + it("captures true color RGB (mode 3)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[38;2;255;128;64m = true color RGB(255, 128, 64) + session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + // Check R cell + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color + + // The color value should be packed RGB: (255 << 16) | (128 << 8) | 64 + const expectedPacked = (255 << 16) | (128 << 8) | 64; + expect(outputRow[0].fg).toBe(expectedPacked); + }); + + it("captures background colors", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[41m = ANSI red background + session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("B"); + expect(outputRow[0].bg).toBe(1); // ANSI red = 1 + expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors + }); + }); + + describe("subscribe", () => { + it("receives a snapshot on initial subscription", async () => { + const session = trackSession( + await createTerminal({ + cwd: realpathSync(tmpdir()), + }), + ); + + const messages: Array<{ type: string }> = []; + const unsubscribe = session.subscribe((msg) => { + messages.push(msg); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(messages.length).toBeGreaterThan(0); + expect(messages[0].type).toBe("snapshot"); + + unsubscribe(); + }); + + it("receives output messages on updates without replay snapshots", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + const messages: Array<{ type: string }> = []; + const unsubscribe = session.subscribe((msg) => { + messages.push(msg); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + messages.length = 0; + + session.send({ type: "input", data: "echo test\r" }); + + await waitForLines(session, ["$ echo test", "test", "$"]); + + expect(messages.some((message) => message.type === "output")).toBe(true); + expect(messages.some((message) => message.type === "snapshot")).toBe(false); + + unsubscribe(); + }); + + it("does not emit snapshot messages for resize-only updates", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + const messages: Array<{ type: string }> = []; + const unsubscribe = session.subscribe((msg) => { + messages.push(msg); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + messages.length = 0; + + session.send({ type: "resize", rows: 30, cols: 100 }); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(messages.some((message) => message.type === "snapshot")).toBe(false); + expect(session.getSize()).toEqual({ rows: 30, cols: 100 }); + + unsubscribe(); + }); + + it("emits output only after getState reflects the new data", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + const outputSeenInState = new Promise((resolve) => { + const unsubscribe = session.subscribe((message) => { + if (message.type !== "output" || !message.data.includes("state-after-output")) { + return; + } + unsubscribe(); + const stateText = getLines(session.getState()).join("\n"); + resolve(stateText.includes("state-after-output")); + }); + }); + + session.send({ type: "input", data: "echo state-after-output\r" }); + expect(await outputSeenInState).toBe(true); + }); + + it("unsubscribe stops receiving messages", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + const messages: Array<{ type: string }> = []; + const unsubscribe = session.subscribe((msg) => { + messages.push(msg); + }); + + unsubscribe(); + messages.length = 0; + + session.send({ type: "input", data: "echo after\r" }); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(messages.length).toBe(0); + }); + }); + + describe("terminal protocol queries", () => { + it("delivers a DA1 reply to a foreground app on stdin", async () => { + const helperPath = writeDaHelper("terminal-da-helper-"); + + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` }); + await waitForState(session, hasDaOkLine); + + const ack = getLines(session.getState()).find(isDaOkLine) ?? ""; + expect(ack).toMatch(/^DA_OK:\[\?[\d;]+c$/); + }); + + it("does not echo DA1 replies onto the prompt after the foreground app exits", async () => { + const helperPath = writeDaHelper("terminal-da-cleanup-"); + + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` }); + await waitForState(session, hasDaOkLine); + await waitForState(session, lastNonEmptyLineIsPrompt); + }); + + it("delivers public DSR cursor-position replies to a foreground app on stdin", async () => { + const helperPath = writeDsrHelper("terminal-dsr-helper-"); + + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: `${process.execPath} ${helperPath} cursor\r` }); + await waitForState(session, hasDsrOkLine); + + const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); + }); + + it("delivers private DSR cursor-position replies to a foreground app on stdin", async () => { + const helperPath = writeDsrHelper("terminal-dsr-private-helper-"); + + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: `${process.execPath} ${helperPath} private-cursor\r` }); + await waitForState(session, hasDsrOkLine); + + const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); + }); + + it("delivers DSR terminal-status replies to a foreground app on stdin", async () => { + const helperPath = writeDsrHelper("terminal-dsr-status-helper-"); + + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: `${process.execPath} ${helperPath} status\r` }); + await waitForState(session, hasDsrOkLine); + + const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + expect(ack).toBe("DSR_OK:status"); + }); + }); + + describe("stream snapshots", () => { + it("streams raw output messages without replay metadata", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + const outputMessages: string[] = []; + const unsubscribe = session.subscribe((message) => { + if (message.type !== "output") { + return; + } + outputMessages.push(message.data); + }); + + session.send({ type: "input", data: "echo raw-stream\r" }); + await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]); + + expect(outputMessages.join("")).toContain("raw-stream"); + + unsubscribe(); + }); + + it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo before-detach\r" }); + await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]); + + session.send({ type: "input", data: "echo after-detach\r" }); + await waitForLines(session, [ + "$ echo before-detach", + "before-detach", + "$ echo after-detach", + "after-detach", + "$", + ]); + + let snapshotText = ""; + const unsubscribe = session.subscribe((message) => { + if (message.type !== "snapshot") { + return; + } + snapshotText = [...message.state.scrollback, ...message.state.grid] + .map(rowToText) + .join("\n"); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(snapshotText).toContain("before-detach"); + expect(snapshotText).toContain("after-detach"); + unsubscribe(); + }); + }); + + describe("getState", () => { + it("returns current terminal state with grid", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + const state = session.getState(); + + expect(state.rows).toBe(24); + expect(state.cols).toBe(80); + expect(state.grid).toBeDefined(); + expect(state.grid.length).toBe(24); + expect(state.grid[0].length).toBe(80); + expect(state.cursor).toBeDefined(); + expect(typeof state.cursor.row).toBe("number"); + expect(typeof state.cursor.col).toBe("number"); + }); + + it("captures cursor presentation modes emitted by terminal apps", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" }); + + const state = await waitForState( + session, + (current) => + current.cursor.style === "block" && + current.cursor.blink === false && + current.cursor.hidden === true, + ); + + expect(state.cursor).toMatchObject({ + style: "block", + blink: false, + hidden: true, + }); + }); + + it("grid cells have char and color attributes", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + const state = session.getState(); + // First cell should be "$" + expect(state.grid[0][0].char).toBe("$"); + expect(state.grid[0][0]).toHaveProperty("fg"); + expect(state.grid[0][0]).toHaveProperty("bg"); + }); + }); + + describe("scrollback", () => { + it("preserves scrollback buffer", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 5, + cols: 80, + }), + ); + + await waitForLines(session, ["$"]); + + // seq 1 20 produces 20 lines of output + // With 5 rows, we expect lines to scroll into scrollback + session.send({ type: "input", data: "seq 1 20\r" }); + + // Wait for command to finish - final prompt appears after "20" + // In a 5-row terminal, we'll see the last lines plus prompt + // The visible area will show something like: 17, 18, 19, 20, $ + await waitForLines(session, ["17", "18", "19", "20", "$"]); + + const state = session.getState(); + + // Scrollback should contain the earlier output + expect(state.scrollback.length).toBeGreaterThan(0); + + const scrollbackText = state.scrollback.map(rowToText).filter((line) => line.length > 0); + + // The scrollback should contain the command and early numbers + expect(scrollbackText).toContain("$ seq 1 20"); + expect(scrollbackText).toContain("1"); + expect(scrollbackText).toContain("2"); + expect(scrollbackText).toContain("3"); + }); + }); + + describe("kill", () => { + it("terminates the shell process", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.kill(); + + // Should not throw when trying to get state after kill + const state = session.getState(); + expect(state).toBeDefined(); + }); + + it("send after kill is a no-op", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + session.kill(); + + // Should not throw + session.send({ type: "input", data: "echo test\r" }); + }); + }); +}); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index af26324cf..f20258ca1 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,108 +1,27 @@ import { describe, it, expect, afterEach } from "vitest"; +import { isPlatform } from "../test-utils/platform.js"; import { - buildTerminalEnvironment, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, humanizeProcessTitle, normalizeProcessTitle, - resolveZshShellIntegrationDir, type TerminalSession, } from "./terminal.js"; import { chmodSync, - cpSync, - existsSync, mkdtempSync, mkdirSync, + realpathSync, rmSync, statSync, writeFileSync, } from "node:fs"; -import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; -const hasZsh = existsSync("/bin/zsh"); - -type TerminalRow = ReturnType["grid"][number]; - -function rowToText(row: TerminalRow): string { - return row - .map((cell) => cell.char) - .join("") - .trimEnd(); -} - -// Extract text from a single row -function getRowText(state: ReturnType, rowIndex: number): string { - return rowToText(state.grid[rowIndex]); -} - -// Extract all visible lines as array (trimmed, empty lines included) -function getLines(state: ReturnType): string[] { - return state.grid.map(rowToText); -} - -// Wait for terminal state to match expected lines -async function waitForLines( - session: TerminalSession, - expectedLines: string[], - timeoutMs = 5000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const lines = getLines(session.getState()); - let matches = true; - for (let i = 0; i < expectedLines.length; i++) { - if (lines[i] !== expectedLines[i]) { - matches = false; - break; - } - } - if (matches) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - const actual = getLines(session.getState()).slice(0, expectedLines.length); - throw new Error( - `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, - ); -} - -async function waitForState( - session: TerminalSession, - predicate: (state: ReturnType) => boolean, - timeoutMs = 5000, -): Promise> { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const state = session.getState(); - if (predicate(state)) { - return state; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - throw new Error("Timeout waiting for terminal state predicate to match"); -} - -async function waitForTitle( - session: TerminalSession, - predicate: (title: string | undefined) => boolean, - timeoutMs = 5000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const title = session.getTitle(); - if (predicate(title)) { - return title; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - - throw new Error("Timeout waiting for terminal title predicate to match"); +if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { + process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; } const sessions: TerminalSession[] = []; @@ -153,7 +72,8 @@ describe("createTerminal", () => { expect(humanizeProcessTitle("/bin/bash /tmp/dev.sh")).toBe("dev.sh"); }); - it("ensures darwin prebuild spawn-helper is executable", () => { + // macOS-only: node-pty ships the spawn-helper prebuild only for darwin. + it.runIf(isPlatform("darwin"))("ensures darwin prebuild spawn-helper is executable", () => { const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-")); temporaryDirs.push(packageRoot); const prebuildDir = join(packageRoot, "prebuilds", `darwin-${process.arch}`); @@ -186,9 +106,7 @@ describe("createTerminal", () => { it("creates a terminal session with an id, name, and cwd", async () => { const session = trackSession( await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, + cwd: realpathSync(tmpdir()), }), ); @@ -196,30 +114,17 @@ describe("createTerminal", () => { expect(typeof session.id).toBe("string"); expect(session.id.length).toBeGreaterThan(0); expect(session.name).toBe("Terminal"); - expect(session.cwd).toBe("/tmp"); - }); - - it("sets zsh wrapper env when spawning zsh", () => { - const resolvedEnv = buildTerminalEnvironment({ - shell: "/bin/zsh", - env: { - HOME: "/tmp/paseo-home", - ZDOTDIR: "/tmp/paseo-zdotdir", - }, - }); - - expect(resolvedEnv.TERM).toBe("xterm-256color"); - expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); - expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir"); - expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true); - expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true); + expect(session.cwd).toBe(realpathSync(tmpdir())); }); it("uses custom name when provided", async () => { + const shell = isPlatform("win32") + ? (process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe") + : "/bin/sh"; const session = trackSession( await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", + cwd: realpathSync(tmpdir()), + shell, env: { PS1: "$ " }, name: "Dev Server", }), @@ -231,7 +136,7 @@ describe("createTerminal", () => { it("uses default shell if not specified", async () => { const session = trackSession( await createTerminal({ - cwd: "/tmp", + cwd: realpathSync(tmpdir()), }), ); @@ -239,10 +144,13 @@ describe("createTerminal", () => { }); it("uses default rows and cols", async () => { + const shell = isPlatform("win32") + ? (process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe") + : "/bin/sh"; const session = trackSession( await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", + cwd: realpathSync(tmpdir()), + shell, env: { PS1: "$ " }, }), ); @@ -253,10 +161,13 @@ describe("createTerminal", () => { }); it("respects custom rows and cols", async () => { + const shell = isPlatform("win32") + ? (process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe") + : "/bin/sh"; const session = trackSession( await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", + cwd: realpathSync(tmpdir()), + shell, env: { PS1: "$ " }, rows: 40, cols: 120, @@ -271,9 +182,12 @@ describe("createTerminal", () => { it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ - cwd: "/tmp", - command: "/bin/sh", - args: ["-lc", "printf 'launch failed\\ncommand missing\\n'; exit 127"], + cwd: realpathSync(tmpdir()), + command: process.execPath, + args: [ + "-e", + "process.stdout.write('launch failed\\ncommand missing\\n'); process.exit(127);", + ], }), ); @@ -291,511 +205,11 @@ describe("createTerminal", () => { }); }); -describe("send input", () => { - it("executes a simple echo command", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - // Wait for initial prompt, then send command - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: "echo hello\r" }); - - // After running "echo hello", terminal should show: - // Line 0: "$ echo hello" - // Line 1: "hello" - // Line 2: "$" - await waitForLines(session, ["$ echo hello", "hello", "$"]); - - const state = session.getState(); - expect(getRowText(state, 0)).toBe("$ echo hello"); - expect(getRowText(state, 1)).toBe("hello"); - expect(getRowText(state, 2)).toBe("$"); - }); - - it("executes multiple commands sequentially", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: "echo first\r" }); - await waitForLines(session, ["$ echo first", "first", "$"]); - - session.send({ type: "input", data: "echo second\r" }); - await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]); - - const state = session.getState(); - expect(getRowText(state, 0)).toBe("$ echo first"); - expect(getRowText(state, 1)).toBe("first"); - expect(getRowText(state, 2)).toBe("$ echo second"); - expect(getRowText(state, 3)).toBe("second"); - expect(getRowText(state, 4)).toBe("$"); - }); - - it("captures output from pwd in specified cwd", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: "pwd\r" }); - - await waitForLines(session, ["$ pwd", "/tmp", "$"]); - - const state = session.getState(); - expect(getRowText(state, 0)).toBe("$ pwd"); - expect(getRowText(state, 1)).toBe("/tmp"); - expect(getRowText(state, 2)).toBe("$"); - }); -}); - -describe("terminal title", () => { - it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => { - const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-")); - temporaryDirs.push(homeDir); - const realZdotdir = join(homeDir, ".config", "zsh"); - mkdirSync(realZdotdir, { recursive: true }); - writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n"); - - const session = trackSession( - await createTerminal({ - cwd: homeDir, - command: "/bin/zsh", - args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], - env: { - HOME: homeDir, - ZDOTDIR: realZdotdir, - }, - }), - ); - - const exitInfo = await new Promise>>( - (resolve) => { - session.onExit((info) => resolve(info)); - }, - ); - - expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]); - }); - - it("emits the initial title from command args to title listeners", async () => { - const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-")); - temporaryDirs.push(packageRoot); - const scriptPath = join(packageRoot, "npm-cli.js"); - writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); - - const session = trackSession( - await createTerminal({ - cwd: packageRoot, - command: process.execPath, - args: [scriptPath, "run", "dev"], - }), - ); - const seenTitles: Array = []; - const unsubscribeTitle = session.onTitleChange((title) => { - seenTitles.push(title); - }); - - await waitForTitle(session, (title) => title === "npm run dev"); - await waitForState(session, (state) => state.title === "npm run dev"); - - expect(seenTitles).toContain("npm run dev"); - expect(session.getTitle()).toBe("npm run dev"); - expect(session.getState().title).toBe("npm run dev"); - - unsubscribeTitle(); - }); - - it("emits OSC title updates to title listeners", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - const seenTitles: Array = []; - const unsubscribeTitle = session.onTitleChange((title) => { - seenTitles.push(title); - }); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); - - await waitForTitle(session, (title) => title === "Build Log"); - - expect(seenTitles).toContain("Build Log"); - expect(session.getTitle()).toBe("Build Log"); - expect(session.getState().title).toBe("Build Log"); - - unsubscribeTitle(); - }); - - it("keeps preset titles instead of applying OSC title updates", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - title: "typecheck", - }), - ); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); - await new Promise((resolve) => setTimeout(resolve, 150)); - - expect(session.getTitle()).toBe("typecheck"); - expect(session.getState().title).toBe("typecheck"); - }); - - it("emits command completion from VS Code OSC 633 without visible output", async () => { - const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-")); - temporaryDirs.push(packageRoot); - const scriptPath = join(packageRoot, "emit-command-finished.sh"); - writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n"); - chmodSync(scriptPath, 0o755); - - const session = trackSession( - await createTerminal({ - cwd: packageRoot, - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - const commandCompletions: Array = []; - const unsubscribeCommandFinished = session.onCommandFinished((info) => { - commandCompletions.push(info.exitCode); - }); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "./emit-command-finished.sh\r" }); - - await waitForState(session, () => commandCompletions.length === 1); - - expect(commandCompletions).toEqual([7]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); - - unsubscribeCommandFinished(); - }); - - it("ignores malformed VS Code OSC 633 command completion payloads", async () => { - const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-")); - temporaryDirs.push(packageRoot); - const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh"); - writeFileSync( - scriptPath, - "#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n", - ); - chmodSync(scriptPath, 0o755); - - const session = trackSession( - await createTerminal({ - cwd: packageRoot, - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - const commandCompletions: Array = []; - const unsubscribeCommandFinished = session.onCommandFinished((info) => { - commandCompletions.push(info.exitCode); - }); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" }); - - await waitForState(session, () => commandCompletions.length === 1); - - expect(commandCompletions).toEqual([3]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); - - unsubscribeCommandFinished(); - }); - - it("debounces rapid title changes and emits only the final title", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - const seenTitles: Array = []; - const seenMessages: Array = []; - const unsubscribeTitle = session.onTitleChange((title) => { - seenTitles.push(title); - }); - const unsubscribeMessages = session.subscribe((message) => { - if (message.type === "titleChange") { - seenMessages.push(message.title); - } - }); - - await waitForLines(session, ["$"]); - session.send({ - type: "input", - data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", - }); - - await waitForTitle(session, (title) => title === "Final"); - - expect(seenTitles).toEqual(["Final"]); - expect(seenMessages).toEqual(["Final"]); - - unsubscribeMessages(); - unsubscribeTitle(); - }); - - it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => { - const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-")); - temporaryDirs.push(homeDir); - const realZdotdir = join(homeDir, ".config", "zsh"); - const workingDir = join(homeDir, "dev", "faro"); - mkdirSync(realZdotdir, { recursive: true }); - mkdirSync(workingDir, { recursive: true }); - writeFileSync(join(realZdotdir, ".zshenv"), ""); - writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); - - const session = trackSession( - await createTerminal({ - cwd: workingDir, - shell: "/bin/zsh", - env: { - HOME: homeDir, - ZDOTDIR: realZdotdir, - }, - }), - ); - - await waitForLines(session, ["$"]); - await waitForTitle(session, (title) => title === "~/dev/faro"); - - session.send({ type: "input", data: "sleep 1\r" }); - - await waitForTitle(session, (title) => title === "sleep 1"); - await waitForTitle(session, (title) => title === "~/dev/faro", 4000); - }); - - it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => { - const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-")); - temporaryDirs.push(homeDir); - writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n"); - - const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources"); - const inaccessiblePackagedIntegrationDir = join( - fakeAppRoot, - "app.asar", - "node_modules", - "@getpaseo", - "server", - "dist", - "server", - "terminal", - "shell-integration", - "zsh", - ); - const unpackedIntegrationDir = join( - fakeAppRoot, - "app.asar.unpacked", - "node_modules", - "@getpaseo", - "server", - "dist", - "server", - "terminal", - "shell-integration", - "zsh", - ); - mkdirSync(unpackedIntegrationDir, { recursive: true }); - cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true }); - writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder"); - - const env = buildTerminalEnvironment({ - shell: "/bin/zsh", - env: { - HOME: homeDir, - }, - zshShellIntegrationDir: inaccessiblePackagedIntegrationDir, - }); - - const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], { - cwd: homeDir, - env, - encoding: "utf8", - }); - - expect(result.status).toBe(0); - expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> "); - }); - - it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { - const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); - temporaryDirs.push(homeDir); - const realZdotdir = join(homeDir, ".config", "zsh"); - const workingDir = join(homeDir, "dev", "faro"); - mkdirSync(realZdotdir, { recursive: true }); - mkdirSync(workingDir, { recursive: true }); - writeFileSync(join(realZdotdir, ".zshenv"), ""); - writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); - - const session = trackSession( - await createTerminal({ - cwd: workingDir, - shell: "/bin/zsh", - env: { - HOME: homeDir, - ZDOTDIR: realZdotdir, - }, - }), - ); - const commandCompletions: Array = []; - const unsubscribeCommandFinished = session.onCommandFinished((info) => { - commandCompletions.push(info.exitCode); - }); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "false\r" }); - - await waitForState(session, () => commandCompletions.includes(1)); - - expect(commandCompletions).toEqual([1]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); - - unsubscribeCommandFinished(); - }); -}); - -describe("colors", () => { - it("captures ANSI 16 color codes (mode 1)", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ ", TERM: "xterm-256color" }, - }), - ); - - await waitForLines(session, ["$"]); - - // \033[31m = ANSI red (color 1) - session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" }); - - await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); - - const state = session.getState(); - const outputRow = state.grid[1]; - - expect(outputRow[0].char).toBe("R"); - expect(outputRow[0].fg).toBe(1); // ANSI red = 1 - expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors - - // The "$" after RED should have default color - expect(outputRow[3].char).toBe("$"); - expect(outputRow[3].fg).toBe(undefined); - expect(outputRow[3].fgMode).toBe(undefined); - }); - - it("captures 256 color codes (mode 2)", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ ", TERM: "xterm-256color" }, - }), - ); - - await waitForLines(session, ["$"]); - - // \033[38;5;208m = 256-color orange (color 208) - session.send({ type: "input", data: "printf '\\033[38;5;208mORG\\033[0m'\r" }); - - await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]); - - const state = session.getState(); - const outputRow = state.grid[1]; - - // Check O cell - expect(outputRow[0].char).toBe("O"); - expect(outputRow[0].fg).toBe(208); // 256-color index - expect(outputRow[0].fgMode).toBe(2); // Mode 2 = 256 colors - }); - - it("captures true color RGB (mode 3)", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ ", TERM: "xterm-256color" }, - }), - ); - - await waitForLines(session, ["$"]); - - // \033[38;2;255;128;64m = true color RGB(255, 128, 64) - session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" }); - - await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); - - const state = session.getState(); - const outputRow = state.grid[1]; - - // Check R cell - expect(outputRow[0].char).toBe("R"); - expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color - - // The color value should be packed RGB: (255 << 16) | (128 << 8) | 64 - const expectedPacked = (255 << 16) | (128 << 8) | 64; - expect(outputRow[0].fg).toBe(expectedPacked); - }); - - it("captures background colors", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ ", TERM: "xterm-256color" }, - }), - ); - - await waitForLines(session, ["$"]); - - // \033[41m = ANSI red background - session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" }); - - await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); - - const state = session.getState(); - const outputRow = state.grid[1]; - - expect(outputRow[0].char).toBe("B"); - expect(outputRow[0].bg).toBe(1); // ANSI red = 1 - expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors - }); -}); - describe("resize", () => { it("updates terminal dimensions on resize", async () => { const session = trackSession( await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, + cwd: realpathSync(tmpdir()), rows: 24, cols: 80, }), @@ -813,9 +227,7 @@ describe("resize", () => { it("grid reflects new dimensions after resize", async () => { const session = trackSession( await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, + cwd: realpathSync(tmpdir()), rows: 24, cols: 80, }), @@ -832,9 +244,7 @@ describe("resize", () => { it("exposes the current size without extracting full state", async () => { const session = trackSession( await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, + cwd: realpathSync(tmpdir()), rows: 24, cols: 80, }), @@ -849,537 +259,11 @@ describe("resize", () => { }); }); -describe("subscribe", () => { - it("receives a snapshot on initial subscription", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - const messages: Array<{ type: string }> = []; - const unsubscribe = session.subscribe((msg) => { - messages.push(msg); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(messages.length).toBeGreaterThan(0); - expect(messages[0].type).toBe("snapshot"); - - unsubscribe(); - }); - - it("receives output messages on updates without replay snapshots", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - const messages: Array<{ type: string }> = []; - const unsubscribe = session.subscribe((msg) => { - messages.push(msg); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - messages.length = 0; - - session.send({ type: "input", data: "echo test\r" }); - - await waitForLines(session, ["$ echo test", "test", "$"]); - - expect(messages.some((message) => message.type === "output")).toBe(true); - expect(messages.some((message) => message.type === "snapshot")).toBe(false); - - unsubscribe(); - }); - - it("does not emit snapshot messages for resize-only updates", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); - - const messages: Array<{ type: string }> = []; - const unsubscribe = session.subscribe((msg) => { - messages.push(msg); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - messages.length = 0; - - session.send({ type: "resize", rows: 30, cols: 100 }); - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(messages.some((message) => message.type === "snapshot")).toBe(false); - expect(session.getSize()).toEqual({ rows: 30, cols: 100 }); - - unsubscribe(); - }); - - it("emits output only after getState reflects the new data", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - const outputSeenInState = new Promise((resolve) => { - const unsubscribe = session.subscribe((message) => { - if (message.type !== "output" || !message.data.includes("state-after-output")) { - return; - } - unsubscribe(); - const stateText = getLines(session.getState()).join("\n"); - resolve(stateText.includes("state-after-output")); - }); - }); - - session.send({ type: "input", data: "echo state-after-output\r" }); - expect(await outputSeenInState).toBe(true); - }); - - it("unsubscribe stops receiving messages", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - const messages: Array<{ type: string }> = []; - const unsubscribe = session.subscribe((msg) => { - messages.push(msg); - }); - - unsubscribe(); - messages.length = 0; - - session.send({ type: "input", data: "echo after\r" }); - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(messages.length).toBe(0); - }); -}); - -const DA_HELPER_SCRIPT = `process.stdin.setRawMode(true); -process.stdin.resume(); -let buf = ""; -const timer = setTimeout(() => { - process.stdout.write("DA_TIMEOUT\\n"); - process.exit(2); -}, 2500); -process.stdin.on("data", (chunk) => { - buf += chunk.toString("binary"); - const m = buf.match(/\\x1b\\[\\?[\\d;]+c/); - if (m) { - clearTimeout(timer); - process.stdout.write("DA_OK:" + m[0].slice(1) + "\\n"); - process.exit(0); - } -}); -process.stdout.write("\\x1b[c"); -`; - -const DSR_HELPER_SCRIPT = `process.stdin.setRawMode(true); -process.stdin.resume(); -const mode = process.argv[2] || "cursor"; -const query = mode === "private-cursor" ? "\\x1b[?6n" : mode === "status" ? "\\x1b[5n" : "\\x1b[6n"; -const pattern = mode === "private-cursor" ? /\\x1b\\[\\?(\\d+);(\\d+)R/ : mode === "status" ? /\\x1b\\[0n/ : /\\x1b\\[(\\d+);(\\d+)R/; -let buf = ""; -const timer = setTimeout(() => { - process.stdout.write("DSR_TIMEOUT\\n"); - process.exit(2); -}, 2500); -process.stdin.on("data", (chunk) => { - buf += chunk.toString("binary"); - const match = buf.match(pattern); - if (!match) { - return; - } - clearTimeout(timer); - if (mode === "status") { - process.stdout.write("DSR_OK:status\\n"); - } else { - process.stdout.write("DSR_OK:" + match[1] + ":" + match[2] + "\\n"); - } - process.exit(0); -}); -process.stdout.write(query); -`; - -function writeDaHelper(prefix: string): string { - const dir = mkdtempSync(join(tmpdir(), prefix)); - temporaryDirs.push(dir); - const path = join(dir, "helper.cjs"); - writeFileSync(path, DA_HELPER_SCRIPT); - return path; -} - -function writeDsrHelper(prefix: string): string { - const dir = mkdtempSync(join(tmpdir(), prefix)); - temporaryDirs.push(dir); - const path = join(dir, "helper.cjs"); - writeFileSync(path, DSR_HELPER_SCRIPT); - return path; -} - -function isDaOkLine(line: string): boolean { - return line.startsWith("DA_OK:"); -} - -function isDsrOkLine(line: string): boolean { - return line.startsWith("DSR_OK:"); -} - -function hasDaOkLine(state: ReturnType): boolean { - return getLines(state).some(isDaOkLine); -} - -function hasDsrOkLine(state: ReturnType): boolean { - return getLines(state).some(isDsrOkLine); -} - -function lastNonEmptyLineIsPrompt(state: ReturnType): boolean { - const last = - getLines(state) - .toReversed() - .find((line) => line.length > 0) ?? ""; - return last === "$"; -} - -describe("terminal protocol queries", () => { - it("delivers a DA1 reply to a foreground app on stdin", async () => { - const helperPath = writeDaHelper("terminal-da-helper-"); - - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` }); - await waitForState(session, hasDaOkLine); - - const ack = getLines(session.getState()).find(isDaOkLine) ?? ""; - expect(ack).toMatch(/^DA_OK:\[\?[\d;]+c$/); - }); - - it("does not echo DA1 replies onto the prompt after the foreground app exits", async () => { - const helperPath = writeDaHelper("terminal-da-cleanup-"); - - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` }); - await waitForState(session, hasDaOkLine); - await waitForState(session, lastNonEmptyLineIsPrompt); - }); - - it("delivers public DSR cursor-position replies to a foreground app on stdin", async () => { - const helperPath = writeDsrHelper("terminal-dsr-helper-"); - - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: `${process.execPath} ${helperPath} cursor\r` }); - await waitForState(session, hasDsrOkLine); - - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; - expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); - }); - - it("delivers private DSR cursor-position replies to a foreground app on stdin", async () => { - const helperPath = writeDsrHelper("terminal-dsr-private-helper-"); - - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: `${process.execPath} ${helperPath} private-cursor\r` }); - await waitForState(session, hasDsrOkLine); - - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; - expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); - }); - - it("delivers DSR terminal-status replies to a foreground app on stdin", async () => { - const helperPath = writeDsrHelper("terminal-dsr-status-helper-"); - - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: `${process.execPath} ${helperPath} status\r` }); - await waitForState(session, hasDsrOkLine); - - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; - expect(ack).toBe("DSR_OK:status"); - }); -}); - -describe("stream snapshots", () => { - it("streams raw output messages without replay metadata", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - const outputMessages: string[] = []; - const unsubscribe = session.subscribe((message) => { - if (message.type !== "output") { - return; - } - outputMessages.push(message.data); - }); - - session.send({ type: "input", data: "echo raw-stream\r" }); - await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]); - - expect(outputMessages.join("")).toContain("raw-stream"); - - unsubscribe(); - }); - - it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: "echo before-detach\r" }); - await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]); - - session.send({ type: "input", data: "echo after-detach\r" }); - await waitForLines(session, [ - "$ echo before-detach", - "before-detach", - "$ echo after-detach", - "after-detach", - "$", - ]); - - let snapshotText = ""; - const unsubscribe = session.subscribe((message) => { - if (message.type !== "snapshot") { - return; - } - snapshotText = [...message.state.scrollback, ...message.state.grid].map(rowToText).join("\n"); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(snapshotText).toContain("before-detach"); - expect(snapshotText).toContain("after-detach"); - unsubscribe(); - }); -}); - -describe("getState", () => { - it("returns current terminal state with grid", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); - - const state = session.getState(); - - expect(state.rows).toBe(24); - expect(state.cols).toBe(80); - expect(state.grid).toBeDefined(); - expect(state.grid.length).toBe(24); - expect(state.grid[0].length).toBe(80); - expect(state.cursor).toBeDefined(); - expect(typeof state.cursor.row).toBe("number"); - expect(typeof state.cursor.col).toBe("number"); - }); - - it("captures cursor presentation modes emitted by terminal apps", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" }); - - const state = await waitForState( - session, - (current) => - current.cursor.style === "block" && - current.cursor.blink === false && - current.cursor.hidden === true, - ); - - expect(state.cursor).toMatchObject({ - style: "block", - blink: false, - hidden: true, - }); - }); - - it("grid cells have char and color attributes", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - const state = session.getState(); - // First cell should be "$" - expect(state.grid[0][0].char).toBe("$"); - expect(state.grid[0][0]).toHaveProperty("fg"); - expect(state.grid[0][0]).toHaveProperty("bg"); - }); -}); - -describe("scrollback", () => { - it("preserves scrollback buffer", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 5, - cols: 80, - }), - ); - - await waitForLines(session, ["$"]); - - // seq 1 20 produces 20 lines of output - // With 5 rows, we expect lines to scroll into scrollback - session.send({ type: "input", data: "seq 1 20\r" }); - - // Wait for command to finish - final prompt appears after "20" - // In a 5-row terminal, we'll see the last lines plus prompt - // The visible area will show something like: 17, 18, 19, 20, $ - await waitForLines(session, ["17", "18", "19", "20", "$"]); - - const state = session.getState(); - - // Scrollback should contain the earlier output - expect(state.scrollback.length).toBeGreaterThan(0); - - const scrollbackText = state.scrollback.map(rowToText).filter((line) => line.length > 0); - - // The scrollback should contain the command and early numbers - expect(scrollbackText).toContain("$ seq 1 20"); - expect(scrollbackText).toContain("1"); - expect(scrollbackText).toContain("2"); - expect(scrollbackText).toContain("3"); - }); -}); - -describe("kill", () => { - it("terminates the shell process", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - session.kill(); - - // Should not throw when trying to get state after kill - const state = session.getState(); - expect(state).toBeDefined(); - }); - - it("send after kill is a no-op", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - session.kill(); - - // Should not throw - session.send({ type: "input", data: "echo test\r" }); - }); -}); - describe("mouse events", () => { it("accepts mouse events without throwing", async () => { const session = trackSession( await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, + cwd: realpathSync(tmpdir()), }), ); diff --git a/packages/server/src/test-utils/platform.ts b/packages/server/src/test-utils/platform.ts new file mode 100644 index 000000000..e0d53e48a --- /dev/null +++ b/packages/server/src/test-utils/platform.ts @@ -0,0 +1,3 @@ +export function isPlatform(...platforms: NodeJS.Platform[]): boolean { + return platforms.includes(process.platform); +} diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts index 1d2445bc1..1fc062657 100644 --- a/packages/server/src/utils/checkout-git-batching.test.ts +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -1,5 +1,5 @@ -import { execSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; @@ -38,16 +38,18 @@ function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoD const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-"))); const repoDir = join(tempDir, "repo"); - execSync(`mkdir -p ${repoDir}`); - execSync("git init -b main", { cwd: repoDir }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir }); - execSync("git config user.name 'Test'", { cwd: repoDir }); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); for (let i = 0; i < fileCount; i += 1) { writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`); } - execSync("git add .", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir }); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + }); for (let i = 0; i < fileCount; i += 1) { writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 21edeef8b..7d7e73849 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { execSync } from "child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, realpathSync } from "fs"; +import { execFileSync } from "child_process"; +import { + existsSync, + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + realpathSync, + mkdirSync, +} from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { @@ -73,15 +81,15 @@ function createLegacyWorktreeForTest( import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; function initRepo(): { tempDir: string; repoDir: string } { - const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-test-"))); + const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-test-"))); const repoDir = join(tempDir, "repo"); - execSync(`mkdir -p ${repoDir}`); - execSync("git init -b main", { cwd: repoDir }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir }); - execSync("git config user.name 'Test'", { cwd: repoDir }); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir }); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repoDir }); return { tempDir, repoDir }; } @@ -142,19 +150,19 @@ function setupRemoteTrackingMain( ): { remoteDir: string; cloneDir: string } { const remoteDir = join(tempDir, "remote.git"); const cloneDir = join(tempDir, "upstream-clone"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); - execSync(`git clone ${remoteDir} ${cloneDir}`); - execSync("git config user.email 'test@test.com'", { cwd: cloneDir }); - execSync("git config user.name 'Test'", { cwd: cloneDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); + execFileSync("git", ["clone", remoteDir, cloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: cloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: cloneDir }); return { remoteDir, cloneDir }; } function commitFile(cwd: string, path: string, content: string, message: string): void { writeFileSync(join(cwd, path), content); - execSync(`git add ${path}`, { cwd }); - execSync(`git -c commit.gpgsign=false commit -m '${message}'`, { cwd }); + execFileSync("git", ["add", path], { cwd }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd }); } describe("checkout git utilities", () => { @@ -179,7 +187,7 @@ describe("checkout git utilities", () => { it("throws NotGitRepoError for non-git directories", async () => { const nonGitDir = join(tempDir, "not-git"); - execSync(`mkdir -p ${nonGitDir}`); + mkdirSync(nonGitDir, { recursive: true }); await expect(getCheckoutDiff(nonGitDir, { mode: "uncommitted" })).rejects.toBeInstanceOf( NotGitRepoError, @@ -188,26 +196,32 @@ describe("checkout git utilities", () => { it("returns null for getCurrentBranch in a repo with no commits", async () => { const emptyRepo = join(tempDir, "empty-repo"); - execSync(`mkdir -p ${emptyRepo}`); - execSync("git init -b main", { cwd: emptyRepo }); + mkdirSync(emptyRepo, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: emptyRepo }); const branch = await getCurrentBranch(emptyRepo); expect(branch).toBeNull(); }); it("returns the branch being rebased when HEAD is detached during a rebase", async () => { - execSync("git checkout -b feature/rebase-test", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/rebase-test"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "feature\n"); - execSync("git add file.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature change'", { cwd: repoDir }); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { + cwd: repoDir, + }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "main\n"); - execSync("git add file.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'main change'", { cwd: repoDir }); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "main change"], { + cwd: repoDir, + }); - execSync("git checkout feature/rebase-test", { cwd: repoDir }); - expect(() => execSync("git rebase main", { cwd: repoDir, stdio: "pipe" })).toThrow(); + execFileSync("git", ["checkout", "feature/rebase-test"], { cwd: repoDir }); + expect(() => + execFileSync("git", ["rebase", "main"], { cwd: repoDir, stdio: "pipe" }), + ).toThrow(); const branch = await getCurrentBranch(repoDir); expect(branch).toBe("feature/rebase-test"); @@ -230,7 +244,9 @@ describe("checkout git utilities", () => { const cleanStatus = await getCheckoutStatus(repoDir); expect(cleanStatus.isDirty).toBe(false); - const message = execSync("git log -1 --pretty=%B", { cwd: repoDir }).toString().trim(); + const message = execFileSync("git", ["log", "-1", "--pretty=%B"], { cwd: repoDir }) + .toString() + .trim(); expect(message).toBe("update file"); }); @@ -276,10 +292,14 @@ const x = 1; `; writeFileSync(join(repoDir, "example.ts"), originalContent); - execSync("git add example.ts", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'add multiline comment fixture'", { - cwd: repoDir, - }); + execFileSync("git", ["add", "example.ts"], { cwd: repoDir }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add multiline comment fixture"], + { + cwd: repoDir, + }, + ); writeFileSync(join(repoDir, "example.ts"), updatedContent); @@ -299,15 +319,15 @@ const x = 1; return; } expect(status.currentBranch).toBe("main"); - expect(status.repoRoot).toBe(repoDir); + expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(repoDir)); expect(status.isPaseoOwnedWorktree).toBe(false); expect(status.mainRepoRoot ?? null).toBeNull(); }); it("exposes hasRemote when origin is configured", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); const status = await getCheckoutStatus(repoDir); expect(status.isGit).toBe(true); @@ -319,19 +339,21 @@ const x = 1; it("reports ahead/behind relative to origin on the base branch", async () => { const remoteDir = join(tempDir, "remote.git"); const cloneDir = join(tempDir, "clone"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); - execSync(`git clone ${remoteDir} ${cloneDir}`); - execSync("git config user.email 'test@test.com'", { cwd: cloneDir }); - execSync("git config user.name 'Test'", { cwd: cloneDir }); + execFileSync("git", ["clone", remoteDir, cloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: cloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: cloneDir }); writeFileSync(join(cloneDir, "file.txt"), "remote\n"); - execSync("git add file.txt", { cwd: cloneDir }); - execSync("git -c commit.gpgsign=false commit -m 'remote update'", { cwd: cloneDir }); - execSync("git push", { cwd: cloneDir }); + execFileSync("git", ["add", "file.txt"], { cwd: cloneDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote update"], { + cwd: cloneDir, + }); + execFileSync("git", ["push"], { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const behindStatus = await getCheckoutStatus(repoDir); expect(behindStatus.isGit).toBe(true); if (!behindStatus.isGit) { @@ -341,8 +363,10 @@ const x = 1; expect(behindStatus.behindOfOrigin).toBe(1); writeFileSync(join(repoDir, "local.txt"), "local\n"); - execSync("git add local.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'local update'", { cwd: repoDir }); + execFileSync("git", ["add", "local.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local update"], { + cwd: repoDir, + }); const divergedStatus = await getCheckoutStatus(repoDir); expect(divergedStatus.isGit).toBe(true); @@ -356,8 +380,8 @@ const x = 1; it("does not report incoming additions when the base branch is behind its remote", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update"); - execSync("git push", { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); @@ -367,8 +391,8 @@ const x = 1; it("does not report incoming deletions when the base branch is behind its remote", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "file.txt", "", "remote deletion"); - execSync("git push", { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); @@ -387,8 +411,8 @@ const x = 1; it("uses the merge-base for shortstat when the base branch diverged from its remote", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update"); - execSync("git push", { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); commitFile(repoDir, "local.txt", "local\n", "local update"); const shortstat = await getCheckoutShortstat(repoDir); @@ -400,8 +424,8 @@ const x = 1; const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "remote-one.txt", "remote one\n", "remote update one"); commitFile(cloneDir, "remote-two.txt", "remote two\n", "remote update two"); - execSync("git push", { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); commitFile(repoDir, "local.txt", "local\n", "local update"); const shortstat = await getCheckoutShortstat(repoDir); @@ -413,8 +437,8 @@ const x = 1; commitFile(repoDir, "tracked.txt", "tracked base\n", "add tracked file"); const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming"); - execSync("git push", { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); writeFileSync(join(repoDir, "tracked.txt"), "local one\nlocal two\n"); const shortstat = await getCheckoutShortstat(repoDir); @@ -424,12 +448,12 @@ const x = 1; it("keeps feature shortstat scoped to feature changes when the base remote is ahead", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "feature.txt", "feature\n", "feature update"); - execSync("git checkout main", { cwd: cloneDir }); + execFileSync("git", ["checkout", "main"], { cwd: cloneDir }); commitFile(cloneDir, "base.txt", "base\n", "base update"); - execSync("git push", { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); @@ -438,11 +462,11 @@ const x = 1; it("does not report incoming base changes when a feature branch has no local work beyond merge-base", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git checkout main", { cwd: cloneDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: cloneDir }); commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming"); - execSync("git push", { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); @@ -451,7 +475,7 @@ const x = 1; it("reports feature shortstat ahead of the comparison merge-base", async () => { setupRemoteTrackingMain(repoDir, tempDir); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "feature.txt", "feature\n", "feature update"); const shortstat = await getCheckoutShortstat(repoDir); @@ -461,7 +485,7 @@ const x = 1; it("includes untracked file lines in shortstat additions", async () => { setupRemoteTrackingMain(repoDir, tempDir); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "committed.txt", "one\n", "add committed"); writeFileSync(join(repoDir, "untracked.txt"), "line1\nline2\nline3\n"); @@ -481,7 +505,7 @@ const x = 1; it("counts empty untracked files as 0 additions", async () => { setupRemoteTrackingMain(repoDir, tempDir); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "committed.txt", "one\n", "add committed"); writeFileSync(join(repoDir, "empty.txt"), ""); @@ -492,17 +516,17 @@ const x = 1; it("uses the merge-base for shortstat when a feature branch diverged from its tracked remote", async () => { setupRemoteTrackingMain(repoDir, tempDir); - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git push -u origin feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "feature"], { cwd: repoDir }); const featureCloneDir = join(tempDir, "feature-clone"); - execSync(`git clone ${join(tempDir, "remote.git")} ${featureCloneDir}`); - execSync("git config user.email 'test@test.com'", { cwd: featureCloneDir }); - execSync("git config user.name 'Test'", { cwd: featureCloneDir }); - execSync("git checkout feature", { cwd: featureCloneDir }); + execFileSync("git", ["clone", join(tempDir, "remote.git"), featureCloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: featureCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: featureCloneDir }); + execFileSync("git", ["checkout", "feature"], { cwd: featureCloneDir }); commitFile(featureCloneDir, "remote-feature.txt", "remote feature\n", "remote feature update"); - execSync("git push", { cwd: featureCloneDir }); + execFileSync("git", ["push"], { cwd: featureCloneDir }); commitFile(repoDir, "local-feature.txt", "local feature\n", "local feature update"); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); @@ -511,13 +535,13 @@ const x = 1; it("uses the remote-only base branch as the feature shortstat comparison", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); - execSync("git remote set-head origin main", { cwd: repoDir }); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["remote", "set-head", "origin", "main"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "feature.txt", "feature\n", "feature update"); - execSync("git branch -D main", { cwd: repoDir }); + execFileSync("git", ["branch", "-D", "main"], { cwd: repoDir }); commitFile(cloneDir, "base.txt", "base\n", "base update"); - execSync("git push", { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); @@ -526,7 +550,7 @@ const x = 1; it("returns no shortstat for a clean base branch that is up to date with its remote", async () => { setupRemoteTrackingMain(repoDir, tempDir); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); @@ -545,23 +569,27 @@ const x = 1; it("uses the freshest comparison base for status and shortstat when local main is stale", async () => { const remoteDir = join(tempDir, "remote.git"); const cloneDir = join(tempDir, "clone"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); - execSync(`git clone ${remoteDir} ${cloneDir}`); - execSync("git config user.email 'test@test.com'", { cwd: cloneDir }); - execSync("git config user.name 'Test'", { cwd: cloneDir }); + execFileSync("git", ["clone", remoteDir, cloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: cloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: cloneDir }); writeFileSync(join(cloneDir, "upstream.txt"), "upstream 1\nupstream 2\n"); - execSync("git add upstream.txt", { cwd: cloneDir }); - execSync("git -c commit.gpgsign=false commit -m 'remote update'", { cwd: cloneDir }); - execSync("git push", { cwd: cloneDir }); + execFileSync("git", ["add", "upstream.txt"], { cwd: cloneDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote update"], { + cwd: cloneDir, + }); + execFileSync("git", ["push"], { cwd: cloneDir }); - execSync("git fetch origin", { cwd: repoDir }); - execSync("git checkout -b feature origin/main", { cwd: repoDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature", "origin/main"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature update'", { cwd: repoDir }); + execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature update"], { + cwd: repoDir, + }); const status = await getCheckoutStatus(repoDir); expect(status.isGit).toBe(true); @@ -578,23 +606,27 @@ const x = 1; it("does not count origin base commits as feature changes when local main is stale", async () => { const remoteDir = join(tempDir, "remote.git"); const otherClone = join(tempDir, "other-clone"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "already-on-origin.txt"), "origin\n"); - execSync("git add already-on-origin.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'origin base commit'", { cwd: otherClone }); - execSync("git push", { cwd: otherClone }); + execFileSync("git", ["add", "already-on-origin.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "origin base commit"], { + cwd: otherClone, + }); + execFileSync("git", ["push"], { cwd: otherClone }); writeFileSync(join(repoDir, "local-only-base.txt"), "local\n"); - execSync("git add local-only-base.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'local base drift'", { cwd: repoDir }); - execSync("git fetch origin", { cwd: repoDir }); - execSync("git checkout -b feature origin/main", { cwd: repoDir }); + execFileSync("git", ["add", "local-only-base.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local base drift"], { + cwd: repoDir, + }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature", "origin/main"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toBeNull(); @@ -609,10 +641,12 @@ const x = 1; }); it("falls back to the local base branch when origin is absent", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "local-feature.txt"), "feature\n"); - execSync("git add local-feature.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'local feature'", { cwd: repoDir }); + execFileSync("git", ["add", "local-feature.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local feature"], { + cwd: repoDir, + }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); @@ -624,23 +658,27 @@ const x = 1; it("keeps an explicit origin base ref instead of stripping it to a stale local branch", async () => { const remoteDir = join(tempDir, "remote.git"); const otherClone = join(tempDir, "other-clone"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "origin-base.txt"), "origin\n"); - execSync("git add origin-base.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'origin base'", { cwd: otherClone }); - execSync("git push", { cwd: otherClone }); + execFileSync("git", ["add", "origin-base.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "origin base"], { + cwd: otherClone, + }); + execFileSync("git", ["push"], { cwd: otherClone }); writeFileSync(join(repoDir, "local-drift.txt"), "local\n"); - execSync("git add local-drift.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'local drift'", { cwd: repoDir }); - execSync("git fetch origin", { cwd: repoDir }); - execSync("git checkout -b feature origin/main", { cwd: repoDir }); + execFileSync("git", ["add", "local-drift.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local drift"], { + cwd: repoDir, + }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature", "origin/main"], { cwd: repoDir }); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "origin/main" }); expect(diff.diff).toBe(""); @@ -648,15 +686,17 @@ const x = 1; it("shows feature commits when the local and origin base branches are up to date", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { + cwd: repoDir, + }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); @@ -680,10 +720,12 @@ const x = 1; }); it("shows committed branch changes without dirty working tree changes in base mode", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { + cwd: repoDir, + }); writeFileSync(join(repoDir, "file.txt"), "dirty\n"); writeFileSync(join(repoDir, "untracked.txt"), "untracked\n"); @@ -724,24 +766,30 @@ const x = 1; await commitAll(repoDir, message); - const logMessage = execSync("git log -1 --pretty=%B", { cwd: repoDir }).toString().trim(); + const logMessage = execFileSync("git", ["log", "-1", "--pretty=%B"], { cwd: repoDir }) + .toString() + .trim(); expect(logMessage).toBe(message); }); it("diffs base mode against merge-base (no base-only deletions)", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); // Advance base branch after feature splits off. - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); writeFileSync(join(repoDir, "base-only.txt"), "base\n"); - execSync("git add base-only.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'base only'", { cwd: repoDir }); + execFileSync("git", ["add", "base-only.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "base only"], { + cwd: repoDir, + }); // Make a feature change. - execSync("git checkout feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { + cwd: repoDir, + }); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" }); expect(diff.diff).toContain("feature.txt"); @@ -761,8 +809,8 @@ const x = 1; it("short-circuits tracked binary files", async () => { const trackedBinaryPath = join(repoDir, "tracked-blob.bin"); writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00])); - execSync("git add tracked-blob.bin", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'add tracked binary'", { + execFileSync("git", ["add", "tracked-blob.bin"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add tracked binary"], { cwd: repoDir, }); @@ -822,10 +870,10 @@ const x = 1; const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); - expect(status.repoRoot).toBe(result.worktreePath); + expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(result.worktreePath)); expect(status.isDirty).toBe(true); expect(status.isPaseoOwnedWorktree).toBe(true); - expect(status.mainRepoRoot).toBe(repoDir); + expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe(realpathSync.native(repoDir)); const diff = await getCheckoutDiff(result.worktreePath, { mode: "uncommitted" }, { paseoHome }); expect(diff.diff).toContain("-hello"); @@ -835,7 +883,7 @@ const x = 1; const cleanStatus = await getCheckoutStatus(result.worktreePath, { paseoHome }); expect(cleanStatus.isDirty).toBe(false); - const message = execSync("git log -1 --pretty=%B", { + const message = execFileSync("git", ["log", "-1", "--pretty=%B"], { cwd: result.worktreePath, }) .toString() @@ -857,19 +905,19 @@ const x = 1; if (!status.isGit) { return; } - expect(status.repoRoot).toBe(result.worktreePath); + expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(result.worktreePath)); expect(status.isPaseoOwnedWorktree).toBe(true); - expect(status.mainRepoRoot).toBe(repoDir); + expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe(realpathSync.native(repoDir)); }); it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => { const bareRepoDir = join(tempDir, "bare-repo"); - execSync(`git clone --bare ${repoDir} ${bareRepoDir}`); + execFileSync("git", ["clone", "--bare", repoDir, bareRepoDir]); const mainCheckoutDir = join(tempDir, "main-checkout"); - execSync(`git -C ${bareRepoDir} worktree add ${mainCheckoutDir} main`); - execSync("git config user.email 'test@test.com'", { cwd: mainCheckoutDir }); - execSync("git config user.name 'Test'", { cwd: mainCheckoutDir }); + execFileSync("git", ["-C", bareRepoDir, "worktree", "add", mainCheckoutDir, "main"]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: mainCheckoutDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: mainCheckoutDir }); const worktree = await createLegacyWorktreeForTest({ branchName: "feature", @@ -882,18 +930,22 @@ const x = 1; const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); expect(status.isPaseoOwnedWorktree).toBe(true); - expect(status.mainRepoRoot).toBe(mainCheckoutDir); + expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe( + realpathSync.native(mainCheckoutDir), + ); }); it("detects plain git worktrees from git alone", async () => { const worktreeDir = join(tempDir, "plain-git-worktree"); - execSync(`git worktree add -b feature/plain ${worktreeDir} main`, { cwd: repoDir }); + execFileSync("git", ["worktree", "add", "-b", "feature/plain", worktreeDir, "main"], { + cwd: repoDir, + }); const status = await getCheckoutStatus(worktreeDir, { paseoHome }); expect(status.isGit).toBe(true); - expect(status.repoRoot).toBe(worktreeDir); + expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(worktreeDir)); expect(status.isPaseoOwnedWorktree).toBe(false); - expect(status.mainRepoRoot).toBe(repoDir); + expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe(realpathSync.native(repoDir)); expect(status.currentBranch).toBe("feature/plain"); }); @@ -907,21 +959,25 @@ const x = 1; }); writeFileSync(join(worktree.worktreePath, "merge.txt"), "feature\n"); - execSync("git checkout -b feature", { cwd: worktree.worktreePath }); - execSync("git add merge.txt", { cwd: worktree.worktreePath }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: worktree.worktreePath }); + execFileSync("git", ["add", "merge.txt"], { cwd: worktree.worktreePath }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); - const featureCommit = execSync("git rev-parse HEAD", { cwd: worktree.worktreePath }) + const featureCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: worktree.worktreePath }) .toString() .trim(); await mergeToBase(worktree.worktreePath, { baseRef: "main" }, { paseoHome }); - const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, { - cwd: repoDir, - stdio: "pipe", - }); + const baseContainsFeature = execFileSync( + "git", + ["merge-base", "--is-ancestor", featureCommit, "main"], + { + cwd: repoDir, + stdio: "pipe", + }, + ); expect(baseContainsFeature).toBeDefined(); const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); @@ -930,7 +986,7 @@ const x = 1; expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0); } - const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { + const currentBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktree.worktreePath, }) .toString() @@ -939,14 +995,16 @@ const x = 1; }); it("reports the base worktree cwd when merge-to-base mutates a separate checkout", async () => { - execSync("git checkout -b develop", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir }); writeFileSync(join(repoDir, "develop.txt"), "develop\n"); - execSync("git add develop.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'develop commit'", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["add", "develop.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "develop commit"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const baseWorktreePath = join(tempDir, "base-worktree"); - execSync(`git worktree add ${baseWorktreePath} develop`, { cwd: repoDir }); + execFileSync("git", ["worktree", "add", baseWorktreePath, "develop"], { cwd: repoDir }); const featureWorktree = await createLegacyWorktreeForTest({ branchName: "feature", @@ -957,120 +1015,152 @@ const x = 1; }); writeFileSync(join(featureWorktree.worktreePath, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: featureWorktree.worktreePath }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + execFileSync("git", ["add", "feature.txt"], { cwd: featureWorktree.worktreePath }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: featureWorktree.worktreePath, }); const mutatedCwd = await mergeToBase(featureWorktree.worktreePath, {}, { paseoHome }); - expect(mutatedCwd).toBe(baseWorktreePath); + expect(realpathSync.native(mutatedCwd)).toBe(realpathSync.native(baseWorktreePath)); expect(mutatedCwd).not.toBe(featureWorktree.worktreePath); }); it("merges from the most-ahead base ref (origin/main when it is ahead)", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); // Advance origin/main without advancing local main. const otherClone = join(tempDir, "other-clone"); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote\n"); - execSync("git add remote-only.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'remote only'", { cwd: otherClone }); - const remoteOnlyCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim(); - execSync("git push", { cwd: otherClone }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only"], { + cwd: otherClone, + }); + const remoteOnlyCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: otherClone }) + .toString() + .trim(); + execFileSync("git", ["push"], { cwd: otherClone }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { + cwd: repoDir, + }); await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }); - execSync(`git merge-base --is-ancestor ${remoteOnlyCommit} feature`, { cwd: repoDir }); + execFileSync("git", ["merge-base", "--is-ancestor", remoteOnlyCommit, "feature"], { + cwd: repoDir, + }); }); it("merges from the most-ahead base ref (local main when it is ahead)", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); // Advance local main without pushing. writeFileSync(join(repoDir, "local-only.txt"), "local\n"); - execSync("git add local-only.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'local only'", { cwd: repoDir }); - const localOnlyCommit = execSync("git rev-parse HEAD", { cwd: repoDir }).toString().trim(); + execFileSync("git", ["add", "local-only.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local only"], { + cwd: repoDir, + }); + const localOnlyCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }) + .toString() + .trim(); - execSync(`git checkout -b feature ${localOnlyCommit}~1`, { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature", `${localOnlyCommit}~1`], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { + cwd: repoDir, + }); await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }); - execSync(`git merge-base --is-ancestor ${localOnlyCommit} feature`, { cwd: repoDir }); + execFileSync("git", ["merge-base", "--is-ancestor", localOnlyCommit, "feature"], { + cwd: repoDir, + }); }); it("aborts merge-from-base on conflicts and leaves no merge in progress", async () => { writeFileSync(join(repoDir, "conflict.txt"), "base\n"); - execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'add conflict file'", { cwd: repoDir }); + execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add conflict file"], { + cwd: repoDir, + }); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "conflict.txt"), "feature\n"); - execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature change'", { cwd: repoDir }); + execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { + cwd: repoDir, + }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); writeFileSync(join(repoDir, "conflict.txt"), "main change\n"); - execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'main change'", { cwd: repoDir }); + execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "main change"], { + cwd: repoDir, + }); - execSync("git checkout feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "feature"], { cwd: repoDir }); await expect( mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }), ).rejects.toBeInstanceOf(MergeFromBaseConflictError); - const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim(); + const porcelain = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }) + .toString() + .trim(); expect(porcelain).toBe(""); - expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow(); + expect(() => + execFileSync("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: repoDir }), + ).toThrow(); }); it("pulls the current branch from origin", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "pulled.txt"), "remote\n"); - execSync("git add pulled.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'remote pull commit'", { cwd: otherClone }); - const remoteCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim(); - execSync("git push", { cwd: otherClone }); + execFileSync("git", ["add", "pulled.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote pull commit"], { + cwd: otherClone, + }); + const remoteCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: otherClone }) + .toString() + .trim(); + execFileSync("git", ["push"], { cwd: otherClone }); await pullCurrentBranch(repoDir); - execSync(`git merge-base --is-ancestor ${remoteCommit} HEAD`, { cwd: repoDir }); - expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n"); + execFileSync("git", ["merge-base", "--is-ancestor", remoteCommit, "HEAD"], { cwd: repoDir }); + expect(readFileSync(join(repoDir, "pulled.txt"), "utf8").replace(/\r\n/g, "\n")).toBe( + "remote\n", + ); }); it("invalidates GitHub cache after successful local git mutation paths", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); const invalidatedCwds: string[] = []; const github = createGitHubServiceForStatus(null); @@ -1085,58 +1175,78 @@ const x = 1; it("aborts pull on merge conflicts and leaves no merge in progress", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); writeFileSync(join(repoDir, "conflict.txt"), "local\n"); - execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'local conflict commit'", { cwd: repoDir }); - - const otherClone = join(tempDir, "other-clone"); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); - writeFileSync(join(otherClone, "conflict.txt"), "remote\n"); - execSync("git add conflict.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'remote conflict commit'", { cwd: otherClone }); - execSync("git push", { cwd: otherClone }); - - await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error); - - const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim(); - expect(porcelain).toBe(""); - expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow(); - }); - - it("aborts pull on rebase conflicts and leaves no rebase in progress", async () => { - const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); - execSync("git config pull.rebase true", { cwd: repoDir }); - - writeFileSync(join(repoDir, "conflict.txt"), "local\n"); - execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", { + execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local conflict commit"], { cwd: repoDir, }); const otherClone = join(tempDir, "other-clone"); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "conflict.txt"), "remote\n"); - execSync("git add conflict.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'remote rebase conflict commit'", { + execFileSync("git", ["add", "conflict.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote conflict commit"], { cwd: otherClone, }); - execSync("git push", { cwd: otherClone }); + execFileSync("git", ["push"], { cwd: otherClone }); await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error); - const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: repoDir }).toString().trim(); - const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim(); + const porcelain = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }) + .toString() + .trim(); + expect(porcelain).toBe(""); + expect(() => + execFileSync("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: repoDir }), + ).toThrow(); + }); + + it("aborts pull on rebase conflicts and leaves no rebase in progress", async () => { + const remoteDir = join(tempDir, "remote.git"); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "pull.rebase", "true"], { cwd: repoDir }); + + writeFileSync(join(repoDir, "conflict.txt"), "local\n"); + execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "local rebase conflict commit"], + { + cwd: repoDir, + }, + ); + + const otherClone = join(tempDir, "other-clone"); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); + writeFileSync(join(otherClone, "conflict.txt"), "remote\n"); + execFileSync("git", ["add", "conflict.txt"], { cwd: otherClone }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "remote rebase conflict commit"], + { + cwd: otherClone, + }, + ); + execFileSync("git", ["push"], { cwd: otherClone }); + + await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error); + + const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { cwd: repoDir }) + .toString() + .trim(); + const porcelain = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }) + .toString() + .trim(); expect(porcelain).toBe(""); expect(existsSync(join(gitDir, "rebase-merge"))).toBe(false); expect(existsSync(join(gitDir, "rebase-apply"))).toBe(false); @@ -1144,45 +1254,51 @@ const x = 1; it("pushes the current branch to origin", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "push.txt"), "push\n"); - execSync("git add push.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'push commit'", { cwd: repoDir }); + execFileSync("git", ["add", "push.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "push commit"], { + cwd: repoDir, + }); await pushCurrentBranch(repoDir); - execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`); + execFileSync("git", ["--git-dir", remoteDir, "show-ref", "--verify", "refs/heads/feature"]); }); it("lists merged local and remote branch suggestions with provenance", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); - execSync("git checkout -b feature/local-only", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); - execSync("git checkout -b feature/shared", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/local-only"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/shared"], { cwd: repoDir }); writeFileSync(join(repoDir, "shared.txt"), "shared\n"); - execSync("git add shared.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir }); - execSync("git push -u origin feature/shared", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["add", "shared.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "shared branch"], { + cwd: repoDir, + }); + execFileSync("git", ["push", "-u", "origin", "feature/shared"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); - execSync("git checkout -b feature/remote-only", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); + execFileSync("git", ["checkout", "-b", "feature/remote-only"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); - execSync("git add remote-only.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone }); - execSync("git push -u origin feature/remote-only", { cwd: otherClone }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only branch"], { + cwd: otherClone, + }); + execFileSync("git", ["push", "-u", "origin", "feature/remote-only"], { cwd: otherClone }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const branches = await listBranchSuggestions(repoDir, { limit: 50 }); const branchNames = branches.map((branch) => branch.name); @@ -1217,29 +1333,33 @@ const x = 1; it("resolves branch checkout targets with local precedence and origin normalization", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); - execSync("git checkout -b feature/local", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); - execSync("git checkout -b feature/shared", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/local"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/shared"], { cwd: repoDir }); writeFileSync(join(repoDir, "shared.txt"), "shared\n"); - execSync("git add shared.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir }); - execSync("git push -u origin feature/shared", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["add", "shared.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "shared branch"], { + cwd: repoDir, + }); + execFileSync("git", ["push", "-u", "origin", "feature/shared"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); - execSync("git checkout -b feature/remote-only", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); + execFileSync("git", ["checkout", "-b", "feature/remote-only"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); - execSync("git add remote-only.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone }); - execSync("git push -u origin feature/remote-only", { cwd: otherClone }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only branch"], { + cwd: otherClone, + }); + execFileSync("git", ["push", "-u", "origin", "feature/remote-only"], { cwd: otherClone }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); await expect(resolveBranchCheckout(repoDir, "feature/local")).resolves.toEqual({ kind: "local", @@ -1265,9 +1385,9 @@ const x = 1; }); it("does not resolve tags as branch checkout targets", async () => { - execSync("git checkout -b feature/a", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); - execSync("git tag v1", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/a"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + execFileSync("git", ["tag", "v1"], { cwd: repoDir }); await expect(resolveBranchCheckout(repoDir, "v1")).resolves.toEqual({ kind: "not-found", @@ -1276,32 +1396,36 @@ const x = 1; it("checks out a remote-only branch as a local tracking branch", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); - execSync("git checkout -b feature/remote-only", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); + execFileSync("git", ["checkout", "-b", "feature/remote-only"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); - execSync("git add remote-only.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone }); - execSync("git push -u origin feature/remote-only", { cwd: otherClone }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only branch"], { + cwd: otherClone, + }); + execFileSync("git", ["push", "-u", "origin", "feature/remote-only"], { cwd: otherClone }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const resolution = await resolveBranchCheckout(repoDir, "feature/remote-only"); await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({ source: "remote", }); - expect(execSync("git symbolic-ref --short HEAD", { cwd: repoDir }).toString().trim()).toBe( - "feature/remote-only", - ); - execSync("git symbolic-ref -q HEAD", { cwd: repoDir }); expect( - execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { cwd: repoDir }) + execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd: repoDir }).toString().trim(), + ).toBe("feature/remote-only"); + execFileSync("git", ["symbolic-ref", "-q", "HEAD"], { cwd: repoDir }); + expect( + execFileSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { + cwd: repoDir, + }) .toString() .trim(), ).toBe("origin/feature/remote-only"); @@ -1309,32 +1433,36 @@ const x = 1; it("normalizes explicit origin input when checking out a remote-only branch", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); - execSync(`git clone ${remoteDir} ${otherClone}`); - execSync("git config user.email 'test@test.com'", { cwd: otherClone }); - execSync("git config user.name 'Test'", { cwd: otherClone }); - execSync("git checkout -b feature/remote-only", { cwd: otherClone }); + execFileSync("git", ["clone", remoteDir, otherClone]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); + execFileSync("git", ["checkout", "-b", "feature/remote-only"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); - execSync("git add remote-only.txt", { cwd: otherClone }); - execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone }); - execSync("git push -u origin feature/remote-only", { cwd: otherClone }); - execSync("git fetch origin", { cwd: repoDir }); + execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only branch"], { + cwd: otherClone, + }); + execFileSync("git", ["push", "-u", "origin", "feature/remote-only"], { cwd: otherClone }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const resolution = await resolveBranchCheckout(repoDir, "origin/feature/remote-only"); await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({ source: "remote", }); - expect(execSync("git symbolic-ref --short HEAD", { cwd: repoDir }).toString().trim()).toBe( - "feature/remote-only", - ); - execSync("git symbolic-ref -q HEAD", { cwd: repoDir }); expect( - execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { cwd: repoDir }) + execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd: repoDir }).toString().trim(), + ).toBe("feature/remote-only"); + execFileSync("git", ["symbolic-ref", "-q", "HEAD"], { cwd: repoDir }); + expect( + execFileSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { + cwd: repoDir, + }) .toString() .trim(), ).toBe("origin/feature/remote-only"); @@ -1342,24 +1470,26 @@ const x = 1; it("checks out the local branch when local and remote branches share a name", async () => { const remoteDir = join(tempDir, "remote.git"); - execSync(`git init --bare -b main ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); - execSync("git checkout -b feature/shared", { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/shared"], { cwd: repoDir }); writeFileSync(join(repoDir, "shared.txt"), "shared\n"); - execSync("git add shared.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir }); - execSync("git push -u origin feature/shared", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["add", "shared.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "shared branch"], { + cwd: repoDir, + }); + execFileSync("git", ["push", "-u", "origin", "feature/shared"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const resolution = await resolveBranchCheckout(repoDir, "feature/shared"); await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({ source: "local", }); - expect(execSync("git symbolic-ref --short HEAD", { cwd: repoDir }).toString().trim()).toBe( - "feature/shared", - ); + expect( + execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd: repoDir }).toString().trim(), + ).toBe("feature/shared"); }); it("throws the existing branch-not-found message for unknown checkout targets", async () => { @@ -1373,12 +1503,12 @@ const x = 1; }); it("filters branch suggestions by query and enforces result limit", async () => { - execSync("git checkout -b feature/alpha", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); - execSync("git checkout -b feature/beta", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); - execSync("git checkout -b chore/docs", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/alpha"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature/beta"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "chore/docs"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const branches = await listBranchSuggestions(repoDir, { query: "FEATURE/", @@ -1390,7 +1520,9 @@ const x = 1; }); it("disables GitHub features when gh is unavailable", async () => { - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); const github = createGitHubServiceForStatus(null); github.getCurrentPullRequestStatus = async () => { @@ -1402,8 +1534,10 @@ const x = 1; }); it("returns merged PR status when no open PR exists for the current branch", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); const status = await getPullRequestStatus( repoDir, @@ -1424,8 +1558,10 @@ const x = 1; }); it("propagates S1 PR metadata and check display fields through checkout PR status", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); const status = await getPullRequestStatus( repoDir, @@ -1473,11 +1609,19 @@ const x = 1; }); it("uses the tracked fork branch for PR worktree status lookup", async () => { - execSync("git checkout -b chethanuk/main", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); - execSync("git remote add paseo-pr-345 git@github.com:chethanuk/paseo.git", { cwd: repoDir }); - execSync("git config branch.chethanuk/main.remote paseo-pr-345", { cwd: repoDir }); - execSync("git config branch.chethanuk/main.merge refs/heads/main", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "chethanuk/main"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["remote", "add", "paseo-pr-345", "git@github.com:chethanuk/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.chethanuk/main.remote", "paseo-pr-345"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.chethanuk/main.merge", "refs/heads/main"], { + cwd: repoDir, + }); const requestedTargets: Array<{ headRef: string; headRepositoryOwner?: string }> = []; const github = createGitHubServiceForStatus( @@ -1512,8 +1656,10 @@ const x = 1; }); it("returns closed-unmerged PR status without marking it as merged", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); const status = await getPullRequestStatus( repoDir, @@ -1535,8 +1681,10 @@ const x = 1; }); it("caches PR status results for duplicate lookups", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); let callCount = 0; const github = createGitHubServiceForStatus(createPullRequestStatus(), { @@ -1552,8 +1700,10 @@ const x = 1; }); it("expires cached PR status after the TTL", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); __setPullRequestStatusCacheTtlForTests(50); try { @@ -1581,8 +1731,10 @@ const x = 1; }); it("keeps stale PR status when a refresh hits a transient GitHub error", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); __setPullRequestStatusCacheTtlForTests(50); try { @@ -1617,8 +1769,10 @@ const x = 1; }); it("clears stale PR status after a successful no-PR refresh", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); __setPullRequestStatusCacheTtlForTests(50); try { @@ -1650,8 +1804,10 @@ const x = 1; }); it("dedupes concurrent PR status lookups for the same cwd", async () => { - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); let callCount = 0; const github = createGitHubServiceForStatus(createPullRequestStatus(), { @@ -1670,26 +1826,26 @@ const x = 1; it("returns typed MergeConflictError on merge conflicts", async () => { const conflictFile = join(repoDir, "conflict.txt"); writeFileSync(conflictFile, "base\n"); - execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'add conflict file'", { + execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add conflict file"], { cwd: repoDir, }); - execSync("git checkout -b feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(conflictFile, "feature change\n"); - execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature change'", { + execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { cwd: repoDir, }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); writeFileSync(conflictFile, "main change\n"); - execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'main change'", { + execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "main change"], { cwd: repoDir, }); - execSync("git checkout feature", { cwd: repoDir }); + execFileSync("git", ["checkout", "feature"], { cwd: repoDir }); await expect(mergeToBase(repoDir, { baseRef: "main" })).rejects.toBeInstanceOf( MergeConflictError, @@ -1698,11 +1854,13 @@ const x = 1; it("uses stored baseRefName for Paseo worktrees (no heuristics)", async () => { // Create a non-default base branch with a unique commit. - execSync("git checkout -b develop", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "develop\n"); - execSync("git add file.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "develop change"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); // Create a worktree/branch based on develop, but keep main as the repo default. const worktree = await createLegacyWorktreeForTest({ @@ -1714,8 +1872,8 @@ const x = 1; }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: worktree.worktreePath }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + execFileSync("git", ["add", "feature.txt"], { cwd: worktree.worktreePath }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); @@ -1739,8 +1897,8 @@ const x = 1; }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: worktree.worktreePath }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + execFileSync("git", ["add", "feature.txt"], { cwd: worktree.worktreePath }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); @@ -1760,12 +1918,18 @@ const x = 1; }); it("resolves the repository default branch from origin HEAD", async () => { - execSync("git checkout -b develop", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); - execSync("git remote add origin https://github.com/acme/repo.git", { cwd: repoDir }); - execSync("git update-ref refs/remotes/origin/main refs/heads/main", { cwd: repoDir }); - execSync("git update-ref refs/remotes/origin/develop refs/heads/develop", { cwd: repoDir }); - execSync("git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main", { + execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["update-ref", "refs/remotes/origin/main", "refs/heads/main"], { + cwd: repoDir, + }); + execFileSync("git", ["update-ref", "refs/remotes/origin/develop", "refs/heads/develop"], { + cwd: repoDir, + }); + execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], { cwd: repoDir, }); @@ -1774,11 +1938,13 @@ const x = 1; it("merges to stored baseRefName when baseRef is not provided", async () => { // Create a non-default base branch with a unique commit. - execSync("git checkout -b develop", { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "develop\n"); - execSync("git add file.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir }); - execSync("git checkout main", { cwd: repoDir }); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "develop change"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); // Create a Paseo worktree configured to use develop as base. const worktree = await createLegacyWorktreeForTest({ @@ -1790,23 +1956,23 @@ const x = 1; }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: worktree.worktreePath }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + execFileSync("git", ["add", "feature.txt"], { cwd: worktree.worktreePath }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); - const featureCommit = execSync("git rev-parse HEAD", { cwd: worktree.worktreePath }) + const featureCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: worktree.worktreePath }) .toString() .trim(); // No baseRef passed: should merge into the configured base (develop), not default/main. await mergeToBase(worktree.worktreePath, {}, { paseoHome }); - execSync(`git merge-base --is-ancestor ${featureCommit} develop`, { + execFileSync("git", ["merge-base", "--is-ancestor", featureCommit, "develop"], { cwd: repoDir, stdio: "pipe", }); expect(() => - execSync(`git merge-base --is-ancestor ${featureCommit} main`, { + execFileSync("git", ["merge-base", "--is-ancestor", featureCommit, "main"], { cwd: repoDir, stdio: "pipe", }), @@ -1823,8 +1989,8 @@ const x = 1; }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); - execSync("git add feature.txt", { cwd: worktree.worktreePath }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + execFileSync("git", ["add", "feature.txt"], { cwd: worktree.worktreePath }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); @@ -1853,9 +2019,9 @@ const x = 1; const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); expect(status.currentBranch).toBe("feature"); - expect(status.repoRoot).toBe(worktree.worktreePath); + expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(worktree.worktreePath)); expect(status.isPaseoOwnedWorktree).toBe(true); - expect(status.mainRepoRoot).toBe(repoDir); + expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe(realpathSync.native(repoDir)); expect(status.baseRef).toBe("main"); }); diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 88c05a388..cef2928a0 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -2,9 +2,10 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSyn import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isPlatform } from "../test-utils/platform.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js"; -const isWindows = process.platform === "win32"; +const isWindows = isPlatform("win32"); describe("searchHomeDirectories", () => { let tempRoot: string; @@ -18,6 +19,8 @@ describe("searchHomeDirectories", () => { mkdirSync(homeDir, { recursive: true }); mkdirSync(outsideDir, { recursive: true }); + homeDir = realpathSync(homeDir); + outsideDir = realpathSync(outsideDir); mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); @@ -26,7 +29,9 @@ describe("searchHomeDirectories", () => { writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n"); mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true }); - symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); + if (!isWindows) { + symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); + } }); afterEach(() => { @@ -50,8 +55,9 @@ describe("searchHomeDirectories", () => { limit: 10, }); - expect(results).toContain(path.join(homeDir, "projects")); - expect(results).toContain(path.join(homeDir, "projects", "paseo")); + const resolvedResults = results.map((result) => realpathSync.native(result)); + expect(resolvedResults).toContain(realpathSync.native(path.join(homeDir, "projects"))); + expect(resolvedResults).toContain(realpathSync.native(path.join(homeDir, "projects", "paseo"))); expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); }); @@ -62,7 +68,9 @@ describe("searchHomeDirectories", () => { limit: 10, }); - expect(results).toEqual([path.join(homeDir, "projects", "paseo")]); + expect(results.map((result) => realpathSync.native(result))).toEqual([ + realpathSync.native(path.join(homeDir, "projects", "paseo")), + ]); }); it("prioritizes exact segment matches before segment-prefix matches", async () => { @@ -77,8 +85,9 @@ describe("searchHomeDirectories", () => { limit: 30, }); - const exactIndex = results.indexOf(exactSegmentPath); - const prefixIndex = results.indexOf(prefixSegmentPath); + const resolvedResults = results.map((result) => realpathSync.native(result)); + const exactIndex = resolvedResults.indexOf(realpathSync.native(exactSegmentPath)); + const prefixIndex = resolvedResults.indexOf(realpathSync.native(prefixSegmentPath)); expect(exactIndex).toBeGreaterThanOrEqual(0); expect(prefixIndex).toBeGreaterThanOrEqual(0); expect(exactIndex).toBeLessThan(prefixIndex); @@ -96,8 +105,9 @@ describe("searchHomeDirectories", () => { limit: 30, }); - const earlierIndex = results.indexOf(earlierPath); - const laterIndex = results.indexOf(laterPath); + const resolvedResults = results.map((result) => realpathSync.native(result)); + const earlierIndex = resolvedResults.indexOf(realpathSync.native(earlierPath)); + const laterIndex = resolvedResults.indexOf(realpathSync.native(laterPath)); expect(earlierIndex).toBeGreaterThanOrEqual(0); expect(laterIndex).toBeGreaterThanOrEqual(0); expect(earlierIndex).toBeLessThan(laterIndex); @@ -125,7 +135,8 @@ describe("searchHomeDirectories", () => { expect(results).not.toContain(path.join(homeDir, ".hidden", "cache")); }); - it("does not return paths that escape home through symlinks", async () => { + // POSIX-only: creates and follows a symlink escape fixture. + it.skipIf(isWindows)("does not return paths that escape home through symlinks", async () => { const results = await searchHomeDirectories({ homeDir, query: "outside", @@ -170,7 +181,9 @@ describe("searchWorkspaceEntries", () => { ); writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n"); - symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link")); + if (!isWindows) { + symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link")); + } }); afterEach(() => { @@ -214,28 +227,32 @@ describe("searchWorkspaceEntries", () => { expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]); }); - it("supports path-style queries and does not escape cwd through symlinks", async () => { - const pathResults = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "src/co", - limit: 20, - includeFiles: true, - includeDirectories: true, - }); - expect(pathResults).toContainEqual({ - path: "src/components", - kind: "directory", - }); + // POSIX-only: creates and follows a symlink escape fixture. + it.skipIf(isWindows)( + "supports path-style queries and does not escape cwd through symlinks", + async () => { + const pathResults = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "src/co", + limit: 20, + includeFiles: true, + includeDirectories: true, + }); + expect(pathResults).toContainEqual({ + path: "src/components", + kind: "directory", + }); - const escapedResults = await searchWorkspaceEntries({ - cwd: workspaceDir, - query: "escaped", - limit: 20, - includeFiles: true, - includeDirectories: true, - }); - expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false); - }); + const escapedResults = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "escaped", + limit: 20, + includeFiles: true, + includeDirectories: true, + }); + expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false); + }, + ); it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => { mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true }); diff --git a/packages/server/src/utils/executable.probe.test.ts b/packages/server/src/utils/executable.probe.test.ts index 18ced11db..1ce56c5cd 100644 --- a/packages/server/src/utils/executable.probe.test.ts +++ b/packages/server/src/utils/executable.probe.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { afterEach, describe, expect, test } from "vitest"; +import { isPlatform } from "../test-utils/platform.js"; import { probeExecutable } from "./executable.js"; const timeoutMs = 1000; @@ -142,18 +143,40 @@ afterEach(() => { }); describe("probeExecutable", () => { - test.each(fixtures)("$name", async ({ create, expected }) => { - const { executablePath, pidFile } = create(makeTempDir()); - const startedAt = performance.now(); + // POSIX-only: positive fixtures rely on direct script probing; Windows command-script probing has separate coverage. + test.skipIf(isPlatform("win32")).each(fixtures.filter((fixture) => fixture.expected))( + "$name", + async ({ create, expected }) => { + const { executablePath, pidFile } = create(makeTempDir()); + const startedAt = performance.now(); - const result = await probeExecutable(executablePath, timeoutMs); + const result = await probeExecutable(executablePath, timeoutMs); - expect(result).toBe(expected); - expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs); - if (pidFile) { - await waitForFile(pidFile); - const pid = Number(readFileSync(pidFile, "utf8")); - expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" })); - } - }); + expect(result).toBe(expected); + expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs); + if (pidFile) { + await waitForFile(pidFile); + const pid = Number(readFileSync(pidFile, "utf8")); + expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" })); + } + }, + ); + + test.each(fixtures.filter((fixture) => !fixture.expected))( + "$name", + async ({ create, expected }) => { + const { executablePath, pidFile } = create(makeTempDir()); + const startedAt = performance.now(); + + const result = await probeExecutable(executablePath, timeoutMs); + + expect(result).toBe(expected); + expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs); + if (pidFile) { + await waitForFile(pidFile); + const pid = Number(readFileSync(pidFile, "utf8")); + expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" })); + } + }, + ); }); diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index 9464d3d44..88e7bc9a3 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -9,6 +9,7 @@ import { quoteWindowsArgument, quoteWindowsCommand, } from "./executable.js"; +import { isPlatform } from "../test-utils/platform.js"; const originalEnv = { PATH: process.env.PATH, @@ -28,24 +29,16 @@ function prependPath(...dirs: string[]): void { function writeExecutable(filePath: string, content: string): string { writeFileSync(filePath, content); - if (process.platform !== "win32") { + if (!isPlatform("win32")) { chmodSync(filePath, 0o755); } return filePath; } -function writeInvokableFixture(dir: string, name: string): string { - if (process.platform === "win32") { - return writeExecutable(path.join(dir, `${name}.cmd`), "@echo off\r\necho 0.1\r\n"); - } - return writeExecutable(path.join(dir, name), "#!/bin/sh\necho 0.1\n"); -} - function writeBrokenAbsoluteFixture(dir: string): string { - const filePath = - process.platform === "win32" ? path.join(dir, "broken.exe") : path.join(dir, "broken"); + const filePath = isPlatform("win32") ? path.join(dir, "broken.exe") : path.join(dir, "broken"); writeFileSync(filePath, "not executable"); - if (process.platform !== "win32") { + if (!isPlatform("win32")) { chmodSync(filePath, 0o644); } return filePath; @@ -64,7 +57,7 @@ afterEach(() => { }); describe("findExecutable", () => { - describe.skipIf(process.platform === "win32")("POSIX", () => { + describe.skipIf(isPlatform("win32"))("POSIX", () => { test("finds an extensionless executable and skips an earlier non-executable candidate", async () => { const executableDir = makeTempDir(); const nonExecutableDir = makeTempDir(); @@ -78,7 +71,7 @@ describe("findExecutable", () => { }); }); - describe.runIf(process.platform === "win32")("Windows", () => { + describe.runIf(isPlatform("win32"))("Windows", () => { test("returns a working .cmd when an invalid .exe candidate appears first", async () => { const dir = makeTempDir(); process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter); @@ -110,10 +103,7 @@ describe("findExecutable", () => { }); test("returns an invokable absolute path", async () => { - const dir = makeTempDir(); - const fixture = writeInvokableFixture(dir, "absolute-ok"); - - await expect(findExecutable(fixture)).resolves.toBe(fixture); + await expect(findExecutable(process.execPath)).resolves.toBe(process.execPath); }); test("returns null for an absolute path that cannot spawn", async () => { diff --git a/packages/server/src/utils/paseo-config-file.test.ts b/packages/server/src/utils/paseo-config-file.test.ts index c12d6f789..02cbd9054 100644 --- a/packages/server/src/utils/paseo-config-file.test.ts +++ b/packages/server/src/utils/paseo-config-file.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isPlatform } from "../test-utils/platform.js"; import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js"; import { readPaseoConfigForEdit, @@ -97,26 +98,30 @@ describe("paseo config file substrate", () => { ); }); - it("rejects stale writes when the current revision changed before rename", () => { - writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); - const expectedRevision = statPaseoConfigPath(tempDir); - writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } })); - const currentRevision = statPaseoConfigPath(tempDir); + // POSIX-only: Windows mtime granularity can collapse the two revisions in this fixture. + it.skipIf(isPlatform("win32"))( + "rejects stale writes when the current revision changed before rename", + () => { + writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); + const expectedRevision = statPaseoConfigPath(tempDir); + writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } })); + const currentRevision = statPaseoConfigPath(tempDir); - const result = writePaseoConfigForEdit({ - repoRoot: tempDir, - config: { worktree: { setup: "from editor" } }, - expectedRevision, - }); + const result = writePaseoConfigForEdit({ + repoRoot: tempDir, + config: { worktree: { setup: "from editor" } }, + expectedRevision, + }); - expect(result).toEqual({ - ok: false, - error: { code: "stale_project_config", currentRevision }, - }); - expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe( - JSON.stringify({ worktree: { setup: "new" } }), - ); - }); + expect(result).toEqual({ + ok: false, + error: { code: "stale_project_config", currentRevision }, + }); + expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe( + JSON.stringify({ worktree: { setup: "new" } }), + ); + }, + ); it("round-trips unknown top-level, worktree, and script-entry fields", () => { const config = { diff --git a/packages/server/src/utils/spawn.launch-regression.test.ts b/packages/server/src/utils/spawn.launch-regression.test.ts index 9d9c559ef..f1a03d7f5 100644 --- a/packages/server/src/utils/spawn.launch-regression.test.ts +++ b/packages/server/src/utils/spawn.launch-regression.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { findExecutable } from "./executable.js"; import { spawnProcess } from "./spawn.js"; +import { isPlatform } from "../test-utils/platform.js"; interface SpawnResult { code: number | null; @@ -136,10 +137,9 @@ async function runFixture(params: { } function withWindowsPathEntry(dir: string, run: () => Promise): Promise { - const pathKey = - process.platform === "win32" - ? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path") - : "PATH"; + const pathKey = isPlatform("win32") + ? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path") + : "PATH"; const previousPath = process.env[pathKey]; const previousPathExt = process.env.PATHEXT; @@ -169,7 +169,7 @@ afterEach(() => { } }); -describe.runIf(process.platform === "win32")("Windows spawn launch regression", () => { +describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => { test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => { const fixture = makeFixture(); @@ -227,7 +227,7 @@ describe.runIf(process.platform === "win32")("Windows spawn launch regression", }); }); -describe.skipIf(process.platform === "win32")("spawn launch regression smoke", () => { +describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => { test("direct launch with a space-containing executable works on this platform", async () => { const fixture = makeFixture(); diff --git a/packages/server/src/utils/spawn.test.ts b/packages/server/src/utils/spawn.test.ts index 43635d0e9..d441fcbcf 100644 --- a/packages/server/src/utils/spawn.test.ts +++ b/packages/server/src/utils/spawn.test.ts @@ -67,7 +67,7 @@ describe("execCommand", () => { const result = await execCommand(command.command, command.args, { cwd }); - expect(result.stdout.trim()).toBe(cwd); + expect(realpathSync(result.stdout.trim())).toBe(cwd); expect(result.stderr).toBe(""); }); diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts new file mode 100644 index 000000000..8599f2ad9 --- /dev/null +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -0,0 +1,1125 @@ +// POSIX-only: git worktree and teardown shell fixtures +/* eslint-disable max-nested-callbacks */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + BranchAlreadyCheckedOutError, + createWorktree as createWorktreePrimitive, + deriveWorktreeProjectHash, + deletePaseoWorktree, + getScriptConfigs, + getWorktreeSetupCommands, + getWorktreeTerminalSpecs, + getWorktreeTeardownCommands, + isServiceScript, + isPaseoOwnedWorktreeCwd, + listPaseoWorktrees, + readPaseoConfig, + resolveWorktreeRuntimeEnv, + type WorktreeSetupCommandProgressEvent, + runWorktreeSetupCommands, + type CreateWorktreeOptions, + type WorktreeConfig, +} from "./worktree"; +import type { PaseoConfig } from "./paseo-config-schema.js"; +import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; +import { execFileSync } from "child_process"; +import { isPlatform } from "../test-utils/platform.js"; +import { + mkdtempSync, + mkdirSync, + rmSync, + existsSync, + realpathSync, + writeFileSync, + readFileSync, +} from "fs"; +import { dirname, join } from "path"; +import { tmpdir } from "os"; +import net from "node:net"; + +function loadConfigForTest(repoRoot: string): PaseoConfig | null { + const result = readPaseoConfig(repoRoot); + return result.ok ? result.config : null; +} + +interface LegacyCreateWorktreeTestOptions { + branchName: string; + cwd: string; + baseBranch: string; + worktreeSlug: string; + runSetup?: boolean; + paseoHome?: string; +} + +function createLegacyWorktreeForTest( + options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions, +): Promise { + if ("source" in options) { + return createWorktreePrimitive(options); + } + + return createWorktreePrimitive({ + cwd: options.cwd, + worktreeSlug: options.worktreeSlug, + source: { + kind: "branch-off", + baseBranch: options.baseBranch, + branchName: options.branchName, + }, + runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, + }); +} + +describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { + describe("createWorktree", () => { + let tempDir: string; + let repoDir: string; + let paseoHome: string; + + beforeEach(() => { + // Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS) + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-test-"))); + repoDir = join(tempDir, "test-repo"); + paseoHome = join(tempDir, "paseo-home"); + + // Create a git repo with an initial commit + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("creates a worktree for the current branch (main)", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "hello-world", + paseoHome, + }); + + expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world")); + expect(existsSync(result.worktreePath)).toBe(true); + expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + expect(existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + expect(metadata).toMatchObject({ version: 1, baseRefName: "main" }); + }); + + it.skip("detects paseo-owned worktrees across realpath differences (macOS /var vs /private/var)", async () => { + // Intentionally create repo using the non-realpath tmpdir() variant (often /var/... on macOS). + const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-")); + const privateTempDir = realpathSync(varTempDir); + const varRepoDir = join(varTempDir, "test-repo"); + const varPaseoHome = join(varTempDir, "paseo-home"); + mkdirSync(varRepoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: varRepoDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: varRepoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: varRepoDir }); + writeFileSync(join(varRepoDir, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: varRepoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: varRepoDir, + }); + + await createLegacyWorktreeForTest({ + branchName: "main", + cwd: varRepoDir, + baseBranch: "main", + worktreeSlug: "realpath-test", + paseoHome: varPaseoHome, + }); + + const projectHash = await deriveWorktreeProjectHash(varRepoDir); + const privateWorktreePath = join( + privateTempDir, + "paseo-home", + "worktrees", + projectHash, + "realpath-test", + ); + expect(existsSync(privateWorktreePath)).toBe(true); + + const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { + paseoHome: varPaseoHome, + }); + expect(ownership.allowed).toBe(true); + + rmSync(varTempDir, { recursive: true, force: true }); + }); + + it("reports repoRoot as the repository root for paseo-owned worktrees", async () => { + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "repo-root-check", + paseoHome, + }); + + const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }); + expect(ownership.allowed).toBe(true); + expect(ownership.repoRoot).toBe(repoDir); + }); + + it("treats non-git directories as non-worktrees without throwing", async () => { + const nonGitDir = join(tempDir, "not-a-repo"); + mkdirSync(nonGitDir, { recursive: true }); + + const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome }); + + expect(ownership.allowed).toBe(false); + expect(ownership.worktreePath).toBe(realpathSync(nonGitDir)); + }); + + it("creates a worktree with a new branch", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "my-feature", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" }, + runSetup: true, + paseoHome, + }); + + expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "my-feature")); + expect(existsSync(result.worktreePath)).toBe(true); + + const currentBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("feature/x"); + execFileSync("git", ["merge-base", "--is-ancestor", "main", "HEAD"], { + cwd: result.worktreePath, + }); + + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + expect(metadata).toMatchObject({ version: 1, baseRefName: "main" }); + }); + + it("checks out an existing local branch that is not checked out elsewhere", async () => { + execFileSync("git", ["branch", "dev"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "dev-worktree", + source: { kind: "checkout-branch", branchName: "dev" }, + runSetup: true, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + const currentBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("dev"); + + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" }); + }); + + it("throws a typed error when checking out a branch already checked out in the main repo", async () => { + let caughtError: unknown; + try { + await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "dev-worktree", + source: { kind: "checkout-branch", branchName: "main" }, + runSetup: true, + paseoHome, + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(BranchAlreadyCheckedOutError); + expect((caughtError as BranchAlreadyCheckedOutError).branchName).toBe("main"); + }); + + it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => { + const remoteDir = join(tempDir, "remote.git"); + const remoteCloneDir = join(tempDir, "remote-clone"); + execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + + execFileSync("git", ["clone", remoteDir, remoteCloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir }); + execFileSync("git", ["checkout", "-b", "contributor/feature"], { cwd: remoteCloneDir }); + writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n"); + writeFileSync( + join(remoteCloneDir, "paseo.json"), + JSON.stringify({ worktree: { setup: ['echo "setup ran" > setup.log'] } }), + ); + execFileSync("git", ["add", "."], { cwd: remoteCloneDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], { + cwd: remoteCloneDir, + }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir }) + .toString() + .trim(); + execFileSync("git", ["push", "origin", "contributor/feature"], { cwd: remoteCloneDir }); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/42/head", prHead]); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "pr-42", + source: { + kind: "checkout-github-pr", + githubPrNumber: 42, + headRef: "user/feature", + baseRefName: "main", + }, + runSetup: true, + paseoHome, + }); + + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n"); + expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n"); + const currentBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("user/feature"); + + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + expect(metadata).toMatchObject({ baseRefName: "main" }); + }); + + it("prefers origin/{branch} over local {branch} when both exist", async () => { + const remoteDir = join(tempDir, "remote.git"); + const remoteCloneDir = join(tempDir, "remote-clone"); + execFileSync("git", ["init", "--bare", remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); + + execFileSync("git", ["clone", remoteDir, remoteCloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir }); + execFileSync("git", ["checkout", "-B", "main", "origin/main"], { cwd: remoteCloneDir }); + writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n"); + execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "advance origin main"], { + cwd: remoteCloneDir, + }); + execFileSync("git", ["push", "origin", "main"], { cwd: remoteCloneDir }); + + writeFileSync(join(repoDir, "file.txt"), "from-local\n"); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "advance local main"], { + cwd: repoDir, + }); + + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + branchName: "prefer-origin-feature", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "prefer-origin-feature", + runSetup: false, + paseoHome, + }); + + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n"); + }); + + it("falls back to local {branch} when origin/{branch} does not exist", async () => { + writeFileSync(join(repoDir, "file.txt"), "from-local-only\n"); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "advance local main only"], + { + cwd: repoDir, + }, + ); + + const result = await createLegacyWorktreeForTest({ + branchName: "prefer-local-fallback-feature", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "prefer-local-fallback-feature", + runSetup: false, + paseoHome, + }); + + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-local-only\n"); + }); + + it("throws when neither origin/{branch} nor local {branch} exists", async () => { + await expect( + createLegacyWorktreeForTest({ + branchName: "missing-base-feature", + cwd: repoDir, + baseBranch: "does-not-exist", + worktreeSlug: "missing-base-feature", + runSetup: false, + paseoHome, + }), + ).rejects.toThrow("Base branch not found: does-not-exist"); + }); + + it("fails with invalid branch name", async () => { + await expect( + createLegacyWorktreeForTest({ + branchName: "INVALID_UPPERCASE", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "test", + }), + ).rejects.toThrow("Invalid branch name"); + }); + + it("handles branch name collision by adding suffix", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); + // Create a branch named "hello" first + execFileSync("git", ["branch", "hello"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "hello", + paseoHome, + }); + + // Should create branch "hello-1" since "hello" exists + expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello")); + expect(existsSync(result.worktreePath)).toBe(true); + + const branches = execFileSync("git", ["branch"], { cwd: repoDir }).toString(); + expect(branches).toContain("hello-1"); + }); + + it("handles multiple collisions", async () => { + // Create branches "hello" and "hello-1" + execFileSync("git", ["branch", "hello"], { cwd: repoDir }); + execFileSync("git", ["branch", "hello-1"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "hello", + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + + const branches = execFileSync("git", ["branch"], { cwd: repoDir }).toString(); + expect(branches).toContain("hello-2"); + }); + + it("runs setup commands from paseo.json", async () => { + // Create paseo.json with setup commands + const paseoConfig = { + worktree: { + setup: [ + 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > setup.log', + 'echo "root_alias=$PASEO_ROOT_PATH" >> setup.log', + 'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log', + 'echo "branch=$PASEO_BRANCH_NAME" >> setup.log', + 'echo "port=$PASEO_WORKTREE_PORT" >> setup.log', + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { + cwd: repoDir, + }); + + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "setup-test", + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + + // Verify setup ran and env vars were available + const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8"); + expect(setupLog).toContain(`source=${repoDir}`); + expect(setupLog).toContain(`root_alias=${repoDir}`); + expect(setupLog).toContain(`worktree=${result.worktreePath}`); + expect(setupLog).toContain("branch=setup-test"); + const portLine = setupLog.split("\n").find((line) => line.startsWith("port=")); + expect(portLine).toBeDefined(); + const portValue = Number(portLine?.slice("port=".length)); + expect(Number.isInteger(portValue)).toBe(true); + expect(portValue).toBeGreaterThan(0); + }); + + it("runs string setup scripts from paseo.json as a single shell command", async () => { + const paseoConfig = { + worktree: { + setup: 'greeting="hello from string setup"\necho "$greeting" > setup.log', + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add string setup"], { + cwd: repoDir, + }); + + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "string-setup-test", + paseoHome, + }); + + expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([ + 'greeting="hello from string setup"\necho "$greeting" > setup.log', + ]); + expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe( + "hello from string setup", + ); + }); + + it("treats blank lifecycle strings as empty", () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: " \n\t ", + teardown: " \n ", + }, + }), + ); + + expect(getWorktreeSetupCommands(repoDir)).toEqual([]); + expect(getWorktreeTeardownCommands(repoDir)).toEqual([]); + }); + + it("filters non-string and blank entries from lifecycle arrays", () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + 'echo "first" > setup-array.log', + null, + " ", + 'echo "second" >> setup-array.log', + ], + teardown: [ + 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', + null, + "", + 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', + ], + }, + }), + ); + + expect(getWorktreeSetupCommands(repoDir)).toEqual([ + 'echo "first" > setup-array.log', + 'echo "second" >> setup-array.log', + ]); + expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', + 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', + ]); + }); + + it("does not run setup commands when runSetup=false", async () => { + const paseoConfig = { + worktree: { + setup: ['echo "setup ran" > setup.log'], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { + cwd: repoDir, + }); + + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "no-setup-test", + runSetup: false, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false); + }); + + it("streams setup command progress events while commands are executing", async () => { + const paseoConfig = { + worktree: { + setup: ['echo "first line"; echo "second line" 1>&2'], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add streaming setup"], { + cwd: repoDir, + }); + + const progressEvents: WorktreeSetupCommandProgressEvent[] = []; + const results = await runWorktreeSetupCommands({ + worktreePath: repoDir, + branchName: "main", + cleanupOnFailure: false, + onEvent: (event) => { + progressEvents.push(event); + }, + }); + + expect(results).toHaveLength(1); + expect(progressEvents.some((event) => event.type === "command_started")).toBe(true); + expect(progressEvents.some((event) => event.type === "output")).toBe(true); + expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true); + }); + + it("reuses persisted worktree runtime port across resolutions", async () => { + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "runtime-env-port-reuse", + runSetup: false, + paseoHome, + }); + + const first = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + const second = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + + expect(second.PASEO_WORKTREE_PORT).toBe(first.PASEO_WORKTREE_PORT); + }); + + it("fails runtime env resolution when persisted port is in use", async () => { + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "runtime-env-port-conflict", + runSetup: false, + paseoHome, + }); + + const env = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + const port = Number(env.PASEO_WORKTREE_PORT); + + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, () => resolve()); + }); + + await expect( + resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }), + ).rejects.toThrow(`Persisted worktree port ${port} is already in use`); + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); + + it("cleans up worktree if setup command fails", async () => { + // Create paseo.json with failing setup command + const paseoConfig = { + worktree: { + setup: ["exit 1"], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { + cwd: repoDir, + }); + + const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test"); + + await expect( + createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "fail-test", + paseoHome, + }), + ).rejects.toThrow("Worktree setup command failed"); + + // Verify worktree was cleaned up + expect(existsSync(expectedWorktreePath)).toBe(false); + }); + + it("reads worktree terminal specs from paseo.json with optional name", async () => { + const paseoConfig = { + worktree: { + terminals: [ + { name: "Dev Server", command: "npm run dev" }, + { command: "cd packages/app && npm run dev" }, + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + + expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + { name: "Dev Server", command: "npm run dev" }, + { command: "cd packages/app && npm run dev" }, + ]); + }); + + it("filters invalid worktree terminal specs", async () => { + const paseoConfig = { + worktree: { + terminals: [ + null, + {}, + { name: " ", command: " " }, + { name: " Watch ", command: "npm run watch", cwd: "packages/app" }, + { name: 123, command: "npm run test" }, + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + + expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + { name: "Watch", command: "npm run watch" }, + { command: "npm run test" }, + ]); + }); + + it("parses omitted script type as a plain script", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + typecheck: { + command: " npm run typecheck ", + }, + }, + }), + ); + + const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); + const typecheck = scriptConfigs.get("typecheck"); + + expect(typecheck).toEqual({ + command: "npm run typecheck", + }); + expect(typecheck).toBeDefined(); + expect(isServiceScript(typecheck!)).toBe(false); + }); + + it("parses service scripts and preserves optional port", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + server: { + type: "service", + command: "npm run dev", + port: 4321, + }, + }, + }), + ); + + const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); + const server = scriptConfigs.get("server"); + + expect(server).toEqual({ + type: "service", + command: "npm run dev", + port: 4321, + }); + expect(server).toBeDefined(); + expect(isServiceScript(server!)).toBe(true); + }); + + it("ignores invalid script entries gracefully", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + valid: { + command: "npm run valid", + }, + invalidType: { + type: "worker", + command: "npm run worker", + }, + missingCommand: { + type: "service", + }, + blankCommand: { + command: " ", + }, + nonObject: "npm run nope", + invalidPort: { + type: "service", + command: "npm run dev", + port: "3000", + }, + }, + }), + ); + + expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual( + new Map([ + ["valid", { command: "npm run valid" }], + ["invalidType", { command: "npm run worker" }], + ["invalidPort", { type: "service", command: "npm run dev" }], + ]), + ); + }); + + it("seeds an uncommitted paseo.json from the main repo into a new worktree", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ scripts: { dev: { command: "echo hi" } } }), + ); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "seed-uncommitted", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" }, + runSetup: false, + paseoHome, + }); + + const worktreeConfigPath = join(result.worktreePath, "paseo.json"); + expect(existsSync(worktreeConfigPath)).toBe(true); + expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({ + scripts: { dev: { command: "echo hi" } }, + }); + }); + + it("does not overwrite a committed paseo.json with uncommitted edits in the main repo", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ scripts: { dev: { command: "committed" } } }), + ); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { + cwd: repoDir, + }); + + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ scripts: { dev: { command: "uncommitted" } } }), + ); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "preserve-committed", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" }, + runSetup: false, + paseoHome, + }); + + const worktreeConfigPath = join(result.worktreePath, "paseo.json"); + expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({ + scripts: { dev: { command: "committed" } }, + }); + }); + + it("creates a worktree without error when no paseo.json exists in the main repo", async () => { + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "no-config", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" }, + runSetup: false, + paseoHome, + }); + + expect(existsSync(join(result.worktreePath, "paseo.json"))).toBe(false); + }); + }); + + describe("paseo worktree manager", () => { + let tempDir: string; + let repoDir: string; + let paseoHome: string; + + beforeEach(() => { + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-manager-test-"))); + repoDir = join(tempDir, "test-repo"); + paseoHome = join(tempDir, "paseo-home"); + + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("isolates worktree roots for repositories that share the same directory name", async () => { + const repoA = join(tempDir, "team-a", "test-repo"); + const repoB = join(tempDir, "team-b", "test-repo"); + + for (const repo of [repoA, repoB]) { + mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repo }); + writeFileSync(join(repo, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repo, + }); + } + + const fromRepoA = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoA, + baseBranch: "main", + worktreeSlug: "alpha", + paseoHome, + }); + const fromRepoB = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoB, + baseBranch: "main", + worktreeSlug: "alpha", + paseoHome, + }); + + expect(dirname(fromRepoA.worktreePath)).not.toBe(dirname(fromRepoB.worktreePath)); + expect(fromRepoA.worktreePath.endsWith("alpha-1")).toBe(false); + expect(fromRepoB.worktreePath.endsWith("alpha-1")).toBe(false); + + const repoAWorktrees = await listPaseoWorktrees({ cwd: repoA, paseoHome }); + const repoBWorktrees = await listPaseoWorktrees({ cwd: repoB, paseoHome }); + + expect(repoAWorktrees.map((entry) => entry.path)).toEqual([fromRepoA.worktreePath]); + expect(repoBWorktrees.map((entry) => entry.path)).toEqual([fromRepoB.worktreePath]); + }); + + it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{hash}", async () => { + const first = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "alpha", + paseoHome, + }); + const second = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "beta", + paseoHome, + }); + + const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); + const paths = worktrees.map((worktree) => worktree.path).sort(); + expect(paths).toEqual([first.worktreePath, second.worktreePath].sort()); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: first.worktreePath, paseoHome }); + expect(existsSync(first.worktreePath)).toBe(false); + + const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); + expect(remaining.map((worktree) => worktree.path)).toEqual([second.worktreePath]); + }); + + it("deletes a paseo worktree even when given a subdirectory path", async () => { + const created = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "alpha", + paseoHome, + }); + + const nestedDir = join(created.worktreePath, "nested", "dir"); + mkdirSync(nestedDir, { recursive: true }); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome }); + expect(existsSync(created.worktreePath)).toBe(false); + + const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); + expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false); + }); + + it("runs teardown commands from paseo.json before deleting a worktree", async () => { + const paseoConfig = { + worktree: { + teardown: [ + 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + 'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + 'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + 'echo "port=$PASEO_WORKTREE_PORT" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add teardown commands"], { + cwd: repoDir, + }); + + const created = await createLegacyWorktreeForTest({ + branchName: "teardown-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "teardown-test", + paseoHome, + }); + const runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: created.worktreePath, + branchName: created.branchName, + }); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); + expect(existsSync(created.worktreePath)).toBe(false); + + const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8"); + expect(teardownLog).toContain(`source=${repoDir}`); + expect(teardownLog).toContain(`root_alias=${repoDir}`); + expect(teardownLog).toContain(`worktree=${created.worktreePath}`); + expect(teardownLog).toContain("branch=teardown-branch"); + expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`); + }); + + it("runs string teardown scripts from paseo.json as a single shell command", async () => { + const paseoConfig = { + worktree: { + teardown: + 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add string teardown"], { + cwd: repoDir, + }); + + const created = await createLegacyWorktreeForTest({ + branchName: "teardown-string-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "teardown-string-test", + paseoHome, + }); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); + + expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + ]); + expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string"); + }); + + it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => { + const paseoConfig = { + worktree: { + teardown: [ + 'echo "port=${PASEO_WORKTREE_PORT-unset}" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-port.log"', + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add teardown port logging"], + { cwd: repoDir }, + ); + + const created = await createLegacyWorktreeForTest({ + branchName: "teardown-port-missing-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "teardown-port-missing-test", + paseoHome, + }); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); + + expect(readFileSync(join(repoDir, "teardown-port.log"), "utf8").trim()).toBe("port=unset"); + expect(existsSync(created.worktreePath)).toBe(false); + }); + + it("does not remove worktree when a teardown command fails", async () => { + const paseoConfig = { + worktree: { + teardown: [ + 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"', + "echo boom 1>&2; exit 9", + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add failing teardown commands"], + { cwd: repoDir }, + ); + + const created = await createLegacyWorktreeForTest({ + branchName: "teardown-failure-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "teardown-failure-test", + paseoHome, + }); + + await expect( + deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }), + ).rejects.toThrow("Worktree teardown command failed"); + + expect(existsSync(created.worktreePath)).toBe(true); + expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true); + }); + }); +}); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index a04eb562c..0200c5a1c 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -1,44 +1,17 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { - BranchAlreadyCheckedOutError, createWorktree as createWorktreePrimitive, deriveWorktreeProjectHash, deletePaseoWorktree, - getScriptConfigs, - getWorktreeSetupCommands, - getWorktreeTerminalSpecs, - getWorktreeTeardownCommands, - isServiceScript, isPaseoOwnedWorktreeCwd, - listPaseoWorktrees, - readPaseoConfig, - resolveWorktreeRuntimeEnv, - type WorktreeSetupCommandProgressEvent, - runWorktreeSetupCommands, slugify, type CreateWorktreeOptions, type WorktreeConfig, } from "./worktree"; -import type { PaseoConfig } from "./paseo-config-schema.js"; - -function loadConfigForTest(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); - return result.ok ? result.config : null; -} -import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; -import { execSync } from "child_process"; -import { - mkdtempSync, - mkdirSync, - rmSync, - existsSync, - realpathSync, - writeFileSync, - readFileSync, -} from "fs"; -import { dirname, join } from "path"; +import { execFileSync } from "child_process"; +import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "fs"; +import { join } from "path"; import { tmpdir } from "os"; -import net from "node:net"; interface LegacyCreateWorktreeTestOptions { branchName: string; @@ -69,785 +42,6 @@ function createLegacyWorktreeForTest( }); } -describe.skipIf(process.platform === "win32")("createWorktree", () => { - let tempDir: string; - let repoDir: string; - let paseoHome: string; - - beforeEach(() => { - // Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS) - tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-test-"))); - repoDir = join(tempDir, "test-repo"); - paseoHome = join(tempDir, "paseo-home"); - - // Create a git repo with an initial commit - mkdirSync(repoDir, { recursive: true }); - execSync("git init -b main", { cwd: repoDir }); - execSync('git config user.email "test@test.com"', { cwd: repoDir }); - execSync('git config user.name "Test"', { cwd: repoDir }); - execSync('echo "hello" > file.txt', { cwd: repoDir }); - execSync("git add .", { cwd: repoDir }); - execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repoDir }); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - it("creates a worktree for the current branch (main)", async () => { - const projectHash = await deriveWorktreeProjectHash(repoDir); - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "hello-world", - paseoHome, - }); - - expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world")); - expect(existsSync(result.worktreePath)).toBe(true); - expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); - expect(existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); - expect(metadata).toMatchObject({ version: 1, baseRefName: "main" }); - }); - - it.skip("detects paseo-owned worktrees across realpath differences (macOS /var vs /private/var)", async () => { - // Intentionally create repo using the non-realpath tmpdir() variant (often /var/... on macOS). - const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-")); - const privateTempDir = realpathSync(varTempDir); - const varRepoDir = join(varTempDir, "test-repo"); - const varPaseoHome = join(varTempDir, "paseo-home"); - mkdirSync(varRepoDir, { recursive: true }); - execSync("git init -b main", { cwd: varRepoDir }); - execSync('git config user.email "test@test.com"', { cwd: varRepoDir }); - execSync('git config user.name "Test"', { cwd: varRepoDir }); - execSync('echo "hello" > file.txt', { cwd: varRepoDir }); - execSync("git add .", { cwd: varRepoDir }); - execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: varRepoDir }); - - await createLegacyWorktreeForTest({ - branchName: "main", - cwd: varRepoDir, - baseBranch: "main", - worktreeSlug: "realpath-test", - paseoHome: varPaseoHome, - }); - - const projectHash = await deriveWorktreeProjectHash(varRepoDir); - const privateWorktreePath = join( - privateTempDir, - "paseo-home", - "worktrees", - projectHash, - "realpath-test", - ); - expect(existsSync(privateWorktreePath)).toBe(true); - - const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { - paseoHome: varPaseoHome, - }); - expect(ownership.allowed).toBe(true); - - rmSync(varTempDir, { recursive: true, force: true }); - }); - - it("reports repoRoot as the repository root for paseo-owned worktrees", async () => { - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "repo-root-check", - paseoHome, - }); - - const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }); - expect(ownership.allowed).toBe(true); - expect(ownership.repoRoot).toBe(repoDir); - }); - - it("treats non-git directories as non-worktrees without throwing", async () => { - const nonGitDir = join(tempDir, "not-a-repo"); - mkdirSync(nonGitDir, { recursive: true }); - - const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome }); - - expect(ownership.allowed).toBe(false); - expect(ownership.worktreePath).toBe(realpathSync(nonGitDir)); - }); - - it("creates a worktree with a new branch", async () => { - const projectHash = await deriveWorktreeProjectHash(repoDir); - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "my-feature", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" }, - runSetup: true, - paseoHome, - }); - - expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "my-feature")); - expect(existsSync(result.worktreePath)).toBe(true); - - const currentBranch = execSync("git branch --show-current", { - cwd: result.worktreePath, - }) - .toString() - .trim(); - expect(currentBranch).toBe("feature/x"); - execSync("git merge-base --is-ancestor main HEAD", { cwd: result.worktreePath }); - - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); - const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); - expect(metadata).toMatchObject({ version: 1, baseRefName: "main" }); - }); - - it("checks out an existing local branch that is not checked out elsewhere", async () => { - execSync("git branch dev", { cwd: repoDir }); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "dev-worktree", - source: { kind: "checkout-branch", branchName: "dev" }, - runSetup: true, - paseoHome, - }); - - expect(existsSync(result.worktreePath)).toBe(true); - const currentBranch = execSync("git branch --show-current", { - cwd: result.worktreePath, - }) - .toString() - .trim(); - expect(currentBranch).toBe("dev"); - - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); - const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); - expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" }); - }); - - it("throws a typed error when checking out a branch already checked out in the main repo", async () => { - let caughtError: unknown; - try { - await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "dev-worktree", - source: { kind: "checkout-branch", branchName: "main" }, - runSetup: true, - paseoHome, - }); - } catch (error) { - caughtError = error; - } - - expect(caughtError).toBeInstanceOf(BranchAlreadyCheckedOutError); - expect((caughtError as BranchAlreadyCheckedOutError).branchName).toBe("main"); - }); - - it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => { - const remoteDir = join(tempDir, "remote.git"); - const remoteCloneDir = join(tempDir, "remote-clone"); - execSync(`git clone --bare ${repoDir} ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - - execSync(`git clone ${remoteDir} ${remoteCloneDir}`); - execSync('git config user.email "test@test.com"', { cwd: remoteCloneDir }); - execSync('git config user.name "Test"', { cwd: remoteCloneDir }); - execSync("git checkout -b contributor/feature", { cwd: remoteCloneDir }); - writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n"); - writeFileSync( - join(remoteCloneDir, "paseo.json"), - JSON.stringify({ worktree: { setup: ['echo "setup ran" > setup.log'] } }), - ); - execSync("git add .", { cwd: remoteCloneDir }); - execSync('git -c commit.gpgsign=false commit -m "pr branch"', { cwd: remoteCloneDir }); - const prHead = execSync("git rev-parse HEAD", { cwd: remoteCloneDir }).toString().trim(); - execSync("git push origin contributor/feature", { cwd: remoteCloneDir }); - execSync(`git --git-dir=${remoteDir} update-ref refs/pull/42/head ${prHead}`); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "pr-42", - source: { - kind: "checkout-github-pr", - githubPrNumber: 42, - headRef: "user/feature", - baseRefName: "main", - }, - runSetup: true, - paseoHome, - }); - - expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n"); - expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n"); - const currentBranch = execSync("git branch --show-current", { - cwd: result.worktreePath, - }) - .toString() - .trim(); - expect(currentBranch).toBe("user/feature"); - - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); - const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); - expect(metadata).toMatchObject({ baseRefName: "main" }); - }); - - it("prefers origin/{branch} over local {branch} when both exist", async () => { - const remoteDir = join(tempDir, "remote.git"); - const remoteCloneDir = join(tempDir, "remote-clone"); - execSync(`git init --bare ${remoteDir}`); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); - execSync("git push -u origin main", { cwd: repoDir }); - - execSync(`git clone ${remoteDir} ${remoteCloneDir}`); - execSync('git config user.email "test@test.com"', { cwd: remoteCloneDir }); - execSync('git config user.name "Test"', { cwd: remoteCloneDir }); - execSync("git checkout -B main origin/main", { cwd: remoteCloneDir }); - writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n"); - execSync("git add file.txt", { cwd: remoteCloneDir }); - execSync('git -c commit.gpgsign=false commit -m "advance origin main"', { - cwd: remoteCloneDir, - }); - execSync("git push origin main", { cwd: remoteCloneDir }); - - writeFileSync(join(repoDir, "file.txt"), "from-local\n"); - execSync("git add file.txt", { cwd: repoDir }); - execSync('git -c commit.gpgsign=false commit -m "advance local main"', { cwd: repoDir }); - - execSync("git fetch origin", { cwd: repoDir }); - - const result = await createLegacyWorktreeForTest({ - branchName: "prefer-origin-feature", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "prefer-origin-feature", - runSetup: false, - paseoHome, - }); - - expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n"); - }); - - it("falls back to local {branch} when origin/{branch} does not exist", async () => { - writeFileSync(join(repoDir, "file.txt"), "from-local-only\n"); - execSync("git add file.txt", { cwd: repoDir }); - execSync('git -c commit.gpgsign=false commit -m "advance local main only"', { cwd: repoDir }); - - const result = await createLegacyWorktreeForTest({ - branchName: "prefer-local-fallback-feature", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "prefer-local-fallback-feature", - runSetup: false, - paseoHome, - }); - - expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-local-only\n"); - }); - - it("throws when neither origin/{branch} nor local {branch} exists", async () => { - await expect( - createLegacyWorktreeForTest({ - branchName: "missing-base-feature", - cwd: repoDir, - baseBranch: "does-not-exist", - worktreeSlug: "missing-base-feature", - runSetup: false, - paseoHome, - }), - ).rejects.toThrow("Base branch not found: does-not-exist"); - }); - - it("fails with invalid branch name", async () => { - await expect( - createLegacyWorktreeForTest({ - branchName: "INVALID_UPPERCASE", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "test", - }), - ).rejects.toThrow("Invalid branch name"); - }); - - it("handles branch name collision by adding suffix", async () => { - const projectHash = await deriveWorktreeProjectHash(repoDir); - // Create a branch named "hello" first - execSync("git branch hello", { cwd: repoDir }); - - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "hello", - paseoHome, - }); - - // Should create branch "hello-1" since "hello" exists - expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello")); - expect(existsSync(result.worktreePath)).toBe(true); - - const branches = execSync("git branch", { cwd: repoDir }).toString(); - expect(branches).toContain("hello-1"); - }); - - it("handles multiple collisions", async () => { - // Create branches "hello" and "hello-1" - execSync("git branch hello", { cwd: repoDir }); - execSync("git branch hello-1", { cwd: repoDir }); - - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "hello", - paseoHome, - }); - - expect(existsSync(result.worktreePath)).toBe(true); - - const branches = execSync("git branch", { cwd: repoDir }).toString(); - expect(branches).toContain("hello-2"); - }); - - it("runs setup commands from paseo.json", async () => { - // Create paseo.json with setup commands - const paseoConfig = { - worktree: { - setup: [ - 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > setup.log', - 'echo "root_alias=$PASEO_ROOT_PATH" >> setup.log', - 'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log', - 'echo "branch=$PASEO_BRANCH_NAME" >> setup.log', - 'echo "port=$PASEO_WORKTREE_PORT" >> setup.log', - ], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', { - cwd: repoDir, - }); - - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "setup-test", - paseoHome, - }); - - expect(existsSync(result.worktreePath)).toBe(true); - - // Verify setup ran and env vars were available - const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8"); - expect(setupLog).toContain(`source=${repoDir}`); - expect(setupLog).toContain(`root_alias=${repoDir}`); - expect(setupLog).toContain(`worktree=${result.worktreePath}`); - expect(setupLog).toContain("branch=setup-test"); - const portLine = setupLog.split("\n").find((line) => line.startsWith("port=")); - expect(portLine).toBeDefined(); - const portValue = Number(portLine?.slice("port=".length)); - expect(Number.isInteger(portValue)).toBe(true); - expect(portValue).toBeGreaterThan(0); - }); - - it("runs string setup scripts from paseo.json as a single shell command", async () => { - const paseoConfig = { - worktree: { - setup: 'greeting="hello from string setup"\necho "$greeting" > setup.log', - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add string setup"', { - cwd: repoDir, - }); - - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "string-setup-test", - paseoHome, - }); - - expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([ - 'greeting="hello from string setup"\necho "$greeting" > setup.log', - ]); - expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe( - "hello from string setup", - ); - }); - - it("treats blank lifecycle strings as empty", () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: " \n\t ", - teardown: " \n ", - }, - }), - ); - - expect(getWorktreeSetupCommands(repoDir)).toEqual([]); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([]); - }); - - it("filters non-string and blank entries from lifecycle arrays", () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [ - 'echo "first" > setup-array.log', - null, - " ", - 'echo "second" >> setup-array.log', - ], - teardown: [ - 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', - null, - "", - 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', - ], - }, - }), - ); - - expect(getWorktreeSetupCommands(repoDir)).toEqual([ - 'echo "first" > setup-array.log', - 'echo "second" >> setup-array.log', - ]); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([ - 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', - 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', - ]); - }); - - it("does not run setup commands when runSetup=false", async () => { - const paseoConfig = { - worktree: { - setup: ['echo "setup ran" > setup.log'], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', { - cwd: repoDir, - }); - - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "no-setup-test", - runSetup: false, - paseoHome, - }); - - expect(existsSync(result.worktreePath)).toBe(true); - expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false); - }); - - it("streams setup command progress events while commands are executing", async () => { - const paseoConfig = { - worktree: { - setup: ['echo "first line"; echo "second line" 1>&2'], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add streaming setup"', { - cwd: repoDir, - }); - - const progressEvents: WorktreeSetupCommandProgressEvent[] = []; - const results = await runWorktreeSetupCommands({ - worktreePath: repoDir, - branchName: "main", - cleanupOnFailure: false, - onEvent: (event) => { - progressEvents.push(event); - }, - }); - - expect(results).toHaveLength(1); - expect(progressEvents.some((event) => event.type === "command_started")).toBe(true); - expect(progressEvents.some((event) => event.type === "output")).toBe(true); - expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true); - }); - - it("reuses persisted worktree runtime port across resolutions", async () => { - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "runtime-env-port-reuse", - runSetup: false, - paseoHome, - }); - - const first = await resolveWorktreeRuntimeEnv({ - worktreePath: result.worktreePath, - branchName: result.branchName, - }); - const second = await resolveWorktreeRuntimeEnv({ - worktreePath: result.worktreePath, - branchName: result.branchName, - }); - - expect(second.PASEO_WORKTREE_PORT).toBe(first.PASEO_WORKTREE_PORT); - }); - - it("fails runtime env resolution when persisted port is in use", async () => { - const result = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "runtime-env-port-conflict", - runSetup: false, - paseoHome, - }); - - const env = await resolveWorktreeRuntimeEnv({ - worktreePath: result.worktreePath, - branchName: result.branchName, - }); - const port = Number(env.PASEO_WORKTREE_PORT); - - const server = net.createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, () => resolve()); - }); - - await expect( - resolveWorktreeRuntimeEnv({ - worktreePath: result.worktreePath, - branchName: result.branchName, - }), - ).rejects.toThrow(`Persisted worktree port ${port} is already in use`); - - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - }); - - it("cleans up worktree if setup command fails", async () => { - // Create paseo.json with failing setup command - const paseoConfig = { - worktree: { - setup: ["exit 1"], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', { - cwd: repoDir, - }); - - const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test"); - - await expect( - createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "fail-test", - paseoHome, - }), - ).rejects.toThrow("Worktree setup command failed"); - - // Verify worktree was cleaned up - expect(existsSync(expectedWorktreePath)).toBe(false); - }); - - it("reads worktree terminal specs from paseo.json with optional name", async () => { - const paseoConfig = { - worktree: { - terminals: [ - { name: "Dev Server", command: "npm run dev" }, - { command: "cd packages/app && npm run dev" }, - ], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - - expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ - { name: "Dev Server", command: "npm run dev" }, - { command: "cd packages/app && npm run dev" }, - ]); - }); - - it("filters invalid worktree terminal specs", async () => { - const paseoConfig = { - worktree: { - terminals: [ - null, - {}, - { name: " ", command: " " }, - { name: " Watch ", command: "npm run watch", cwd: "packages/app" }, - { name: 123, command: "npm run test" }, - ], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - - expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ - { name: "Watch", command: "npm run watch" }, - { command: "npm run test" }, - ]); - }); - - it("parses omitted script type as a plain script", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - scripts: { - typecheck: { - command: " npm run typecheck ", - }, - }, - }), - ); - - const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); - const typecheck = scriptConfigs.get("typecheck"); - - expect(typecheck).toEqual({ - command: "npm run typecheck", - }); - expect(typecheck).toBeDefined(); - expect(isServiceScript(typecheck!)).toBe(false); - }); - - it("parses service scripts and preserves optional port", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - scripts: { - server: { - type: "service", - command: "npm run dev", - port: 4321, - }, - }, - }), - ); - - const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); - const server = scriptConfigs.get("server"); - - expect(server).toEqual({ - type: "service", - command: "npm run dev", - port: 4321, - }); - expect(server).toBeDefined(); - expect(isServiceScript(server!)).toBe(true); - }); - - it("ignores invalid script entries gracefully", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - scripts: { - valid: { - command: "npm run valid", - }, - invalidType: { - type: "worker", - command: "npm run worker", - }, - missingCommand: { - type: "service", - }, - blankCommand: { - command: " ", - }, - nonObject: "npm run nope", - invalidPort: { - type: "service", - command: "npm run dev", - port: "3000", - }, - }, - }), - ); - - expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual( - new Map([ - ["valid", { command: "npm run valid" }], - ["invalidType", { command: "npm run worker" }], - ["invalidPort", { type: "service", command: "npm run dev" }], - ]), - ); - }); - - it("seeds an uncommitted paseo.json from the main repo into a new worktree", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ scripts: { dev: { command: "echo hi" } } }), - ); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "seed-uncommitted", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" }, - runSetup: false, - paseoHome, - }); - - const worktreeConfigPath = join(result.worktreePath, "paseo.json"); - expect(existsSync(worktreeConfigPath)).toBe(true); - expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({ - scripts: { dev: { command: "echo hi" } }, - }); - }); - - it("does not overwrite a committed paseo.json with uncommitted edits in the main repo", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ scripts: { dev: { command: "committed" } } }), - ); - execSync("git add paseo.json", { cwd: repoDir }); - execSync('git -c commit.gpgsign=false commit -m "add paseo.json"', { cwd: repoDir }); - - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ scripts: { dev: { command: "uncommitted" } } }), - ); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "preserve-committed", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" }, - runSetup: false, - paseoHome, - }); - - const worktreeConfigPath = join(result.worktreePath, "paseo.json"); - expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({ - scripts: { dev: { command: "committed" } }, - }); - }); - - it("creates a worktree without error when no paseo.json exists in the main repo", async () => { - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "no-config", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" }, - runSetup: false, - paseoHome, - }); - - expect(existsSync(join(result.worktreePath, "paseo.json"))).toBe(false); - }); -}); - describe("paseo worktree manager", () => { let tempDir: string; let repoDir: string; @@ -859,234 +53,20 @@ describe("paseo worktree manager", () => { paseoHome = join(tempDir, "paseo-home"); mkdirSync(repoDir, { recursive: true }); - execSync("git init -b main", { cwd: repoDir }); - execSync('git config user.email "test@test.com"', { cwd: repoDir }); - execSync('git config user.name "Test"', { cwd: repoDir }); - execSync('echo "hello" > file.txt', { cwd: repoDir }); - execSync("git add .", { cwd: repoDir }); - execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repoDir }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + }); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); - it("isolates worktree roots for repositories that share the same directory name", async () => { - const repoA = join(tempDir, "team-a", "test-repo"); - const repoB = join(tempDir, "team-b", "test-repo"); - - for (const repo of [repoA, repoB]) { - mkdirSync(repo, { recursive: true }); - execSync("git init -b main", { cwd: repo }); - execSync('git config user.email "test@test.com"', { cwd: repo }); - execSync('git config user.name "Test"', { cwd: repo }); - execSync('echo "hello" > file.txt', { cwd: repo }); - execSync("git add .", { cwd: repo }); - execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repo }); - } - - const fromRepoA = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoA, - baseBranch: "main", - worktreeSlug: "alpha", - paseoHome, - }); - const fromRepoB = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoB, - baseBranch: "main", - worktreeSlug: "alpha", - paseoHome, - }); - - expect(dirname(fromRepoA.worktreePath)).not.toBe(dirname(fromRepoB.worktreePath)); - expect(fromRepoA.worktreePath.endsWith("alpha-1")).toBe(false); - expect(fromRepoB.worktreePath.endsWith("alpha-1")).toBe(false); - - const repoAWorktrees = await listPaseoWorktrees({ cwd: repoA, paseoHome }); - const repoBWorktrees = await listPaseoWorktrees({ cwd: repoB, paseoHome }); - - expect(repoAWorktrees.map((entry) => entry.path)).toEqual([fromRepoA.worktreePath]); - expect(repoBWorktrees.map((entry) => entry.path)).toEqual([fromRepoB.worktreePath]); - }); - - it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{hash}", async () => { - const first = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "alpha", - paseoHome, - }); - const second = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "beta", - paseoHome, - }); - - const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); - const paths = worktrees.map((worktree) => worktree.path).sort(); - expect(paths).toEqual([first.worktreePath, second.worktreePath].sort()); - - await deletePaseoWorktree({ cwd: repoDir, worktreePath: first.worktreePath, paseoHome }); - expect(existsSync(first.worktreePath)).toBe(false); - - const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); - expect(remaining.map((worktree) => worktree.path)).toEqual([second.worktreePath]); - }); - - it("deletes a paseo worktree even when given a subdirectory path", async () => { - const created = await createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "alpha", - paseoHome, - }); - - const nestedDir = join(created.worktreePath, "nested", "dir"); - mkdirSync(nestedDir, { recursive: true }); - - await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome }); - expect(existsSync(created.worktreePath)).toBe(false); - - const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); - expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false); - }); - - it("runs teardown commands from paseo.json before deleting a worktree", async () => { - const paseoConfig = { - worktree: { - teardown: [ - 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', - 'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', - 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', - 'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', - 'echo "port=$PASEO_WORKTREE_PORT" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', - ], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync( - 'git add paseo.json && git -c commit.gpgsign=false commit -m "add teardown commands"', - { - cwd: repoDir, - }, - ); - - const created = await createLegacyWorktreeForTest({ - branchName: "teardown-branch", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "teardown-test", - paseoHome, - }); - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: created.worktreePath, - branchName: created.branchName, - }); - - await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); - expect(existsSync(created.worktreePath)).toBe(false); - - const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8"); - expect(teardownLog).toContain(`source=${repoDir}`); - expect(teardownLog).toContain(`root_alias=${repoDir}`); - expect(teardownLog).toContain(`worktree=${created.worktreePath}`); - expect(teardownLog).toContain("branch=teardown-branch"); - expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`); - }); - - it("runs string teardown scripts from paseo.json as a single shell command", async () => { - const paseoConfig = { - worktree: { - teardown: - 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add string teardown"', { - cwd: repoDir, - }); - - const created = await createLegacyWorktreeForTest({ - branchName: "teardown-string-branch", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "teardown-string-test", - paseoHome, - }); - - await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); - - expect(getWorktreeTeardownCommands(repoDir)).toEqual([ - 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', - ]); - expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string"); - }); - - it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => { - const paseoConfig = { - worktree: { - teardown: [ - 'echo "port=${PASEO_WORKTREE_PORT-unset}" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-port.log"', - ], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync( - 'git add paseo.json && git -c commit.gpgsign=false commit -m "add teardown port logging"', - { cwd: repoDir }, - ); - - const created = await createLegacyWorktreeForTest({ - branchName: "teardown-port-missing-branch", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "teardown-port-missing-test", - paseoHome, - }); - - await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); - - expect(readFileSync(join(repoDir, "teardown-port.log"), "utf8").trim()).toBe("port=unset"); - expect(existsSync(created.worktreePath)).toBe(false); - }); - - it("does not remove worktree when a teardown command fails", async () => { - const paseoConfig = { - worktree: { - teardown: [ - 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"', - "echo boom 1>&2; exit 9", - ], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execSync( - 'git add paseo.json && git -c commit.gpgsign=false commit -m "add failing teardown commands"', - { cwd: repoDir }, - ); - - const created = await createLegacyWorktreeForTest({ - branchName: "teardown-failure-branch", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "teardown-failure-test", - paseoHome, - }); - - await expect( - deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }), - ).rejects.toThrow("Worktree teardown command failed"); - - expect(existsSync(created.worktreePath)).toBe(true); - expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true); - }); - it("treats a worktree as paseo-owned even when its .git admin is missing", async () => { const created = await createLegacyWorktreeForTest({ branchName: "orphan-admin-branch",