Run server tests on Linux+Windows matrix (#809)

* Run server tests on Linux+Windows matrix

Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets.

* Add isPlatform helper and gate Windows-hostile server tests

- Add a shared server test isPlatform helper.

- Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper.

- Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf.

* Replace POSIX shell calls in test fixtures with Node primitives

- Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests.

- Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures.

- Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior.

* Fix Windows server-test failures and split POSIX-only suites into sibling files

- Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later.

* Move POSIX-only test blocks into sibling .posix.test.ts files

- terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts.

- worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts.

- worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts.

- worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original.

- provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites.

* Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests

- Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches.

- Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup.

- Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files.

- Gate the two explicitly Linux-only workspace-git watcher tests.

* Replace hardcoded POSIX paths with portable Node path constructions in server tests

- checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches.

- directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native.

- session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions.

- workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands.

* Fix loop-service verify-check shell and workspace-git-service path-separator on Windows

- Run the loop-service verify script through the current Node executable with a relative script path.

- Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion.

* Fix loop-service worker PTY spawn on Windows

- Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd.

* Gate loop-service real-worker-PTY test on Windows

- Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered.

* Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows

- Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub.

* Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir

- Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
This commit is contained in:
Mohamed Boudra
2026-05-08 10:57:22 +08:00
committed by GitHub
parent 39e461b872
commit c4e4a28bc0
44 changed files with 5673 additions and 4899 deletions

View File

@@ -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

View File

@@ -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");
},
);
});

View File

@@ -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,

View File

@@ -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 });
});

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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 });
}
},
);
});

View File

@@ -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();

View File

@@ -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<string> {
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 });
}
});
});

View File

@@ -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<string> {
return mkdtemp(path.join(os.homedir(), prefix));
@@ -13,29 +13,6 @@ async function createTempDir(prefix: string): Promise<string> {
}
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 });
}
});
});

View File

@@ -102,7 +102,7 @@ describe("resolveLogConfig", () => {
},
file: {
level: "debug",
path: path.join(paseoHome, "logs", "programmatic.log"),
path: path.resolve(paseoHome, "logs", "programmatic.log"),
},
});
});

View File

@@ -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[] = [];

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -47,6 +47,7 @@ import {
asDaemonConfigStore,
createProviderSnapshotManagerStub,
} from "./test-utils/session-stubs.js";
import { isPlatform } from "../test-utils/platform.js";
interface SessionHandlerInternals {
startVoiceTurnController(): Promise<void>;
@@ -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();

View File

@@ -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();
});
});

View File

@@ -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<unknown>;
}>(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");

View File

@@ -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<unknown[]>;
@@ -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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, unknown> }
| 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<string, unknown> }
| 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",

View File

@@ -86,6 +86,15 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
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 = {

View File

@@ -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<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.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<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.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<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.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<string | null>>()
.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<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.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 });
}
},
);
});

View File

@@ -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<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>;
@@ -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<CheckoutStatusGit>();
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<CheckoutStatusGit>>()
.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 } },
}),
);

View File

@@ -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();

View File

@@ -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");
});

View File

@@ -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<void> {
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<void> {
return terminalManager.killTerminalAndWait(terminal.id, {
gracefulTimeoutMs: 100,
forceTimeoutMs: 100,
});
}
async function createBootstrapWorktreeForTest(
options: CreateAgentWorktreeTestOptions,
): Promise<CreateAgentWorktreeTestResult> {
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<void> {
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<string, string> {
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<string, string> = {};
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<AgentTimelineItem, { type: "tool_call" }> =>
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<AgentTimelineItem, { type: "tool_call" }> =>
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<string, string> }> = [];
const createTerminalEnvs: Record<string, string>[] = [];
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<AgentTimelineItem, { type: "tool_call" }> =>
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",
},
]);
});
});
});

View File

@@ -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<void> {
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<AgentTimelineItem, { type: "tool_call" }> =>
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<AgentTimelineItem, { type: "tool_call" }> =>
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<string, string> }> = [];
const createTerminalEnvs: Record<string, string>[] = [];
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<AgentTimelineItem, { type: "tool_call" }> =>
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<string, string> {
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<string, string> = {};
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();

View File

@@ -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");
});
});
});

View File

@@ -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",
);
});
});

View File

@@ -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<string, unknown> }) {
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<string, unknown>();
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<string, unknown>();
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<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
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<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
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 });
}

View File

@@ -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<T>(shell: string, run: () => Promise<T>): Promise<T> {
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,
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
export function isPlatform(...platforms: NodeJS.Platform[]): boolean {
return platforms.includes(process.platform);
}

View File

@@ -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`);

File diff suppressed because it is too large Load Diff

View File

@@ -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 });

View File

@@ -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" }));
}
},
);
});

View File

@@ -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 () => {

View File

@@ -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 = {

View File

@@ -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<T>(dir: string, run: () => Promise<T>): Promise<T> {
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();

View File

@@ -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("");
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff