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

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