mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
109
packages/server/src/utils/checkout-git-rev-parse.test.ts
Normal file
109
packages/server/src/utils/checkout-git-rev-parse.test.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
25
packages/server/src/utils/git-rev-parse-path.ts
Normal file
25
packages/server/src/utils/git-rev-parse-path.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user