Merge remote-tracking branch 'origin/main' into dev

# Conflicts:
#	packages/server/src/server/workspace-registry-model.test.ts
#	packages/server/src/utils/worktree.ts
This commit is contained in:
Mohamed Boudra
2026-04-16 15:52:14 +07:00
20 changed files with 921 additions and 319 deletions

View File

@@ -102,7 +102,20 @@ jobs:
- name: Run Windows-critical server tests
working-directory: packages/server
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.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/persisted-config.test.ts
run: >
npx vitest run
src/utils/executable.test.ts
src/utils/spawn.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.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-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
app-tests:
runs-on: ubuntu-latest

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-UrTPJVju3jiSlkUMfh25rWobIo9hWNVFoVewFGxRQC0=";
npmDepsHash = "sha256-Wn7g+VXuUe0LI733MUj+WRX/fJQHOO/HFEkqyHrWTf0=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

25
package-lock.json generated
View File

@@ -35365,6 +35365,7 @@
"strip-ansi": "^7.1.2",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.1"
@@ -35588,6 +35589,15 @@
"node": ">= 0.8"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -35742,6 +35752,21 @@
"node": ">= 0.6"
}
},
"packages/server/node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",

View File

@@ -89,6 +89,7 @@
"strip-ansi": "^7.1.2",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.1"

View File

@@ -659,6 +659,12 @@ export class AgentManager {
: overrides;
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.resumeSession(handle, resumeOverrides, launchContext);
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
}

View File

@@ -0,0 +1,103 @@
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import {
query,
type Options,
type Query,
type SpawnOptions as ClaudeSpawnOptions,
} from "@anthropic-ai/claude-agent-sdk";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import * as spawnUtils from "../../../utils/spawn.js";
import { ClaudeAgentClient } from "./claude-agent.js";
function createQueryMock(events: unknown[]): Query {
let index = 0;
return {
next: vi.fn(async () =>
index < events.length
? { done: false, value: events[index++] }
: { done: true, value: undefined },
),
return: vi.fn(async () => ({ done: true, value: undefined })),
interrupt: vi.fn(async () => undefined),
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
} as Query;
}
function createChildProcessStub(): ChildProcess {
return {
stderr: new EventEmitter(),
} as ChildProcess;
}
describe("Claude spawn override", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("bypasses the shell when spawning Claude Code", async () => {
let capturedOptions: Options | undefined;
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
capturedOptions = options;
return createQueryMock([
{
type: "system",
subtype: "init",
session_id: "claude-spawn-shell-regression-session",
permissionMode: "default",
model: "opus",
},
{
type: "assistant",
message: { content: "done" },
},
{
type: "result",
subtype: "success",
usage: {
input_tokens: 1,
cache_read_input_tokens: 0,
output_tokens: 1,
},
total_cost_usd: 0,
},
]);
});
const spawnSpy = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(createChildProcessStub());
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
await session.run("spawn shell regression");
capturedOptions?.spawnClaudeCodeProcess?.({
command: "node",
args: ["claude.js", "--mcp-config", '{"mcpServers":{"paseo":{"type":"http"}}}'],
cwd: process.cwd(),
env: {},
signal: new AbortController().signal,
} satisfies ClaudeSpawnOptions);
} finally {
await session.close();
}
expect(spawnSpy).toHaveBeenCalledTimes(1);
const spawnOptions = spawnSpy.mock.calls[0]?.[2];
expect(spawnOptions?.shell).toBe(false);
});
});

View File

@@ -1135,6 +1135,8 @@ export class ClaudeAgentClient implements AgentClient {
if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]);
}
// Default mode uses @anthropic-ai/claude-agent-sdk's bundled cli.js run
// via process.execPath. No external `claude` binary is required.
return true;
}

View File

@@ -4215,7 +4215,7 @@ export class CodexAppServerAgentClient implements AgentClient {
if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]);
}
return true;
return await isCommandAvailable("codex");
}
async getDiagnostic(): Promise<{ diagnostic: string }> {

View File

@@ -1041,7 +1041,7 @@ export class OpenCodeAgentClient implements AgentClient {
if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]);
}
return true;
return await isCommandAvailable("opencode");
}
async getDiagnostic(): Promise<{ diagnostic: string }> {

View File

@@ -0,0 +1,147 @@
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 type { AgentProvider } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ClaudeAgentClient } from "./claude-agent.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("default provider availability", () => {
test("Codex reports unavailable when the default command cannot be resolved", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(false);
});
test("Claude reports available without a PATH binary because the SDK bundles its own cli.js", async () => {
const binDir = makeTempDir("provider-availability-claude-");
isolatePathTo(binDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
await expect(client.isAvailable()).resolves.toBe(true);
});
test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
const binDir = makeTempDir("provider-availability-opencode-");
isolatePathTo(binDir);
const client = new OpenCodeAgentClient(createTestLogger());
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);
const workdir = makeTempDir("provider-availability-manager-work-");
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
const manager = new AgentManager({
clients: {
codex: new CodexAppServerAgentClient(createTestLogger()),
},
registry: storage,
logger: createTestLogger(),
});
await expect(manager.listProviderAvailability()).resolves.toEqual([
{
provider: "codex",
available: false,
error: null,
},
]);
});
test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => {
const binDir = makeTempDir("provider-availability-resume-bin-");
isolatePathTo(binDir);
const workdir = makeTempDir("provider-availability-resume-work-");
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
const manager = new AgentManager({
clients: {
codex: new CodexAppServerAgentClient(createTestLogger()),
},
registry: storage,
logger: createTestLogger(),
});
await expect(
manager.resumeAgentFromPersistence(
{
provider: "codex" as AgentProvider,
sessionId: "missing-codex-session",
metadata: {
provider: "codex",
cwd: workdir,
},
},
{ cwd: workdir },
),
).rejects.toThrow("Provider 'codex' is not available");
});
});

View File

@@ -0,0 +1,116 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test } from "vitest";
import { ensureAgentLoaded } from "./agent/agent-loading.js";
import type { PaseoDaemonConfig } from "./bootstrap.js";
const originalEnv = {
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
};
describe("bootstrap provider availability", () => {
const tempRoots: string[] = [];
afterEach(async () => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const root of tempRoots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
test("loads a persisted Codex record without spawning a missing Codex binary", async () => {
const { createPaseoDaemon } = await import("./bootstrap.js");
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
tempRoots.push(root);
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
tempRoots.push(binDir);
process.env.PATH = binDir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
const paseoHome = path.join(root, ".paseo");
const staticDir = path.join(root, "static");
const agentStoragePath = path.join(paseoHome, "agents");
const now = new Date("2026-04-16T00:00:00.000Z").toISOString();
const agentId = "11111111-1111-4111-8111-111111111111";
await mkdir(agentStoragePath, { recursive: true });
await mkdir(staticDir, { recursive: true });
await writeFile(
path.join(agentStoragePath, `${agentId}.json`),
JSON.stringify({
id: agentId,
provider: "codex",
cwd: root,
createdAt: now,
updatedAt: now,
lastStatus: "idle",
lastModeId: "auto",
config: {
modeId: "auto",
model: "gpt-5.4",
},
persistence: {
provider: "codex",
sessionId: "codex-session-1",
metadata: {
provider: "codex",
cwd: root,
},
},
}),
);
const config: PaseoDaemonConfig = {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: {},
agentStoragePath,
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
};
const processFailures: Error[] = [];
const onUnhandledRejection = (reason: unknown) => {
processFailures.push(reason instanceof Error ? reason : new Error(String(reason)));
};
const onUncaughtException = (error: Error) => {
processFailures.push(error);
};
process.on("unhandledRejection", onUnhandledRejection);
process.on("uncaughtException", onUncaughtException);
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.agentStorage.list()).resolves.toHaveLength(1);
await expect(daemon.agentManager.listProviderAvailability()).resolves.toContainEqual({
provider: "codex",
available: false,
error: null,
});
await expect(
ensureAgentLoaded(agentId, {
agentManager: daemon.agentManager,
agentStorage: daemon.agentStorage,
logger: pino({ level: "silent" }),
}),
).rejects.toThrow("Provider 'codex' is not available");
await new Promise((resolve) => setImmediate(resolve));
expect(processFailures).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
process.off("uncaughtException", onUncaughtException);
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
}
});
});

View File

@@ -11,6 +11,7 @@ import {
resolveGhPath,
resolveAbsoluteGitDir,
} from "../utils/checkout-git.js";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
@@ -368,15 +369,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await this.deps.runGitCommand(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const root = stdout.trim();
return root.length > 0 ? root : null;
const { stdout } = await this.deps.runGitCommand(["rev-parse", "--show-toplevel"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
return parseGitRevParsePath(stdout);
} catch {
return null;
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, test, vi } from "vitest";
import {
deriveWorkspaceId,
detectStaleWorkspaces,
normalizeWorkspaceId,
} from "./workspace-registry-model.js";
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
function createWorkspaceRecord(workspaceId: string) {
return createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd: workspaceId,
kind: "directory",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
}
describe("detectStaleWorkspaces", () => {
test("returns workspace ids whose directories no longer exist", async () => {
const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing");
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/existing"),
createWorkspaceRecord("/tmp/missing"),
],
checkDirectoryExists,
});
expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/missing"]);
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
});
test("keeps workspaces whose directories exist even when all agents are archived", async () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")],
checkDirectoryExists: async () => true,
});
expect(Array.from(staleWorkspaceIds)).toEqual([]);
});
test("keeps workspaces with no agents when directory exists", async () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/active"),
createWorkspaceRecord("/tmp/no-agents"),
],
checkDirectoryExists: async () => true,
});
expect(Array.from(staleWorkspaceIds)).toEqual([]);
});
});
describe("deriveWorkspaceId", () => {
test("uses git worktree root when available", () => {
expect(
deriveWorkspaceId("/tmp/repo/packages/app", {
cwd: "/tmp/repo/packages/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: "/tmp/repo",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
).toBe("/tmp/repo");
});
test("falls back to normalized cwd when git worktree root contains multiple lines", () => {
const cwd = String.raw`E:\project\node-ai`;
expect(
deriveWorkspaceId(cwd, {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: `--path-format=absolute\n${cwd}`,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
).toBe(normalizeWorkspaceId(cwd));
});
test("falls back to normalized cwd for non-git directories", () => {
const cwd = "/tmp/repo/../repo/scratch";
expect(
deriveWorkspaceId(cwd, {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
).toBe(normalizeWorkspaceId("/tmp/repo/scratch"));
});
});

View File

@@ -1,6 +1,7 @@
import { resolve } from "node:path";
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
@@ -20,7 +21,8 @@ export function normalizeWorkspaceId(cwd: string): string {
}
export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string {
return checkout.worktreeRoot ?? normalizeWorkspaceId(cwd);
const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null;
return worktreeRoot ?? normalizeWorkspaceId(cwd);
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {

View File

@@ -0,0 +1,109 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const VALID_WINDOWS_ROOT = String.raw`E:\project\node-ai`;
const OLD_GIT_PATH_FORMAT_ECHO = `--path-format=absolute\n${VALID_WINDOWS_ROOT}\n`;
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "paseo-checkout-git-"));
tempDirs.push(dir);
return dir;
}
function gitResult(stdout: string) {
return { stdout, stderr: "", exitCode: 0 };
}
function normalizePathForPlatform(value: string): string {
if (process.platform !== "win32") {
return value;
}
return value.replace(/\\/g, "/").toLowerCase();
}
function gitCanonicalize(dir: string): string {
return execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
encoding: "utf8",
}).trim();
}
async function loadCheckoutGitWithRevParseTopLevelOutput(stdout: string) {
vi.resetModules();
const runGitCommand = vi.fn(async (args: string[]) => {
if (args.join(" ") === "rev-parse --show-toplevel") {
return gitResult(stdout);
}
if (args.join(" ") === "rev-parse --abbrev-ref HEAD") {
return gitResult("main\n");
}
if (args.join(" ") === "status --porcelain") {
return gitResult("");
}
if (args.join(" ") === "branch --format=%(refname:short)") {
return gitResult("main\n");
}
throw new Error(`Unexpected git command: git ${args.join(" ")}`);
});
vi.doMock("./run-git-command.js", () => ({ runGitCommand }));
const checkoutGit = await import("./checkout-git.js");
return { getCheckoutStatus: checkoutGit.getCheckoutStatus, runGitCommand };
}
describe("checkout git rev-parse path handling", () => {
afterEach(() => {
vi.doUnmock("./run-git-command.js");
vi.resetModules();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("resolves the worktree root from a nested real git checkout", async () => {
const repoRoot = makeTempDir();
const nested = join(repoRoot, "packages", "server", "src");
mkdirSync(nested, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
const { getCheckoutStatus } = await import("./checkout-git.js");
const status = await getCheckoutStatus(nested);
expect(status.isGit).toBe(true);
if (!status.isGit) {
throw new Error("Expected nested checkout to be detected as a git repository");
}
expect(normalizePathForPlatform(status.repoRoot)).toBe(
normalizePathForPlatform(gitCanonicalize(repoRoot)),
);
});
it("rejects multi-line rev-parse stdout and never calls the removed path-format command", async () => {
// Pre-2.31 Git is difficult to install in CI; inject its multi-line stdout
// shape at the exact production command boundary that consumes rev-parse.
const { getCheckoutStatus, runGitCommand } =
await loadCheckoutGitWithRevParseTopLevelOutput(OLD_GIT_PATH_FORMAT_ECHO);
const status = await getCheckoutStatus(VALID_WINDOWS_ROOT);
expect(status).toEqual({ isGit: false });
expect(runGitCommand).toHaveBeenCalledWith(["rev-parse", "--show-toplevel"], expect.anything());
expect(runGitCommand).not.toHaveBeenCalledWith(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
expect.anything(),
);
expect(runGitCommand).not.toHaveBeenCalledWith(
["rev-parse", "--git-common-dir"],
expect.anything(),
);
});
});

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { findExecutable } from "./executable.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
import { execCommand } from "./spawn.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
@@ -573,26 +574,25 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
async function getWorktreeRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const root = stdout.trim();
return root.length > 0 ? root : null;
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
return parseGitRevParsePath(stdout);
} catch {
return null;
}
}
export async function getMainRepoRoot(cwd: string): Promise<string> {
const { stdout: commonDirOut } = await runGitCommand(
["rev-parse", "--path-format=absolute", "--git-common-dir"],
{ cwd, env: READ_ONLY_GIT_ENV },
);
const commonDir = commonDirOut.trim();
const { stdout: commonDirOut } = await runGitCommand(["rev-parse", "--git-common-dir"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const commonDir = resolveGitRevParsePath(cwd, commonDirOut);
if (!commonDir) {
throw new Error("Not in a git repository");
}
const normalized = realpathSync(commonDir);
if (basename(normalized) === ".git") {

View File

@@ -1,213 +1,157 @@
import { promisify } from "node:util";
import { afterEach, describe, expect, test, vi } from "vitest";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
import {
executableExists,
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable.js";
async function loadExecutableModule(params?: {
execFileImpl?: (
command: string,
args: string[],
options: unknown,
callback: ExecFileCallback,
) => void;
}) {
vi.resetModules();
const originalEnv = {
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
};
const tempDirs: string[] = [];
const execFileMock = vi.fn(
params?.execFileImpl ??
((_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(new Error("execFile not mocked"), "", "");
}),
);
Object.assign(execFileMock, {
[promisify.custom]: (command: string, args: string[], options: unknown) =>
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFileMock(
command,
args,
options,
(error: Error | null, stdout: string, stderr: string) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
},
);
}),
});
vi.doMock("node:child_process", () => ({
execFile: execFileMock,
}));
const module = await import("./executable.js");
return {
...module,
execFileMock,
};
function makeTempDir(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "paseo-executable-test-"));
tempDirs.push(dir);
return dir;
}
describe("findExecutable", () => {
const originalPlatform = process.platform;
const missingBinaryName = "nonexistent-binary-xyz-12345";
function prependPath(...dirs: string[]): void {
process.env.PATH = [...dirs, originalEnv.PATH].filter(Boolean).join(path.delimiter);
}
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
function writeExecutable(filePath: string, content: string): string {
writeFileSync(filePath, content);
if (process.platform !== "win32") {
chmodSync(filePath, 0o755);
}
return filePath;
}
afterEach(() => {
setPlatform(originalPlatform);
});
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");
}
test("on Windows, resolves executables using where.exe with inherited PATH", async () => {
setPlatform("win32");
const { execFileMock, findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", "");
},
});
function writeBrokenAbsoluteFixture(dir: string): string {
const filePath =
process.platform === "win32" ? path.join(dir, "broken.exe") : path.join(dir, "broken");
writeFileSync(filePath, "not executable");
if (process.platform !== "win32") {
chmodSync(filePath, 0o644);
}
return filePath;
}
await expect(findExecutable("claude")).resolves.toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
);
expect(execFileMock).toHaveBeenCalledOnce();
const call = execFileMock.mock.calls[0];
expect(call?.[0]).toBe("where.exe");
expect(call?.[1]).toEqual(["claude"]);
expect(call?.[2]).toMatchObject({
encoding: "utf8",
windowsHide: true,
function expectWindowsPathsEqual(actual: string | null, expected: string): void {
expect(actual?.toLowerCase()).toBe(expected.toLowerCase());
}
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("findExecutable", () => {
describe.skipIf(process.platform === "win32")("POSIX", () => {
test("finds an extensionless executable and skips an earlier non-executable candidate", async () => {
const executableDir = makeTempDir();
const nonExecutableDir = makeTempDir();
const executable = writeExecutable(path.join(executableDir, "foo"), "#!/bin/sh\necho 0.1\n");
const nonExecutable = path.join(nonExecutableDir, "foo");
writeFileSync(nonExecutable, "#!/bin/sh\necho broken\n");
chmodSync(nonExecutable, 0o644);
prependPath(nonExecutableDir, executableDir);
await expect(findExecutable("foo")).resolves.toBe(executable);
});
});
test("on Windows, prefers an executable match from where.exe output", async () => {
setPlatform("win32");
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", "");
},
describe.runIf(process.platform === "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);
const brokenExe = path.join(dir, "foo.exe");
const cmd = writeExecutable(path.join(dir, "foo.cmd"), "@echo off\r\necho 0.1\r\n");
writeFileSync(brokenExe, "");
prependPath(dir);
expectWindowsPathsEqual(await findExecutable("foo"), cmd);
});
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd");
test("returns null when the only candidate is a broken .exe", async () => {
const dir = makeTempDir();
process.env.PATHEXT = ".EXE";
writeFileSync(path.join(dir, "foo.exe"), "");
prependPath(dir);
await expect(findExecutable("foo")).resolves.toBeNull();
});
test("returns a .cmd when it is the only candidate", async () => {
const dir = makeTempDir();
process.env.PATHEXT = ".CMD";
const cmd = writeExecutable(path.join(dir, "foo.cmd"), "@echo off\r\necho 0.1\r\n");
prependPath(dir);
expectWindowsPathsEqual(await findExecutable("foo"), cmd);
});
});
test("on Windows, prefers .exe over .cmd, .ps1, and extensionless candidates", async () => {
setPlatform("win32");
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(
null,
[
"C:\\nvm4w\\nodejs\\codex",
"C:\\nvm4w\\nodejs\\codex.ps1",
"C:\\nvm4w\\nodejs\\codex.cmd",
"C:\\nvm4w\\nodejs\\codex.exe",
].join("\r\n"),
"",
);
},
});
test("returns an invokable absolute path", async () => {
const dir = makeTempDir();
const fixture = writeInvokableFixture(dir, "absolute-ok");
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.exe");
await expect(findExecutable(fixture)).resolves.toBe(fixture);
});
test("on Windows, returns null when where.exe output is empty", async () => {
setPlatform("win32");
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "\r\n", "");
},
});
test("returns null for an absolute path that cannot spawn", async () => {
const dir = makeTempDir();
const fixture = writeBrokenAbsoluteFixture(dir);
await expect(findExecutable(missingBinaryName)).resolves.toBeNull();
await expect(findExecutable(fixture)).resolves.toBeNull();
});
test("on Windows, falls back to the first extensionless candidate when needed", async () => {
setPlatform("win32");
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "C:\\nvm4w\\nodejs\\codex\r\n", "");
},
});
test("returns null when the command is not on PATH", async () => {
const dir = makeTempDir();
prependPath(dir);
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex");
});
test("on Unix, uses the last line from which output", async () => {
const { execFileMock, findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "/usr/local/bin/codex\n", "");
},
});
await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex");
expect(execFileMock).toHaveBeenCalledWith(
process.platform === "win32" ? "where.exe" : "which",
["codex"],
process.platform === "win32" ? { encoding: "utf8", windowsHide: true } : { encoding: "utf8" },
expect.any(Function),
);
});
test.skipIf(process.platform === "win32")(
"warns and returns null when the final which line is not an absolute path",
async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "codex\n", "");
},
});
await expect(findExecutable("codex")).resolves.toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
warnSpy.mockRestore();
},
);
test("returns null when which lookup fails", async () => {
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(new Error("which failed"), "", "");
},
});
await expect(findExecutable(missingBinaryName)).resolves.toBeNull();
await expect(findExecutable("paseo-definitely-missing-command")).resolves.toBeNull();
});
});
describe("executableExists", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("returns the path when it already exists", async () => {
const { executableExists } = await loadExecutableModule();
const exists = vi.fn((candidate: string) => candidate === "/usr/local/bin/codex");
test("returns the path when it already exists", () => {
const exists = (candidate: string) => candidate === "/usr/local/bin/codex";
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
});
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", async () => {
setPlatform("win32");
const { executableExists } = await loadExecutableModule();
const exists = vi.fn((candidate: string) => candidate === "C:\\tools\\codex.cmd");
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", writable: true });
try {
const exists = (candidate: string) => candidate === "C:\\tools\\codex.cmd";
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
}
});
test("returns null when no matching path exists", async () => {
const { executableExists } = await loadExecutableModule();
const exists = vi.fn(() => false);
expect(executableExists("/missing/codex", exists)).toBeNull();
test("returns null when no matching path exists", () => {
expect(executableExists("/missing/codex", () => false)).toBeNull();
});
});
@@ -222,71 +166,61 @@ describe("quoteWindowsCommand", () => {
setPlatform(originalPlatform);
});
test("quotes a Windows path with spaces", async () => {
test("quotes a Windows path with spaces", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("does not double-quote an already-quoted path", async () => {
test("does not double-quote an already-quoted path", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("returns the command unchanged when there are no spaces", async () => {
test("returns the command unchanged when there are no spaces", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("escapes ampersands", async () => {
test("escapes ampersands", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
});
test("escapes pipes", async () => {
test("escapes pipes", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
});
test("doubles percent signs", async () => {
test("doubles percent signs", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("100%")).toBe("100%%");
});
test("escapes carets", async () => {
test("escapes carets", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
});
test("escapes multiple metacharacters", async () => {
test("escapes multiple metacharacters", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
"build^&^(test^|deploy^)^!^<output^>",
);
});
test("quotes commands with spaces after escaping metacharacters", async () => {
test("quotes commands with spaces after escaping metacharacters", () => {
setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
'"C:\\Program Files\\My Tool^&Stuff\\run 100%%.cmd"',
);
});
test("returns the command unchanged on non-Windows platforms", async () => {
test("returns the command unchanged on non-Windows platforms", () => {
setPlatform("darwin");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
@@ -302,31 +236,27 @@ describe("quoteWindowsArgument", () => {
setPlatform(originalPlatform);
});
test("quotes a Windows argument with spaces", async () => {
test("quotes a Windows argument with spaces", () => {
setPlatform("win32");
const { quoteWindowsArgument } = await loadExecutableModule();
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("does not double-quote an already-quoted argument", async () => {
test("does not double-quote an already-quoted argument", () => {
setPlatform("win32");
const { quoteWindowsArgument } = await loadExecutableModule();
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("returns the argument unchanged when there are no spaces", async () => {
test("returns the argument unchanged when there are no spaces", () => {
setPlatform("win32");
const { quoteWindowsArgument } = await loadExecutableModule();
expect(quoteWindowsArgument("--version")).toBe("--version");
});
test("returns the argument unchanged on non-Windows platforms", async () => {
test("returns the argument unchanged on non-Windows platforms", () => {
setPlatform("darwin");
const { quoteWindowsArgument } = await loadExecutableModule();
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});

View File

@@ -1,54 +1,100 @@
import { execFile } from "node:child_process";
import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import path, { extname } from "node:path";
import { promisify } from "node:util";
import { extname } from "node:path";
const execFileAsync = promisify(execFile);
type Which = (command: string, options: { all: true }) => Promise<string[]>;
function pickBestWindowsCandidate(lines: string[]): string | null {
const candidates = lines.filter((line) => line.length > 0);
if (candidates.length === 0) return null;
const require = createRequire(import.meta.url);
const which = require("which") as Which;
const PROBE_TIMEOUT_MS = 2000;
const extPriority = [".exe", ".cmd", ".ps1"];
for (const ext of extPriority) {
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext));
if (match) return match;
}
return candidates[0] ?? null;
function hasPathSeparator(value: string): boolean {
return value.includes("/") || value.includes("\\");
}
function resolveExecutableFromWhichOutput(
name: string,
output: string,
source: "which",
): string | null {
const lines = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
const candidate = lines.at(-1);
if (!candidate) {
return null;
async function enumerateCandidates(name: string): Promise<string[]> {
let candidates: string[];
try {
candidates = await which(name, { all: true });
} catch (error) {
// `which` throws ENOENT when the command is absent from PATH.
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
if (!path.isAbsolute(candidate)) {
console.warn(
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
);
return null;
}
const seen = new Set<string>();
return candidates.filter((candidate) => {
if (seen.has(candidate)) {
return false;
}
seen.add(candidate);
return true;
});
}
return candidate;
function isWindowsCommandScript(executablePath: string): boolean {
const extension = extname(executablePath).toLowerCase();
return process.platform === "win32" && (extension === ".cmd" || extension === ".bat");
}
async function probeExecutable(executablePath: string): Promise<boolean> {
return await new Promise((resolve) => {
let settled = false;
let started = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const settle = (result: boolean) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
resolve(result);
};
let child: ChildProcess;
try {
child = spawn(executablePath, ["--version"], {
stdio: "ignore",
windowsHide: true,
// Windows batch shims (.cmd/.bat) require cmd.exe; native binaries do not.
shell: isWindowsCommandScript(executablePath),
});
} catch {
settle(false);
return;
}
timer = setTimeout(() => {
if (started) {
child.kill();
settle(true);
return;
}
settle(false);
}, PROBE_TIMEOUT_MS);
timer.unref?.();
child.once("spawn", () => {
started = true;
});
child.once("error", () => {
// ENOENT/EACCES/EPERM/UNKNOWN here means the OS could not start the candidate.
settle(started);
});
child.once("exit", () => {
settle(started);
});
});
}
/**
* On Unix we use `which`. On Windows we use `where.exe`.
*
* Both rely on the inherited process.env.PATH — on macOS/Linux, Electron
* enriches it at startup via inheritLoginShellEnv(); on Windows, Electron
* inherits the full user environment from Explorer.
* Check a literal executable path. PATH search is handled by findExecutable().
*/
export function executableExists(
executablePath: string,
@@ -70,35 +116,18 @@ export async function findExecutable(name: string): Promise<string | null> {
return null;
}
if (trimmed.includes("/") || trimmed.includes("\\")) {
return executableExists(trimmed);
if (hasPathSeparator(trimmed)) {
return (await probeExecutable(trimmed)) ? trimmed : null;
}
if (process.platform === "win32") {
try {
const { stdout } = await execFileAsync("where.exe", [trimmed], {
encoding: "utf8",
windowsHide: true,
});
return (
pickBestWindowsCandidate(
stdout
.trim()
.split(/\r?\n/)
.map((line) => line.trim()),
) ?? null
);
} catch {
return null;
const candidates = await enumerateCandidates(trimmed);
for (const candidate of candidates) {
if (await probeExecutable(candidate)) {
return candidate;
}
}
try {
const { stdout } = await execFileAsync("which", [trimmed], { encoding: "utf8" });
return resolveExecutableFromWhichOutput(trimmed, stdout.trim(), "which");
} catch {
return null;
}
return null;
}
export async function isCommandAvailable(command: string): Promise<boolean> {

View File

@@ -0,0 +1,25 @@
import { resolve } from "node:path";
export function parseGitRevParsePath(stdout: string): string | null {
const trimmed = stdout.trim();
if (!trimmed) {
return null;
}
const lines = trimmed.split(/\r?\n/);
if (lines.length !== 1) {
return null;
}
const path = lines[0]?.trim() ?? "";
if (!path || path.startsWith("--")) {
return null;
}
return path;
}
export function resolveGitRevParsePath(cwd: string, stdout: string): string | null {
const parsed = parseGitRevParsePath(stdout);
return parsed ? resolve(cwd, parsed) : null;
}

View File

@@ -18,6 +18,7 @@ import {
import { runGitCommand } from "./run-git-command.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
interface PaseoConfig {
worktree?: {
@@ -529,14 +530,11 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
} catch {
// Fallback: best-effort resolve toplevel (will be the worktree root in typical cases)
try {
const { stdout } = await runGitCommand(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
{
cwd: worktreePath,
env: READ_ONLY_GIT_ENV,
},
);
const topLevel = stdout.trim();
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd: worktreePath,
env: READ_ONLY_GIT_ENV,
});
const topLevel = parseGitRevParsePath(stdout);
if (topLevel) {
return normalizePathForOwnership(topLevel);
}
@@ -716,14 +714,11 @@ export async function runWorktreeTeardownCommands(options: {
* This is where refs, objects, etc. are stored.
*/
export async function getGitCommonDir(cwd: string): Promise<string> {
const { stdout } = await runGitCommand(
["rev-parse", "--path-format=absolute", "--git-common-dir"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const commonDir = stdout.trim();
const { stdout } = await runGitCommand(["rev-parse", "--git-common-dir"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const commonDir = resolveGitRevParsePath(cwd, stdout);
if (!commonDir) {
throw new Error("Not in a git repository");
}
@@ -983,15 +978,11 @@ export async function resolvePaseoWorktreeRootForCwd(
let worktreeRoot: string | null = null;
try {
const { stdout } = await runGitCommand(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const trimmed = stdout.trim();
worktreeRoot = trimmed.length > 0 ? trimmed : null;
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
worktreeRoot = parseGitRevParsePath(stdout);
} catch {
worktreeRoot = null;
}