feat(coding-agent): fetch portable git bash on windows

This commit is contained in:
Armin Ronacher
2026-05-18 00:58:40 +02:00
parent ceacacaa45
commit 4275f98b3f
6 changed files with 287 additions and 37 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added automatic Portable Git Bash downloads on Windows when no bash shell is installed.
### Fixed
- Fixed npm-family package commands on Windows to avoid shell argument splitting when install prefixes contain spaces ([#4623](https://github.com/earendil-works/pi/issues/4623)).
+3 -2
View File
@@ -4,9 +4,10 @@ Pi requires a bash shell on Windows. Checked locations (in order):
1. Custom path from `~/.pi/agent/settings.json`
2. Git Bash (`C:\Program Files\Git\bin\bash.exe`)
3. `bash.exe` on PATH (Cygwin, MSYS2, WSL)
3. Managed Portable Git Bash (`~/.pi/agent/bin/portable-git/bin/bash.exe`)
4. `bash.exe` on PATH (Cygwin, MSYS2, WSL)
For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient.
If no bash is found, Pi downloads Git for Windows Portable Git into `~/.pi/agent/bin/portable-git`. Set `PI_OFFLINE=1` to disable automatic downloads.
## Custom Shell Path
+7 -7
View File
@@ -8,7 +8,7 @@ import { truncateToVisualLines } from "../../modes/interactive/components/visual
import { theme } from "../../modes/interactive/theme/theme.js";
import { waitForChildProcess } from "../../utils/child-process.js";
import {
getShellConfig,
ensureShellConfig,
getShellEnv,
killProcessTree,
trackDetachedChildPid,
@@ -64,13 +64,13 @@ export interface BashOperations {
*/
export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations {
return {
exec: (command, cwd, { onData, signal, timeout, env }) => {
exec: async (command, cwd, { onData, signal, timeout, env }) => {
if (!existsSync(cwd)) {
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
}
const { shell, args } = await ensureShellConfig(options?.shellPath, { silent: true });
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig(options?.shellPath);
if (!existsSync(cwd)) {
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
return;
}
const child = spawn(shell, [...args, command], {
cwd,
detached: process.platform !== "win32",
@@ -89,7 +89,7 @@ import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipb
import { parseGitUrl } from "../../utils/git.js";
import { getCwdRelativePath } from "../../utils/paths.js";
import { getPiUserAgent } from "../../utils/pi-user-agent.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js";
import { ensureWindowsBash, killTrackedDetachedChildren } from "../../utils/shell.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { checkForNewPiVersion } from "../../utils/version-check.js";
import { ArminComponent } from "./components/armin.js";
@@ -567,9 +567,13 @@ export class InteractiveMode {
// Load changelog (only show new entries, skip for resumed sessions)
this.changelogMarkdown = this.getChangelogForDisplay();
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
// Both are needed: fd for autocomplete, rg for grep tool and bash commands
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
// Ensure fd, rg, and Windows bash are available, downloading missing tools under getBinDir.
// fd is needed for autocomplete, rg for grep and bash commands, bash for shell execution on Windows.
const [fdPath] = await Promise.all([
ensureTool("fd"),
ensureTool("rg"),
ensureWindowsBash(this.settingsManager.getShellPath()),
]);
this.fdPath = fdPath;
if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
+77 -18
View File
@@ -2,12 +2,23 @@ import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { spawn, spawnSync } from "child_process";
import { getBinDir } from "../config.js";
import {
ensureWindowsPortableGitBash,
getManagedWindowsPortableGitBashCandidates,
getManagedWindowsPortableGitBashPath,
} from "./tools-manager.js";
export interface ShellConfig {
shell: string;
args: string[];
}
function isSystemBashLookupDisabled(): boolean {
const value = process.env.__PI_DISABLE_SYSTEM_BASH_LOOKUP;
if (!value) return false;
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
/**
* Find bash executable on PATH (cross-platform)
*/
@@ -47,7 +58,7 @@ function findBashOnPath(): string | null {
* Resolve shell configuration based on platform and an optional explicit shell path.
* Resolution order:
* 1. User-specified shellPath
* 2. On Windows: Git Bash in known locations, then bash on PATH
* 2. On Windows: Git Bash in known locations, managed Portable Git Bash, then bash on PATH
* 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh
*/
export function getShellConfig(customShellPath?: string): ShellConfig {
@@ -60,35 +71,51 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
}
if (process.platform === "win32") {
const skipSystemBashLookup = isSystemBashLookupDisabled();
// 2. Try Git Bash in known locations
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
if (!skipSystemBashLookup) {
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const path of paths) {
if (existsSync(path)) {
return { shell: path, args: ["-c"] };
for (const path of paths) {
if (existsSync(path)) {
return { shell: path, args: ["-c"] };
}
}
}
const managedPortableGitBash = getManagedWindowsPortableGitBashPath();
if (managedPortableGitBash) {
return { shell: managedPortableGitBash, args: ["-c"] };
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
if (!skipSystemBashLookup) {
const bashOnPath = findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
}
}
throw new Error(
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
" 3. Set shellPath in settings.json\n\n" +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
` 2. Let pi download Portable Git Bash automatically\n` +
` 3. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
" 4. Set shellPath in settings.json\n\n" +
(skipSystemBashLookup ? "System bash lookup disabled by __PI_DISABLE_SYSTEM_BASH_LOOKUP.\n" : "") +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}\n` +
`Searched managed Portable Git Bash in:\n${getManagedWindowsPortableGitBashCandidates()
.map((p) => ` ${p}`)
.join("\n")}`,
);
}
@@ -105,6 +132,38 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
return { shell: "sh", args: ["-c"] };
}
export async function ensureShellConfig(
customShellPath?: string,
options: { silent?: boolean } = {},
): Promise<ShellConfig> {
try {
return getShellConfig(customShellPath);
} catch (error) {
if (customShellPath || process.platform !== "win32") {
throw error;
}
const installedBash = await ensureWindowsPortableGitBash(options.silent ?? false);
if (installedBash) {
return { shell: installedBash, args: ["-c"] };
}
throw error;
}
}
export async function ensureWindowsBash(
customShellPath?: string,
silent: boolean = false,
): Promise<string | undefined> {
if (process.platform !== "win32") return undefined;
try {
const { shell } = await ensureShellConfig(customShellPath, { silent });
return shell;
} catch {
return undefined;
}
}
export function getShellEnv(): NodeJS.ProcessEnv {
const binDir = getBinDir();
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
@@ -1,13 +1,26 @@
import chalk from "chalk";
import { type SpawnSyncReturns, spawnSync } from "child_process";
import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs";
import { createHash } from "crypto";
import {
chmodSync,
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
} from "fs";
import { arch, platform } from "os";
import { join } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import type { ReadableStream as NodeReadableStream } from "stream/web";
import { APP_NAME, getBinDir } from "../config.js";
const TOOLS_DIR = getBinDir();
const PORTABLE_GIT_DIR = join(TOOLS_DIR, "portable-git");
const GIT_FOR_WINDOWS_REPO = "git-for-windows/git";
const NETWORK_TIMEOUT_MS = 10_000;
const DOWNLOAD_TIMEOUT_MS = 120_000;
@@ -17,6 +30,17 @@ function isOfflineModeEnabled(): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
digest?: string;
}
interface GitHubRelease {
tag_name: string;
assets: GitHubReleaseAsset[];
}
interface ToolConfig {
name: string;
repo: string; // GitHub repo (e.g., "sharkdp/fd")
@@ -103,8 +127,8 @@ export function getToolPath(tool: "fd" | "rg"): string | null {
return null;
}
// Fetch latest release version from GitHub
async function getLatestVersion(repo: string): Promise<string> {
// Fetch latest release metadata from GitHub
async function getLatestRelease(repo: string): Promise<GitHubRelease> {
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),
@@ -114,7 +138,16 @@ async function getLatestVersion(repo: string): Promise<string> {
throw new Error(`GitHub API error: ${response.status}`);
}
const data = (await response.json()) as { tag_name: string };
const data = (await response.json()) as { tag_name?: string; assets?: GitHubReleaseAsset[] };
if (!data.tag_name) {
throw new Error("GitHub API response did not include a release tag");
}
return { tag_name: data.tag_name, assets: data.assets ?? [] };
}
// Fetch latest release version from GitHub
async function getLatestVersion(repo: string): Promise<string> {
const data = await getLatestRelease(repo);
return data.tag_name.replace(/^v/, "");
}
@@ -133,7 +166,28 @@ async function downloadFile(url: string, dest: string): Promise<void> {
}
const fileStream = createWriteStream(dest);
await pipeline(Readable.fromWeb(response.body as any), fileStream);
await pipeline(Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>), fileStream);
}
async function calculateFileSha256(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
async function verifyGitHubAssetDigest(filePath: string, digest: string | undefined, assetName: string): Promise<void> {
if (!digest) return;
const [algorithm, expectedHash] = digest.split(":", 2);
if (algorithm !== "sha256" || !expectedHash) return;
const actualHash = await calculateFileSha256(filePath);
if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
throw new Error(`Checksum mismatch for ${assetName}`);
}
}
function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {
@@ -174,7 +228,7 @@ function formatSpawnFailure(result: SpawnSyncReturns<Buffer>): string {
}
function runExtractionCommand(command: string, args: string[]): string | null {
const result = spawnSync(command, args, { stdio: "pipe" });
const result = spawnSync(command, args, { stdio: "pipe", windowsHide: true });
if (!result.error && result.status === 0) {
return null;
}
@@ -237,6 +291,134 @@ function extractZipArchive(archivePath: string, extractDir: string, assetName: s
throw new Error(`Failed to extract ${assetName}: ${failures.join("; ")}`);
}
function getPortableGitBashCandidates(rootDir: string): string[] {
return [join(rootDir, "bin", "bash.exe"), join(rootDir, "usr", "bin", "bash.exe")];
}
export function getManagedWindowsPortableGitBashCandidates(): string[] {
return getPortableGitBashCandidates(PORTABLE_GIT_DIR);
}
function getPortableGitBashPath(rootDir: string): string | null {
return getPortableGitBashCandidates(rootDir).find((candidate) => existsSync(candidate)) ?? null;
}
export function getManagedWindowsPortableGitBashPath(): string | null {
if (platform() !== "win32") return null;
return getPortableGitBashPath(PORTABLE_GIT_DIR);
}
function selectPortableGitAsset(release: GitHubRelease, architecture: string): GitHubReleaseAsset {
let suffix: string;
switch (architecture) {
case "x64":
suffix = "-64-bit.7z.exe";
break;
case "arm64":
suffix = "-arm64.7z.exe";
break;
default:
throw new Error(`Unsupported Windows architecture: ${architecture}`);
}
const asset = release.assets.find((asset) => asset.name.startsWith("PortableGit-") && asset.name.endsWith(suffix));
if (!asset) {
throw new Error(`Portable Git release asset not found for ${architecture}`);
}
return asset;
}
function validatePortableGitBash(bashPath: string): void {
const result = spawnSync(bashPath, ["--version"], { stdio: "pipe" });
if (result.error || result.status !== 0) {
throw new Error(`Portable Git bash validation failed: ${formatSpawnFailure(result)}`);
}
}
async function downloadPortableGitBash(): Promise<string> {
if (platform() !== "win32") {
throw new Error("Portable Git Bash is only available on Windows");
}
const release = await getLatestRelease(GIT_FOR_WINDOWS_REPO);
const asset = selectPortableGitAsset(release, arch());
mkdirSync(TOOLS_DIR, { recursive: true });
const stagingRoot = join(
TOOLS_DIR,
`portable_git_tmp_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
);
const extractDir = join(stagingRoot, "extract");
const installerPath = join(stagingRoot, asset.name);
mkdirSync(extractDir, { recursive: true });
try {
await downloadFile(asset.browser_download_url, installerPath);
await verifyGitHubAssetDigest(installerPath, asset.digest, asset.name);
const failure = runExtractionCommand(installerPath, ["-y", `-o${extractDir}`]);
if (failure) {
throw new Error(`Failed to extract ${asset.name}: ${failure}`);
}
const extractedBash = getPortableGitBashPath(extractDir);
if (!extractedBash) {
throw new Error(`Portable Git Bash not found in ${asset.name}`);
}
validatePortableGitBash(extractedBash);
const existingPath = getManagedWindowsPortableGitBashPath();
if (existingPath) {
return existingPath;
}
if (existsSync(PORTABLE_GIT_DIR)) {
rmSync(PORTABLE_GIT_DIR, { recursive: true, force: true });
}
renameSync(extractDir, PORTABLE_GIT_DIR);
const installedBash = getManagedWindowsPortableGitBashPath();
if (!installedBash) {
throw new Error(`Portable Git Bash install did not create ${getManagedWindowsPortableGitBashCandidates()[0]}`);
}
validatePortableGitBash(installedBash);
return installedBash;
} finally {
rmSync(stagingRoot, { recursive: true, force: true });
}
}
export async function ensureWindowsPortableGitBash(silent: boolean = false): Promise<string | undefined> {
if (platform() !== "win32") return undefined;
const existingPath = getManagedWindowsPortableGitBashPath();
if (existingPath) return existingPath;
if (isOfflineModeEnabled()) {
if (!silent) {
console.log(chalk.yellow("bash not found. Offline mode enabled, skipping Portable Git download."));
}
return undefined;
}
if (!silent) {
console.log(chalk.dim("bash not found. Downloading Portable Git..."));
}
try {
const path = await downloadPortableGitBash();
if (!silent) {
console.log(chalk.dim(`Portable Git Bash installed to ${path}`));
}
return path;
} catch (e) {
if (!silent) {
console.log(chalk.yellow(`Failed to download Portable Git Bash: ${e instanceof Error ? e.message : e}`));
}
return undefined;
}
}
// Download and install a tool
async function downloadTool(tool: "fd" | "rg"): Promise<string> {
const config = TOOLS[tool];