Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
46 changes: 45 additions & 1 deletion packages/core/src/__tests__/platform.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,48 @@
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 } from "vitest";
import { getGitExecutable, isWindows } from "../platform.js";

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

beforeEach(() => {
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(() => {
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.
});

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
52 changes: 39 additions & 13 deletions packages/core/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,30 +53,56 @@ 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 {
const pathGit = findOnPath("git");
if (pathGit) return 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 candidates.find((candidate) => existsSync(candidate)) ?? "git";
}
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
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