Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion packages/core/src/__tests__/platform.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,94 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { _resetGitExecutableCache, getGitExecutable, isWindows } from "../platform.js";

describe("platform executable resolution", () => {
let tempRoot: string;
let originalPath: string | undefined;
let originalPathExt: string | undefined;
const originalPlatform = process.platform;

beforeEach(() => {
_resetGitExecutableCache();
tempRoot = join(
tmpdir(),
`ao-platform-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
mkdirSync(tempRoot, { recursive: true });
originalPath = process.env["PATH"];
originalPathExt = process.env["PATHEXT"];
});

afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform });
_resetGitExecutableCache();
vi.doUnmock("node:fs");
vi.resetModules();

if (originalPath === undefined) delete process.env["PATH"];
else process.env["PATH"] = originalPath;

if (originalPathExt === undefined) delete process.env["PATHEXT"];
else process.env["PATHEXT"] = originalPathExt;

rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});

it("resolves git from PATH before falling back to bare git", () => {
const binDir = join(tempRoot, "bin");
mkdirSync(binDir, { recursive: true });

const executableName = isWindows() ? "git.EXE" : "git";
const executablePath = join(binDir, executableName);
writeFileSync(executablePath, "");

process.env["PATH"] = binDir;
process.env["PATHEXT"] = ".EXE";

expect(getGitExecutable()).toBe(executablePath);
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

async function loadPlatformWithMockedFs(platform: NodeJS.Platform, existingPaths: Set<string>) {
Object.defineProperty(process, "platform", { value: platform });
vi.resetModules();
vi.doMock("node:fs", async () => {
const actual = await vi.importActual("node:fs");
return {
...(actual as Record<string, unknown>),
existsSync: (path: string) => existingPaths.has(path),
};
});
return import("../platform.js");
}

it("falls back to standard Windows Git install paths", async () => {
process.env["PATH"] = "";
const gitPath = "C:\\Program Files\\Git\\cmd\\git.exe";

const mod = await loadPlatformWithMockedFs("win32", new Set([gitPath]));

expect(mod.getGitExecutable()).toBe(gitPath);
});

it("falls back to Homebrew Git on macOS", async () => {
process.env["PATH"] = "";
const gitPath = "/opt/homebrew/bin/git";

const mod = await loadPlatformWithMockedFs("darwin", new Set([gitPath]));

expect(mod.getGitExecutable()).toBe(gitPath);
});

it("falls back to bare git when PATH and standard install paths miss", async () => {
process.env["PATH"] = "";

const mod = await loadPlatformWithMockedFs("linux", new Set());

expect(mod.getGitExecutable()).toBe("git");
});
});

describe("platform adapter", () => {
const originalPlatform = process.platform;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export {
isMac,
getDefaultRuntime,
getShell,
getGitExecutable,
killProcessTree,
findPidByPort,
getEnvDefaults,
Expand Down
62 changes: 49 additions & 13 deletions packages/core/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface ShellInfo {
}

let cachedShell: ShellInfo | null = null;
let cachedGitExecutable: string | null = null;

/**
* Infer the command-string flag for a given shell from its basename.
Expand All @@ -53,30 +54,65 @@ function inferShellArgsFlag(cmd: string): (command: string) => string[] {
return (c) => ["-Command", c];
}

function pathDelimiterForCurrentPlatform(): string {
return isWindows() ? ";" : ":";
}

function executableExtensions(): string[] {
if (!isWindows()) return [""];
return [...(process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [".COM", ".EXE", ".BAT", ".CMD"]), ""];
}

/**
* Walk PATH looking for an executable. Windows-only: only ever called from
* resolveWindowsShell. Hard-coded `;` separator and `\` path join regardless
* of host OS so unit tests that simulate Windows on a Linux CI runner produce
* canonical Windows paths.
* Walk PATH looking for an executable. Uses platform-specific delimiters and
* separators so tests can simulate Windows path lookup on any host OS.
*/
function findOnPath(name: string): string | null {
const exts = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [
".COM",
".EXE",
".BAT",
".CMD",
];
const dirs = (process.env["PATH"] ?? "").split(";").filter(Boolean);
const dirs = (process.env["PATH"] ?? "").split(pathDelimiterForCurrentPlatform()).filter(Boolean);
for (const dir of dirs) {
const base = dir.endsWith("\\") || dir.endsWith("/") ? dir.slice(0, -1) : dir;
for (const ext of [...exts, ""]) {
const candidate = `${base}\\${name}${ext}`;
for (const ext of executableExtensions()) {
const candidate = isWindows() ? `${base}\\${name}${ext}` : `${base}/${name}${ext}`;
if (existsSync(candidate)) return candidate;
}
}
return null;
}

/**
* Resolve Git for child_process.execFile callers.
*
* Most callers should work with bare "git", but GUI-launched Windows shells can
* have Node on PATH without Git. Falling back to standard install paths keeps
* worktree setup from failing with a raw ENOENT when Git is installed.
*/
export function getGitExecutable(): string {
if (cachedGitExecutable) return cachedGitExecutable;

const pathGit = findOnPath("git");
if (pathGit) return (cachedGitExecutable = pathGit);

const candidates = isWindows()
? [
"C:\\Program Files\\Git\\cmd\\git.exe",
"C:\\Program Files\\Git\\bin\\git.exe",
"C:\\Program Files (x86)\\Git\\cmd\\git.exe",
"C:\\Program Files (x86)\\Git\\bin\\git.exe",
]
: isMac()
? ["/usr/bin/git", "/opt/homebrew/bin/git", "/usr/local/bin/git"]
: ["/usr/bin/git", "/usr/local/bin/git"];

return (cachedGitExecutable = candidates.find((candidate) => existsSync(candidate)) ?? "git");
}

/** Reset cached git executable (for testing)
* @internal
*/
export function _resetGitExecutableCache(): void {
cachedGitExecutable = null;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

function resolveWindowsShell(): ShellInfo {
// Explicit override — set AO_SHELL to an absolute path or shell name
// (e.g. "powershell.exe", "pwsh", "cmd.exe", "bash"). Args are inferred
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ vi.mock("node:fs", () => ({
}));

vi.mock("@aoagents/ao-core", () => ({
getGitExecutable: vi.fn(() => "git"),
getShell: vi.fn(() => ({ cmd: "sh", args: (c: string) => ["-c", c] })),
isWindows: vi.fn(() => false),
}));
Expand Down
30 changes: 23 additions & 7 deletions packages/plugins/workspace-worktree/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { join, resolve, basename, dirname, sep } from "node:path";
import { homedir } from "node:os";
import {
getGitExecutable,
getShell,
isWindows,
type PluginModule,
Expand All @@ -37,12 +38,27 @@ export const manifest = {

/** Run a git command in a given directory */
async function git(cwd: string, ...args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd,
windowsHide: true,
timeout: GIT_TIMEOUT,
});
return stdout.trimEnd();
const gitExecutable = getGitExecutable();
try {
const { stdout } = await execFileAsync(gitExecutable, args, {
cwd,
windowsHide: true,
timeout: GIT_TIMEOUT,
});
return stdout.trimEnd();
} catch (err) {
const code =
typeof err === "object" && err !== null && "code" in err
? String((err as { code?: unknown }).code)
: undefined;
if (code === "ENOENT") {
throw new Error(
`Git executable not found while setting up the worktree. Install Git or add it to PATH. Tried: ${gitExecutable}`,
{ cause: err },
);
}
throw err;
}
}

/**
Expand Down Expand Up @@ -525,7 +541,7 @@ export function create(config?: Record<string, unknown>): Workspace {
async exists(workspacePath: string): Promise<boolean> {
if (!existsSync(workspacePath)) return false;
try {
await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
await execFileAsync(getGitExecutable(), ["rev-parse", "--is-inside-work-tree"], {
cwd: workspacePath,
timeout: GIT_TIMEOUT,
windowsHide: true,
Expand Down
Loading