From 03a6a6bc4f0050102274d03500f81cb413f97cc6 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 5 May 2026 04:51:36 -0300 Subject: [PATCH 1/3] wsl: prefer Linux-side git/gh over Windows interop binaries When Warp runs as a Linux ELF inside WSL, WSL's default appendWindowsPath=true puts directories like /mnt/c/Program Files/Git/cmd/ on PATH. That makes a bare Command::new("git") resolve to Windows git.exe through interop, which is dramatically slower (cross-OS calls), can mishandle Linux paths, and can break Linux-side hooks (e.g. LFS). Add warp_util::wsl with cached is_wsl(), git_binary(), and gh_binary() helpers. On WSL, the helpers walk PATH, skipping any entry under /mnt/, and return the first executable Linux-side match; everywhere else they return the literal program name. The /mnt/* filter mirrors the existing precedent in wsl_command_executor.rs:60-65 used for compgen. Rewire every Warp-internal git/gh subprocess invocation through the helpers, including production callers (diff stats, code review, branch detection, file search, AI skill resolution, passive suggestions) and the test/debug helpers, and dedupe two existing /proc/sys/fs/binfmt_misc/WSLInterop checks while we're here. Tracks #8410. --- Cargo.lock | 4 + app/src/ai/agent_sdk/driver/snapshot.rs | 2 +- app/src/ai/agent_sdk/driver/snapshot_tests.rs | 4 +- .../blocklist/passive_suggestions/legacy.rs | 2 +- app/src/ai/skills/resolve_skill_spec.rs | 4 +- app/src/crash_reporting/linux.rs | 6 +- app/src/integration_testing/agent_mode/mod.rs | 6 +- app/src/search/files/model.rs | 2 +- app/src/util/git.rs | 6 +- app/src/util/git_tests.rs | 2 +- crates/integration/Cargo.toml | 1 + crates/integration/src/test/code_review.rs | 2 +- crates/warp_util/Cargo.toml | 2 + crates/warp_util/src/lib.rs | 1 + crates/warp_util/src/wsl.rs | 103 ++++++++++++ crates/warp_util/src/wsl_tests.rs | 146 ++++++++++++++++++ crates/warpui/Cargo.toml | 1 + crates/warpui/src/platform/linux/mod.rs | 6 +- 18 files changed, 275 insertions(+), 25 deletions(-) create mode 100644 crates/warp_util/src/wsl.rs create mode 100644 crates/warp_util/src/wsl_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 351ab370c..d11a44898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6635,6 +6635,7 @@ dependencies = [ "warp_cli", "warp_core", "warp_multi_agent_api", + "warp_util", "warpui", "warpui_extras", "whoami", @@ -14871,6 +14872,7 @@ dependencies = [ "gloo", "hex", "lazy_static", + "log", "mime_guess", "pathdiff", "pin-project", @@ -14878,6 +14880,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "thiserror 2.0.17", "typed-path 0.10.0", "warpui", @@ -14971,6 +14974,7 @@ dependencies = [ "vec1", "version-compare", "virtual-fs", + "warp_util", "warpui", "warpui_core", "wasm-bindgen", diff --git a/app/src/ai/agent_sdk/driver/snapshot.rs b/app/src/ai/agent_sdk/driver/snapshot.rs index 602621d45..2d9c0cfa9 100644 --- a/app/src/ai/agent_sdk/driver/snapshot.rs +++ b/app/src/ai/agent_sdk/driver/snapshot.rs @@ -1204,7 +1204,7 @@ where .into_iter() .map(|arg| arg.as_ref().to_os_string()) .collect::>(); - let mut command = Command::new("git"); + let mut command = Command::new(warp_util::wsl::git_binary()); command .args(&args) .current_dir(repo_dir) diff --git a/app/src/ai/agent_sdk/driver/snapshot_tests.rs b/app/src/ai/agent_sdk/driver/snapshot_tests.rs index 322fb6b44..2b36f0132 100644 --- a/app/src/ai/agent_sdk/driver/snapshot_tests.rs +++ b/app/src/ai/agent_sdk/driver/snapshot_tests.rs @@ -151,7 +151,7 @@ impl HarnessSupportClient for TestClient { /// (uncommitted edit) when `dirty` is true so `git diff --binary HEAD` has something to emit. fn init_git_repo(dir: &Path, dirty: bool) { let run = |args: &[&str]| { - let output = BlockingCommand::new("git") + let output = BlockingCommand::new(warp_util::wsl::git_binary()) .current_dir(dir) .args(args) .output() @@ -174,7 +174,7 @@ fn init_git_repo(dir: &Path, dirty: bool) { } fn git_stdout(dir: &Path, args: &[&str]) -> String { - let output = BlockingCommand::new("git") + let output = BlockingCommand::new(warp_util::wsl::git_binary()) .current_dir(dir) .args(args) .output() diff --git a/app/src/ai/blocklist/passive_suggestions/legacy.rs b/app/src/ai/blocklist/passive_suggestions/legacy.rs index 954fb5588..b6c1bee8e 100644 --- a/app/src/ai/blocklist/passive_suggestions/legacy.rs +++ b/app/src/ai/blocklist/passive_suggestions/legacy.rs @@ -329,7 +329,7 @@ impl PassiveSuggestionsModel { self.unit_test_generation_future_handle = Some(ctx.spawn( async move { - let output = Command::new("git") + let output = Command::new(warp_util::wsl::git_binary()) .args(["show", "HEAD"]) .current_dir(current_dir) .stdout(Stdio::piped()) diff --git a/app/src/ai/skills/resolve_skill_spec.rs b/app/src/ai/skills/resolve_skill_spec.rs index b8b063dfe..d4bd3edaf 100644 --- a/app/src/ai/skills/resolve_skill_spec.rs +++ b/app/src/ai/skills/resolve_skill_spec.rs @@ -177,7 +177,7 @@ pub async fn clone_repo_for_skill( target_dir.display() ); - let output = AsyncCommand::new("git") + let output = AsyncCommand::new(warp_util::wsl::git_binary()) .arg("clone") .arg(&repo_url) .arg(&target_dir) @@ -529,7 +529,7 @@ fn get_git_remote_org(repo_path: &Path) -> Option { log::debug!( "[GIT OPERATION] resolve_skill_spec.rs get_git_remote_org git remote get-url origin" ); - let output = Command::new("git") + let output = Command::new(warp_util::wsl::git_binary()) .args(["remote", "get-url", "origin"]) .current_dir(repo_path) .output() diff --git a/app/src/crash_reporting/linux.rs b/app/src/crash_reporting/linux.rs index 7dca4568d..c24c794ed 100644 --- a/app/src/crash_reporting/linux.rs +++ b/app/src/crash_reporting/linux.rs @@ -16,11 +16,7 @@ pub fn get_virtualized_environment() -> Option { } }; - // Test specifically for WSL based on existence of a particular file under - // /proc. - // - // See: https://superuser.com/questions/1749781/how-can-i-check-if-the-environment-is-wsl-from-a-shell-script - if std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() { + if warp_util::wsl::is_wsl() { return Some(VirtualEnvironment { name: "wsl".to_owned(), }); diff --git a/app/src/integration_testing/agent_mode/mod.rs b/app/src/integration_testing/agent_mode/mod.rs index e9c98012a..19e3e83a8 100644 --- a/app/src/integration_testing/agent_mode/mod.rs +++ b/app/src/integration_testing/agent_mode/mod.rs @@ -44,14 +44,14 @@ pub fn output_code_diff_with_base_commit( log::debug!( "[GIT OPERATION] mod.rs output_code_diff_with_base_commit git checkout {base_commit} -- {test_files_str}" ); - let _ = Command::new("git") + let _ = Command::new(warp_util::wsl::git_binary()) .args(["checkout", base_commit, "--", test_files_str]) .current_dir(working_dir) .output(); log::debug!( "[GIT OPERATION] mod.rs output_code_diff_with_base_commit git --no-pager diff {base_commit}" ); - let git_diff_output = Command::new("git") + let git_diff_output = Command::new(warp_util::wsl::git_binary()) .args(["--no-pager", "diff", base_commit]) .current_dir(working_dir) .output(); @@ -104,7 +104,7 @@ pub fn output_code_diff_debug_info(app: &mut App, window_id: WindowId) { log::debug!( "[GIT OPERATION] mod.rs output_code_diff_debug_info git diff -- {file_name}" ); - let output = Command::new("git") + let output = Command::new(warp_util::wsl::git_binary()) .args(["diff", "--", &file_name]) .current_dir(¤t_dir) .output(); diff --git a/app/src/search/files/model.rs b/app/src/search/files/model.rs index 6d6491692..532e108f7 100644 --- a/app/src/search/files/model.rs +++ b/app/src/search/files/model.rs @@ -505,7 +505,7 @@ impl FileSearchModel { log::debug!("[GIT OPERATION] model.rs get_git_changed_files git status --porcelain"); // Run `git status --porcelain` to get changed files - let output = Command::new("git") + let output = Command::new(warp_util::wsl::git_binary()) .args(["status", "--porcelain"]) .current_dir(repo_path) .output()?; diff --git a/app/src/util/git.rs b/app/src/util/git.rs index 07b2c4770..9d58c8404 100644 --- a/app/src/util/git.rs +++ b/app/src/util/git.rs @@ -30,7 +30,7 @@ pub async fn run_git_command_with_env( "[GIT OPERATION] git.rs run_git_command git {}", args.join(" ") ); - let mut cmd = Command::new("git"); + let mut cmd = Command::new(warp_util::wsl::git_binary()); cmd.arg("-c") .arg("diff.autoRefreshIndex=false") .args(args) @@ -81,7 +81,7 @@ pub async fn run_git_command_with_env( /// Returns an empty set on any failure (not a git repo, git not found, etc.). #[cfg(feature = "local_fs")] pub fn list_local_branches_sync(repo_path: &Path) -> HashSet { - let output = command::blocking::Command::new("git") + let output = command::blocking::Command::new(warp_util::wsl::git_binary()) .args(["branch", "--list", "--format=%(refname:short)"]) .current_dir(repo_path) .stdout(command::Stdio::piped()) @@ -761,7 +761,7 @@ async fn run_gh_command(repo_path: &Path, args: &[&str], path_env: Option<&str>) args.join(" ") ); - let mut cmd = Command::new("gh"); + let mut cmd = Command::new(warp_util::wsl::gh_binary()); cmd.args(args) .current_dir(repo_path) .stdin(Stdio::null()) diff --git a/app/src/util/git_tests.rs b/app/src/util/git_tests.rs index 0610a6b5f..2acf678b7 100644 --- a/app/src/util/git_tests.rs +++ b/app/src/util/git_tests.rs @@ -8,7 +8,7 @@ use super::{detect_current_branch, detect_current_branch_display}; /// Helper: run a git command inside the given repo directory. async fn git(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") + let output = Command::new(warp_util::wsl::git_binary()) .args(args) .current_dir(repo) .stdout(Stdio::piped()) diff --git a/crates/integration/Cargo.toml b/crates/integration/Cargo.toml index c20891e60..f09fe5553 100644 --- a/crates/integration/Cargo.toml +++ b/crates/integration/Cargo.toml @@ -41,6 +41,7 @@ version-compare.workspace = true warp = { workspace = true, features = ["integration_tests"] } warp_cli = { workspace = true, features = ["integration_tests"] } warp_core.workspace = true +warp_util.workspace = true warp-command-signatures.workspace = true warp_multi_agent_api.workspace = true warp-workflows.workspace = true diff --git a/crates/integration/src/test/code_review.rs b/crates/integration/src/test/code_review.rs index dbc028d8a..48060097f 100644 --- a/crates/integration/src/test/code_review.rs +++ b/crates/integration/src/test/code_review.rs @@ -80,7 +80,7 @@ fn insert_lines(path: &Path, before_line_number: usize, new_lines: &[String]) { } fn run_git(test_dir: &Path, args: &[&str]) { - let status = Command::new("git") + let status = Command::new(warp_util::wsl::git_binary()) .args(args) .current_dir(test_dir) .status() diff --git a/crates/warp_util/Cargo.toml b/crates/warp_util/Cargo.toml index 9996cff86..9e07fb51b 100644 --- a/crates/warp_util/Cargo.toml +++ b/crates/warp_util/Cargo.toml @@ -16,6 +16,7 @@ rand.workspace = true dirs.workspace = true hex.workspace = true lazy_static.workspace = true +log.workspace = true mime_guess.workspace = true regex.workspace = true thiserror.workspace = true @@ -33,4 +34,5 @@ windows.workspace = true gloo.workspace = true [dev-dependencies] +tempfile = "3.8.0" warpui.workspace = true diff --git a/crates/warp_util/src/lib.rs b/crates/warp_util/src/lib.rs index 49fd688c6..122785a8c 100644 --- a/crates/warp_util/src/lib.rs +++ b/crates/warp_util/src/lib.rs @@ -12,6 +12,7 @@ pub mod path; pub mod standardized_path; pub mod user_input; pub mod worktree_names; +pub mod wsl; #[cfg(windows)] pub mod windows; diff --git a/crates/warp_util/src/wsl.rs b/crates/warp_util/src/wsl.rs new file mode 100644 index 000000000..6dd2f0963 --- /dev/null +++ b/crates/warp_util/src/wsl.rs @@ -0,0 +1,103 @@ +//! WSL detection and binary resolution for Warp-internal subprocesses. +//! +//! Warp ships as a Linux ELF binary that users routinely run inside +//! WSL. WSL's default `appendWindowsPath = true` (in `/etc/wsl.conf`) +//! puts directories like `/mnt/c/Program Files/Git/cmd/` on `PATH`, +//! so a bare `Command::new("git")` can resolve to Windows `git.exe` +//! through WSL interop. That path works for some commands but is +//! dramatically slower, can mishandle Linux paths, and breaks +//! Linux-side hooks. +//! +//! [`git_binary`] and [`gh_binary`] return an absolute Linux-side +//! path when running inside WSL, falling back to the literal program +//! name everywhere else. The same `/mnt/*` filtering precedent is +//! used for `compgen` in +//! `app/src/terminal/model/session/command_executor/wsl_command_executor.rs`. +//! +//! Resolution is cached for the life of the process; PATH is +//! effectively static for the Warp host process. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +#[cfg(test)] +#[path = "wsl_tests.rs"] +mod tests; + +/// True when running inside a WSL guest. Cached for the life of the +/// process. Detection probes `/proc/sys/fs/binfmt_misc/WSLInterop`, +/// matching the existing checks in `crates/warpui/src/platform/linux/` +/// and `app/src/crash_reporting/linux.rs`. +pub fn is_wsl() -> bool { + static IS_WSL: OnceLock = OnceLock::new(); + *IS_WSL.get_or_init(|| Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists()) +} + +/// Program name to pass to `Command::new` for invoking `git` from +/// Warp-internal code. See module docs for the WSL behavior. +pub fn git_binary() -> &'static OsStr { + static GIT_BIN: OnceLock = OnceLock::new(); + GIT_BIN.get_or_init(|| resolve_or_warn("git")) +} + +/// Program name to pass to `Command::new` for invoking `gh`. +pub fn gh_binary() -> &'static OsStr { + static GH_BIN: OnceLock = OnceLock::new(); + GH_BIN.get_or_init(|| resolve_or_warn("gh")) +} + +fn resolve_or_warn(name: &str) -> OsString { + let path_env = std::env::var_os("PATH"); + match resolve_binary_in_wsl_safe_path(name, path_env.as_deref(), is_wsl()) { + Some(p) => p.into_os_string(), + None => { + if is_wsl() { + log::warn!( + "wsl: no Linux-side `{name}` found on PATH (excluding /mnt/*); \ + falling back to bare `{name}` which may resolve to a Windows .exe" + ); + } + OsString::from(name) + } + } +} + +/// Returns the first executable named `name` on `path_env`, skipping +/// any PATH entry under `/mnt/` when `is_wsl` is true. Returns `None` +/// if no acceptable match exists. Pure — exposed for unit testing +/// without depending on a real WSL host. +pub fn resolve_binary_in_wsl_safe_path( + name: &str, + path_env: Option<&OsStr>, + is_wsl: bool, +) -> Option { + let path_env = path_env?; + for dir in std::env::split_paths(path_env) { + if is_wsl && dir.starts_with("/mnt") { + continue; + } + let candidate = dir.join(name); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + None +} + +#[cfg(unix)] +fn is_executable_file(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + match std::fs::metadata(path) { + Ok(md) => md.is_file() && (md.permissions().mode() & 0o111 != 0), + Err(_) => false, + } +} + +#[cfg(not(unix))] +fn is_executable_file(_path: &Path) -> bool { + // The WSL-safe resolver only runs on Linux. Other targets short- + // circuit through `is_wsl() == false`, so this stub is unreachable + // in practice — present only to keep the crate compiling. + false +} diff --git a/crates/warp_util/src/wsl_tests.rs b/crates/warp_util/src/wsl_tests.rs new file mode 100644 index 000000000..b32a83cc9 --- /dev/null +++ b/crates/warp_util/src/wsl_tests.rs @@ -0,0 +1,146 @@ +use std::ffi::OsString; +use std::fs; +use std::path::PathBuf; + +use super::resolve_binary_in_wsl_safe_path; + +#[cfg(unix)] +fn make_executable(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt as _; + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).unwrap(); +} + +#[cfg(unix)] +fn write_exec(path: &std::path::Path) { + fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap(); + make_executable(path); +} + +fn join(parts: &[PathBuf]) -> OsString { + std::env::join_paths(parts).unwrap() +} + +#[cfg(unix)] +#[test] +fn picks_first_linux_path_when_wsl() { + let linux_dir = tempfile::tempdir().unwrap(); + write_exec(&linux_dir.path().join("git")); + + // `/mnt/c/...` paths in the synthetic PATH that don't exist on + // disk simulate the WSL-with-Windows-git layout: the resolver + // should skip them on the prefix and never stat them. + let path_env = join(&[ + PathBuf::from("/mnt/c/Program Files/Git/cmd"), + linux_dir.path().to_path_buf(), + ]); + + let resolved = + resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap(); + assert_eq!(resolved, linux_dir.path().join("git")); + assert!(!resolved.starts_with("/mnt")); +} + +#[cfg(unix)] +#[test] +fn passes_through_first_match_when_not_wsl() { + // When not WSL, `/mnt/...` is just another directory; the resolver + // should pick the first dir on PATH that contains an exec match. + let mnt_dir = tempfile::tempdir().unwrap(); + write_exec(&mnt_dir.path().join("git")); + let other_dir = tempfile::tempdir().unwrap(); + write_exec(&other_dir.path().join("git")); + + let path_env = join(&[mnt_dir.path().to_path_buf(), other_dir.path().to_path_buf()]); + + let resolved = + resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), false).unwrap(); + assert_eq!(resolved, mnt_dir.path().join("git")); +} + +#[cfg(unix)] +#[test] +fn falls_back_to_none_when_only_mnt_has_git() { + let path_env = join(&[ + PathBuf::from("/mnt/c/Program Files/Git/cmd"), + PathBuf::from("/mnt/c/Windows/System32"), + ]); + assert!(resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).is_none()); +} + +#[cfg(unix)] +#[test] +fn picks_user_local_bin() { + let bin_dir = tempfile::tempdir().unwrap(); + let empty_dir = bin_dir.path().join("empty"); + fs::create_dir_all(&empty_dir).unwrap(); + let local_bin = bin_dir.path().join("home/.local/bin"); + fs::create_dir_all(&local_bin).unwrap(); + write_exec(&local_bin.join("git")); + + // PATH order: an empty dir, then a `/mnt/...` candidate that + // should be skipped on WSL, then the directory with the real + // exec. The resolver must walk past the first two and land on + // the third. + let path_env = join(&[ + empty_dir, + PathBuf::from("/mnt/c/Program Files/Git/cmd"), + local_bin.clone(), + ]); + let resolved = + resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap(); + assert_eq!(resolved, local_bin.join("git")); +} + +#[cfg(unix)] +#[test] +fn follows_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("git-wrapper"); + write_exec(&real); + let link = dir.path().join("git"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let path_env = join(&[dir.path().to_path_buf()]); + let resolved = + resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap(); + assert_eq!(resolved, link); +} + +#[cfg(unix)] +#[test] +fn skips_non_executable() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("git"), b"not exec").unwrap(); + // Intentionally do NOT chmod +x. + + let path_env = join(&[dir.path().to_path_buf()]); + assert!(resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).is_none()); +} + +#[test] +fn handles_empty_path_env() { + assert!(resolve_binary_in_wsl_safe_path("git", None, true).is_none()); + assert!(resolve_binary_in_wsl_safe_path("git", None, false).is_none()); +} + +#[cfg(unix)] +#[test] +fn handles_non_utf8_path_components() { + use std::os::unix::ffi::OsStringExt as _; + + let dir = tempfile::tempdir().unwrap(); + write_exec(&dir.path().join("git")); + + // Build a PATH whose first component is a non-UTF-8 byte sequence, + // followed by a real directory. The resolver must walk past the + // garbage entry without panicking and find the valid one. + let mut bytes = b"/\xff\xfe/bad:".to_vec(); + bytes.extend_from_slice(dir.path().as_os_str().as_encoded_bytes()); + let path_env = OsString::from_vec(bytes); + + let resolved = + resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap(); + assert_eq!(resolved, dir.path().join("git")); +} diff --git a/crates/warpui/Cargo.toml b/crates/warpui/Cargo.toml index 81d36a6e6..f95472e11 100644 --- a/crates/warpui/Cargo.toml +++ b/crates/warpui/Cargo.toml @@ -50,6 +50,7 @@ takecell = "0.1.1" thiserror.workspace = true vec1.workspace = true version-compare = { workspace = true, optional = true } +warp_util.workspace = true warpui_core.workspace = true wgpu = { workspace = true, optional = true } diff --git a/crates/warpui/src/platform/linux/mod.rs b/crates/warpui/src/platform/linux/mod.rs index c89429989..8ecab178b 100644 --- a/crates/warpui/src/platform/linux/mod.rs +++ b/crates/warpui/src/platform/linux/mod.rs @@ -56,11 +56,7 @@ pub fn user_windowing_system() -> WindowingSystem { } pub fn is_wsl() -> bool { - use std::sync::OnceLock; - static IS_WSL: OnceLock = OnceLock::new(); - IS_WSL - .get_or_init(|| std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists()) - .to_owned() + warp_util::wsl::is_wsl() } pub fn is_wayland_env_var_set() -> bool { From fce8b74604d0cd91723bcb94271cc6e6f41853fd Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 5 May 2026 05:10:47 -0300 Subject: [PATCH 2/3] wsl: short-circuit binary resolution when not in WSL Per Oz review feedback (#10137): the helper resolved and cached an absolute path to git/gh on every host, not just on WSL. That froze the binary path at module init and silently ignored call-site `cmd.env("PATH", ...)` overrides used to expose user-installed hooks (e.g. `run_git_command_with_env` for LFS `git-lfs`, `run_gh_command` for Homebrew gh on macOS). Return the bare program name immediately when `is_wsl()` is false so `Command::new` performs its normal PATH lookup at spawn time, matching the original contract advertised in the module docs. --- crates/warp_util/src/wsl.rs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/warp_util/src/wsl.rs b/crates/warp_util/src/wsl.rs index 6dd2f0963..c5ecefeef 100644 --- a/crates/warp_util/src/wsl.rs +++ b/crates/warp_util/src/wsl.rs @@ -9,9 +9,11 @@ //! Linux-side hooks. //! //! [`git_binary`] and [`gh_binary`] return an absolute Linux-side -//! path when running inside WSL, falling back to the literal program -//! name everywhere else. The same `/mnt/*` filtering precedent is -//! used for `compgen` in +//! path when running inside WSL, and the literal program name on +//! every other host so `Command::new` performs its normal PATH +//! lookup at spawn time (preserving call-site `cmd.env("PATH", …)` +//! overrides). The same `/mnt/*` filtering precedent is used for +//! `compgen` in //! `app/src/terminal/model/session/command_executor/wsl_command_executor.rs`. //! //! Resolution is cached for the life of the process; PATH is @@ -48,16 +50,23 @@ pub fn gh_binary() -> &'static OsStr { } fn resolve_or_warn(name: &str) -> OsString { + // Outside WSL, return the bare program name so each `Command::new` + // performs the OS's normal PATH lookup at spawn time. Resolving up + // front would freeze the binary path at module init and silently + // ignore call-site `cmd.env("PATH", ...)` overrides used to expose + // user-installed hooks (see `run_git_command_with_env` and + // `run_gh_command` in `app/src/util/git.rs`). + if !is_wsl() { + return OsString::from(name); + } let path_env = std::env::var_os("PATH"); - match resolve_binary_in_wsl_safe_path(name, path_env.as_deref(), is_wsl()) { + match resolve_binary_in_wsl_safe_path(name, path_env.as_deref(), true) { Some(p) => p.into_os_string(), None => { - if is_wsl() { - log::warn!( - "wsl: no Linux-side `{name}` found on PATH (excluding /mnt/*); \ - falling back to bare `{name}` which may resolve to a Windows .exe" - ); - } + log::warn!( + "wsl: no Linux-side `{name}` found on PATH (excluding /mnt/*); \ + falling back to bare `{name}` which may resolve to a Windows .exe" + ); OsString::from(name) } } From a811077e66e1d73a2f286d356ca09e09e05e6e86 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 5 May 2026 12:42:16 -0300 Subject: [PATCH 3/3] wsl: move resolution into command crate, hide it from call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @vorporeal review (#10137): - Folding the helper into `command` so `Command::new("git")` / `BlockingCommand::new("git")` transparently substitute the Linux-side binary on WSL — no per-callsite remembering. - The dependency edge `warpui -> warp_util` was wrong (warp_* crates should sit above the UI framework). `command` is already a warpui dep on non-macOS, and a much better home for both `is_wsl` and the resolution logic. Add `command::wsl` with cached `is_wsl()`, the pure `resolve_binary_in_wsl_safe_path` resolver, and an internal `translate_program_for_spawn` invoked from the wrappers' `new`, `new_with_session`, and `new_with_process_group` constructors. Only bare-name programs in `KNOWN_NAMES = ["git", "gh"]` are touched — path-qualified or unknown programs pass through unchanged. All previous `warp_util::wsl::git_binary()` / `gh_binary()` call sites revert to bare `Command::new("git" | "gh")`, and the `warp_util::wsl` module + its `warpui` / `integration` Cargo edges are removed. `warpui::platform::linux::is_wsl` and `app::crash_reporting::linux` now delegate to `command::wsl::is_wsl`. 11 unit tests cover the resolver and the bare-name detector. --- Cargo.lock | 5 +- app/src/ai/agent_sdk/driver/snapshot.rs | 2 +- app/src/ai/agent_sdk/driver/snapshot_tests.rs | 4 +- .../blocklist/passive_suggestions/legacy.rs | 2 +- app/src/ai/skills/resolve_skill_spec.rs | 4 +- app/src/crash_reporting/linux.rs | 2 +- app/src/integration_testing/agent_mode/mod.rs | 6 +- app/src/search/files/model.rs | 2 +- app/src/util/git.rs | 6 +- app/src/util/git_tests.rs | 2 +- crates/command/Cargo.toml | 5 +- crates/command/src/async.rs | 3 + crates/command/src/blocking.rs | 1 + crates/command/src/lib.rs | 1 + crates/command/src/wsl.rs | 128 ++++++++++++++++++ .../{warp_util => command}/src/wsl_tests.rs | 26 +++- crates/integration/Cargo.toml | 1 - crates/integration/src/test/code_review.rs | 2 +- crates/warp_util/Cargo.toml | 2 - crates/warp_util/src/lib.rs | 1 - crates/warp_util/src/wsl.rs | 112 --------------- crates/warpui/Cargo.toml | 1 - crates/warpui/src/platform/linux/mod.rs | 2 +- 23 files changed, 179 insertions(+), 141 deletions(-) create mode 100644 crates/command/src/wsl.rs rename crates/{warp_util => command}/src/wsl_tests.rs (85%) delete mode 100644 crates/warp_util/src/wsl.rs diff --git a/Cargo.lock b/Cargo.lock index d11a44898..f551101ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2941,6 +2941,7 @@ dependencies = [ "lazy_static", "libc", "log", + "tempfile", "thiserror 2.0.17", "win32job", "windows 0.62.2", @@ -6635,7 +6636,6 @@ dependencies = [ "warp_cli", "warp_core", "warp_multi_agent_api", - "warp_util", "warpui", "warpui_extras", "whoami", @@ -14872,7 +14872,6 @@ dependencies = [ "gloo", "hex", "lazy_static", - "log", "mime_guess", "pathdiff", "pin-project", @@ -14880,7 +14879,6 @@ dependencies = [ "regex", "serde", "serde_json", - "tempfile", "thiserror 2.0.17", "typed-path 0.10.0", "warpui", @@ -14974,7 +14972,6 @@ dependencies = [ "vec1", "version-compare", "virtual-fs", - "warp_util", "warpui", "warpui_core", "wasm-bindgen", diff --git a/app/src/ai/agent_sdk/driver/snapshot.rs b/app/src/ai/agent_sdk/driver/snapshot.rs index 2d9c0cfa9..602621d45 100644 --- a/app/src/ai/agent_sdk/driver/snapshot.rs +++ b/app/src/ai/agent_sdk/driver/snapshot.rs @@ -1204,7 +1204,7 @@ where .into_iter() .map(|arg| arg.as_ref().to_os_string()) .collect::>(); - let mut command = Command::new(warp_util::wsl::git_binary()); + let mut command = Command::new("git"); command .args(&args) .current_dir(repo_dir) diff --git a/app/src/ai/agent_sdk/driver/snapshot_tests.rs b/app/src/ai/agent_sdk/driver/snapshot_tests.rs index 2b36f0132..322fb6b44 100644 --- a/app/src/ai/agent_sdk/driver/snapshot_tests.rs +++ b/app/src/ai/agent_sdk/driver/snapshot_tests.rs @@ -151,7 +151,7 @@ impl HarnessSupportClient for TestClient { /// (uncommitted edit) when `dirty` is true so `git diff --binary HEAD` has something to emit. fn init_git_repo(dir: &Path, dirty: bool) { let run = |args: &[&str]| { - let output = BlockingCommand::new(warp_util::wsl::git_binary()) + let output = BlockingCommand::new("git") .current_dir(dir) .args(args) .output() @@ -174,7 +174,7 @@ fn init_git_repo(dir: &Path, dirty: bool) { } fn git_stdout(dir: &Path, args: &[&str]) -> String { - let output = BlockingCommand::new(warp_util::wsl::git_binary()) + let output = BlockingCommand::new("git") .current_dir(dir) .args(args) .output() diff --git a/app/src/ai/blocklist/passive_suggestions/legacy.rs b/app/src/ai/blocklist/passive_suggestions/legacy.rs index b6c1bee8e..954fb5588 100644 --- a/app/src/ai/blocklist/passive_suggestions/legacy.rs +++ b/app/src/ai/blocklist/passive_suggestions/legacy.rs @@ -329,7 +329,7 @@ impl PassiveSuggestionsModel { self.unit_test_generation_future_handle = Some(ctx.spawn( async move { - let output = Command::new(warp_util::wsl::git_binary()) + let output = Command::new("git") .args(["show", "HEAD"]) .current_dir(current_dir) .stdout(Stdio::piped()) diff --git a/app/src/ai/skills/resolve_skill_spec.rs b/app/src/ai/skills/resolve_skill_spec.rs index d4bd3edaf..b8b063dfe 100644 --- a/app/src/ai/skills/resolve_skill_spec.rs +++ b/app/src/ai/skills/resolve_skill_spec.rs @@ -177,7 +177,7 @@ pub async fn clone_repo_for_skill( target_dir.display() ); - let output = AsyncCommand::new(warp_util::wsl::git_binary()) + let output = AsyncCommand::new("git") .arg("clone") .arg(&repo_url) .arg(&target_dir) @@ -529,7 +529,7 @@ fn get_git_remote_org(repo_path: &Path) -> Option { log::debug!( "[GIT OPERATION] resolve_skill_spec.rs get_git_remote_org git remote get-url origin" ); - let output = Command::new(warp_util::wsl::git_binary()) + let output = Command::new("git") .args(["remote", "get-url", "origin"]) .current_dir(repo_path) .output() diff --git a/app/src/crash_reporting/linux.rs b/app/src/crash_reporting/linux.rs index c24c794ed..c3787ec94 100644 --- a/app/src/crash_reporting/linux.rs +++ b/app/src/crash_reporting/linux.rs @@ -16,7 +16,7 @@ pub fn get_virtualized_environment() -> Option { } }; - if warp_util::wsl::is_wsl() { + if command::wsl::is_wsl() { return Some(VirtualEnvironment { name: "wsl".to_owned(), }); diff --git a/app/src/integration_testing/agent_mode/mod.rs b/app/src/integration_testing/agent_mode/mod.rs index 19e3e83a8..e9c98012a 100644 --- a/app/src/integration_testing/agent_mode/mod.rs +++ b/app/src/integration_testing/agent_mode/mod.rs @@ -44,14 +44,14 @@ pub fn output_code_diff_with_base_commit( log::debug!( "[GIT OPERATION] mod.rs output_code_diff_with_base_commit git checkout {base_commit} -- {test_files_str}" ); - let _ = Command::new(warp_util::wsl::git_binary()) + let _ = Command::new("git") .args(["checkout", base_commit, "--", test_files_str]) .current_dir(working_dir) .output(); log::debug!( "[GIT OPERATION] mod.rs output_code_diff_with_base_commit git --no-pager diff {base_commit}" ); - let git_diff_output = Command::new(warp_util::wsl::git_binary()) + let git_diff_output = Command::new("git") .args(["--no-pager", "diff", base_commit]) .current_dir(working_dir) .output(); @@ -104,7 +104,7 @@ pub fn output_code_diff_debug_info(app: &mut App, window_id: WindowId) { log::debug!( "[GIT OPERATION] mod.rs output_code_diff_debug_info git diff -- {file_name}" ); - let output = Command::new(warp_util::wsl::git_binary()) + let output = Command::new("git") .args(["diff", "--", &file_name]) .current_dir(¤t_dir) .output(); diff --git a/app/src/search/files/model.rs b/app/src/search/files/model.rs index 532e108f7..6d6491692 100644 --- a/app/src/search/files/model.rs +++ b/app/src/search/files/model.rs @@ -505,7 +505,7 @@ impl FileSearchModel { log::debug!("[GIT OPERATION] model.rs get_git_changed_files git status --porcelain"); // Run `git status --porcelain` to get changed files - let output = Command::new(warp_util::wsl::git_binary()) + let output = Command::new("git") .args(["status", "--porcelain"]) .current_dir(repo_path) .output()?; diff --git a/app/src/util/git.rs b/app/src/util/git.rs index 9d58c8404..07b2c4770 100644 --- a/app/src/util/git.rs +++ b/app/src/util/git.rs @@ -30,7 +30,7 @@ pub async fn run_git_command_with_env( "[GIT OPERATION] git.rs run_git_command git {}", args.join(" ") ); - let mut cmd = Command::new(warp_util::wsl::git_binary()); + let mut cmd = Command::new("git"); cmd.arg("-c") .arg("diff.autoRefreshIndex=false") .args(args) @@ -81,7 +81,7 @@ pub async fn run_git_command_with_env( /// Returns an empty set on any failure (not a git repo, git not found, etc.). #[cfg(feature = "local_fs")] pub fn list_local_branches_sync(repo_path: &Path) -> HashSet { - let output = command::blocking::Command::new(warp_util::wsl::git_binary()) + let output = command::blocking::Command::new("git") .args(["branch", "--list", "--format=%(refname:short)"]) .current_dir(repo_path) .stdout(command::Stdio::piped()) @@ -761,7 +761,7 @@ async fn run_gh_command(repo_path: &Path, args: &[&str], path_env: Option<&str>) args.join(" ") ); - let mut cmd = Command::new(warp_util::wsl::gh_binary()); + let mut cmd = Command::new("gh"); cmd.args(args) .current_dir(repo_path) .stdin(Stdio::null()) diff --git a/app/src/util/git_tests.rs b/app/src/util/git_tests.rs index 2acf678b7..0610a6b5f 100644 --- a/app/src/util/git_tests.rs +++ b/app/src/util/git_tests.rs @@ -8,7 +8,7 @@ use super::{detect_current_branch, detect_current_branch_display}; /// Helper: run a git command inside the given repo directory. async fn git(repo: &Path, args: &[&str]) -> String { - let output = Command::new(warp_util::wsl::git_binary()) + let output = Command::new("git") .args(args) .current_dir(repo) .stdout(Stdio::piped()) diff --git a/crates/command/Cargo.toml b/crates/command/Cargo.toml index 5ba9a97aa..13dfb6769 100644 --- a/crates/command/Cargo.toml +++ b/crates/command/Cargo.toml @@ -9,6 +9,10 @@ license.workspace = true test-util = [] [dependencies] +log.workspace = true + +[dev-dependencies] +tempfile = "3.8.0" [target.'cfg(not(target_family = "wasm"))'.dependencies] async-process = { workspace = true } @@ -17,7 +21,6 @@ futures-lite.workspace = true [target.'cfg(windows)'.dependencies] anyhow.workspace = true lazy_static.workspace = true -log.workspace = true thiserror.workspace = true win32job = "2.0.2" windows.workspace = true diff --git a/crates/command/src/async.rs b/crates/command/src/async.rs index c2c1ebd89..9557cfb06 100644 --- a/crates/command/src/async.rs +++ b/crates/command/src/async.rs @@ -39,6 +39,7 @@ impl Command { /// let mut cmd = Command::new("ls"); /// ``` pub fn new>(program: S) -> Command { + let program = crate::wsl::translate_program_for_spawn(program.as_ref()); let inner = async_process::Command::new(program); Self::new_internal(inner) } @@ -51,6 +52,7 @@ impl Command { /// See [`setsid(2)`](https://man7.org/linux/man-pages/man2/setsid.2.html). #[cfg(unix)] pub fn new_with_session>(program: S) -> Command { + let program = crate::wsl::translate_program_for_spawn(program.as_ref()); let mut command = std::process::Command::new(program); // SAFETY: `pre_exec` requires the closure to be async-signal-safe. @@ -77,6 +79,7 @@ impl Command { /// This allows for killing any other processes spawned by this process /// when we kill this process. pub fn new_with_process_group>(program: S) -> Command { + let program = crate::wsl::translate_program_for_spawn(program.as_ref()); #[allow(unused_mut)] let mut command = std::process::Command::new(program); diff --git a/crates/command/src/blocking.rs b/crates/command/src/blocking.rs index 521a1519c..a04643417 100644 --- a/crates/command/src/blocking.rs +++ b/crates/command/src/blocking.rs @@ -66,6 +66,7 @@ impl Command { /// .expect("sh command failed to start"); /// ``` pub fn new>(program: S) -> Command { + let program = crate::wsl::translate_program_for_spawn(program.as_ref()); #[cfg_attr(not(windows), expect(unused_mut))] let mut inner = std::process::Command::new(program); diff --git a/crates/command/src/lib.rs b/crates/command/src/lib.rs index 16b9a921c..4d67ef36c 100644 --- a/crates/command/src/lib.rs +++ b/crates/command/src/lib.rs @@ -13,5 +13,6 @@ pub mod blocking; pub mod unix; #[cfg(windows)] pub mod windows; +pub mod wsl; pub use std::process::{ExitStatus, Output, Stdio}; diff --git a/crates/command/src/wsl.rs b/crates/command/src/wsl.rs new file mode 100644 index 000000000..aea1f6ce9 --- /dev/null +++ b/crates/command/src/wsl.rs @@ -0,0 +1,128 @@ +//! WSL detection and Linux-side binary resolution for subprocess +//! invocations made through this crate's [`Command`](crate::r#async::Command) +//! and [`Command`](crate::blocking::Command) wrappers. +//! +//! Warp ships as a Linux ELF that users routinely run inside WSL. +//! WSL's default `appendWindowsPath = true` (in `/etc/wsl.conf`) +//! puts directories like `/mnt/c/Program Files/Git/cmd/` on `PATH`, +//! so a bare `Command::new("git")` can resolve to Windows `git.exe` +//! through WSL interop. That path is dramatically slower, can +//! mishandle Linux paths, and breaks Linux-side hooks. +//! +//! [`translate_program_for_spawn`] is invoked from the wrappers' +//! `new` constructors and transparently substitutes the program +//! string when (a) we're inside WSL and (b) the program is a bare +//! name in [`KNOWN_NAMES`]. Path-qualified or unknown programs are +//! passed through unchanged. Resolution is cached for the life of +//! the process — PATH is effectively static for the host process. +//! +//! The same `/mnt/*` filtering precedent is used for `compgen` in +//! `app/src/terminal/model/session/command_executor/wsl_command_executor.rs`. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +#[cfg(test)] +#[path = "wsl_tests.rs"] +mod tests; + +/// Bare program names whose resolution Warp wants to override under +/// WSL. Anything not in this list is passed through unchanged. +const KNOWN_NAMES: &[&str] = &["git", "gh"]; + +/// True when the current process is running inside a WSL guest. +/// Cached for the life of the process. +pub fn is_wsl() -> bool { + static IS_WSL: OnceLock = OnceLock::new(); + *IS_WSL.get_or_init(|| Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists()) +} + +/// Translate a `Command::new` program string. On WSL, bare names in +/// [`KNOWN_NAMES`] are resolved to the first executable on `PATH` +/// outside `/mnt/*`; everything else is returned unchanged so the OS +/// performs its normal lookup at spawn time. +pub(crate) fn translate_program_for_spawn(program: &OsStr) -> OsString { + if !is_wsl() { + return program.to_owned(); + } + let Some(name) = known_bare_name(program) else { + return program.to_owned(); + }; + cached_resolve(name).to_owned() +} + +/// Returns the program's name when it's a bare entry in +/// [`KNOWN_NAMES`]. Paths and absolute names are filtered out so a +/// caller that already specified `/usr/bin/git` is not touched. +fn known_bare_name(program: &OsStr) -> Option<&'static str> { + let s = program.to_str()?; + if s.contains('/') || s.contains('\\') { + return None; + } + KNOWN_NAMES.iter().copied().find(|&n| n == s) +} + +fn cached_resolve(name: &'static str) -> &'static OsString { + static GIT: OnceLock = OnceLock::new(); + static GH: OnceLock = OnceLock::new(); + let cell = match name { + "git" => &GIT, + "gh" => &GH, + // `KNOWN_NAMES` is exhaustive over the static cells above; if + // a new name is added there it must also be wired here. + _ => unreachable!("unknown name {name:?}"), + }; + cell.get_or_init(|| { + let path_env = std::env::var_os("PATH"); + match resolve_binary_in_wsl_safe_path(name, path_env.as_deref(), true) { + Some(p) => p.into_os_string(), + None => { + log::warn!( + "wsl: no Linux-side `{name}` found on PATH (excluding /mnt/*); \ + falling back to bare `{name}` which may resolve to a Windows .exe" + ); + OsString::from(name) + } + } + }) +} + +/// Returns the first executable named `name` on `path_env`, skipping +/// any PATH entry under `/mnt/` when `is_wsl` is true. Returns `None` +/// if no acceptable match exists. Pure — exposed for unit testing +/// without depending on a real WSL host. +pub fn resolve_binary_in_wsl_safe_path( + name: &str, + path_env: Option<&OsStr>, + is_wsl: bool, +) -> Option { + let path_env = path_env?; + for dir in std::env::split_paths(path_env) { + if is_wsl && dir.starts_with("/mnt") { + continue; + } + let candidate = dir.join(name); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + None +} + +#[cfg(unix)] +fn is_executable_file(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + match std::fs::metadata(path) { + Ok(md) => md.is_file() && (md.permissions().mode() & 0o111 != 0), + Err(_) => false, + } +} + +#[cfg(not(unix))] +fn is_executable_file(_path: &Path) -> bool { + // The WSL-safe resolver only runs on Linux. Other targets short- + // circuit through `is_wsl() == false`, so this stub is unreachable + // in practice — present only to keep the crate compiling. + false +} diff --git a/crates/warp_util/src/wsl_tests.rs b/crates/command/src/wsl_tests.rs similarity index 85% rename from crates/warp_util/src/wsl_tests.rs rename to crates/command/src/wsl_tests.rs index b32a83cc9..2a8baec91 100644 --- a/crates/warp_util/src/wsl_tests.rs +++ b/crates/command/src/wsl_tests.rs @@ -1,8 +1,8 @@ -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fs; use std::path::PathBuf; -use super::resolve_binary_in_wsl_safe_path; +use super::{known_bare_name, resolve_binary_in_wsl_safe_path}; #[cfg(unix)] fn make_executable(path: &std::path::Path) { @@ -144,3 +144,25 @@ fn handles_non_utf8_path_components() { resolve_binary_in_wsl_safe_path("git", Some(path_env.as_os_str()), true).unwrap(); assert_eq!(resolved, dir.path().join("git")); } + +#[test] +fn known_bare_name_recognizes_git_and_gh() { + assert_eq!(known_bare_name(OsStr::new("git")), Some("git")); + assert_eq!(known_bare_name(OsStr::new("gh")), Some("gh")); +} + +#[test] +fn known_bare_name_skips_paths() { + assert_eq!(known_bare_name(OsStr::new("/usr/bin/git")), None); + assert_eq!(known_bare_name(OsStr::new("./git")), None); + assert_eq!(known_bare_name(OsStr::new("bin/git")), None); + #[cfg(windows)] + assert_eq!(known_bare_name(OsStr::new("C:\\git\\git.exe")), None); +} + +#[test] +fn known_bare_name_skips_unknowns() { + assert_eq!(known_bare_name(OsStr::new("ls")), None); + assert_eq!(known_bare_name(OsStr::new("python")), None); + assert_eq!(known_bare_name(OsStr::new("")), None); +} diff --git a/crates/integration/Cargo.toml b/crates/integration/Cargo.toml index f09fe5553..c20891e60 100644 --- a/crates/integration/Cargo.toml +++ b/crates/integration/Cargo.toml @@ -41,7 +41,6 @@ version-compare.workspace = true warp = { workspace = true, features = ["integration_tests"] } warp_cli = { workspace = true, features = ["integration_tests"] } warp_core.workspace = true -warp_util.workspace = true warp-command-signatures.workspace = true warp_multi_agent_api.workspace = true warp-workflows.workspace = true diff --git a/crates/integration/src/test/code_review.rs b/crates/integration/src/test/code_review.rs index 48060097f..dbc028d8a 100644 --- a/crates/integration/src/test/code_review.rs +++ b/crates/integration/src/test/code_review.rs @@ -80,7 +80,7 @@ fn insert_lines(path: &Path, before_line_number: usize, new_lines: &[String]) { } fn run_git(test_dir: &Path, args: &[&str]) { - let status = Command::new(warp_util::wsl::git_binary()) + let status = Command::new("git") .args(args) .current_dir(test_dir) .status() diff --git a/crates/warp_util/Cargo.toml b/crates/warp_util/Cargo.toml index 9e07fb51b..9996cff86 100644 --- a/crates/warp_util/Cargo.toml +++ b/crates/warp_util/Cargo.toml @@ -16,7 +16,6 @@ rand.workspace = true dirs.workspace = true hex.workspace = true lazy_static.workspace = true -log.workspace = true mime_guess.workspace = true regex.workspace = true thiserror.workspace = true @@ -34,5 +33,4 @@ windows.workspace = true gloo.workspace = true [dev-dependencies] -tempfile = "3.8.0" warpui.workspace = true diff --git a/crates/warp_util/src/lib.rs b/crates/warp_util/src/lib.rs index 122785a8c..49fd688c6 100644 --- a/crates/warp_util/src/lib.rs +++ b/crates/warp_util/src/lib.rs @@ -12,7 +12,6 @@ pub mod path; pub mod standardized_path; pub mod user_input; pub mod worktree_names; -pub mod wsl; #[cfg(windows)] pub mod windows; diff --git a/crates/warp_util/src/wsl.rs b/crates/warp_util/src/wsl.rs deleted file mode 100644 index c5ecefeef..000000000 --- a/crates/warp_util/src/wsl.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! WSL detection and binary resolution for Warp-internal subprocesses. -//! -//! Warp ships as a Linux ELF binary that users routinely run inside -//! WSL. WSL's default `appendWindowsPath = true` (in `/etc/wsl.conf`) -//! puts directories like `/mnt/c/Program Files/Git/cmd/` on `PATH`, -//! so a bare `Command::new("git")` can resolve to Windows `git.exe` -//! through WSL interop. That path works for some commands but is -//! dramatically slower, can mishandle Linux paths, and breaks -//! Linux-side hooks. -//! -//! [`git_binary`] and [`gh_binary`] return an absolute Linux-side -//! path when running inside WSL, and the literal program name on -//! every other host so `Command::new` performs its normal PATH -//! lookup at spawn time (preserving call-site `cmd.env("PATH", …)` -//! overrides). The same `/mnt/*` filtering precedent is used for -//! `compgen` in -//! `app/src/terminal/model/session/command_executor/wsl_command_executor.rs`. -//! -//! Resolution is cached for the life of the process; PATH is -//! effectively static for the Warp host process. - -use std::ffi::{OsStr, OsString}; -use std::path::{Path, PathBuf}; -use std::sync::OnceLock; - -#[cfg(test)] -#[path = "wsl_tests.rs"] -mod tests; - -/// True when running inside a WSL guest. Cached for the life of the -/// process. Detection probes `/proc/sys/fs/binfmt_misc/WSLInterop`, -/// matching the existing checks in `crates/warpui/src/platform/linux/` -/// and `app/src/crash_reporting/linux.rs`. -pub fn is_wsl() -> bool { - static IS_WSL: OnceLock = OnceLock::new(); - *IS_WSL.get_or_init(|| Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists()) -} - -/// Program name to pass to `Command::new` for invoking `git` from -/// Warp-internal code. See module docs for the WSL behavior. -pub fn git_binary() -> &'static OsStr { - static GIT_BIN: OnceLock = OnceLock::new(); - GIT_BIN.get_or_init(|| resolve_or_warn("git")) -} - -/// Program name to pass to `Command::new` for invoking `gh`. -pub fn gh_binary() -> &'static OsStr { - static GH_BIN: OnceLock = OnceLock::new(); - GH_BIN.get_or_init(|| resolve_or_warn("gh")) -} - -fn resolve_or_warn(name: &str) -> OsString { - // Outside WSL, return the bare program name so each `Command::new` - // performs the OS's normal PATH lookup at spawn time. Resolving up - // front would freeze the binary path at module init and silently - // ignore call-site `cmd.env("PATH", ...)` overrides used to expose - // user-installed hooks (see `run_git_command_with_env` and - // `run_gh_command` in `app/src/util/git.rs`). - if !is_wsl() { - return OsString::from(name); - } - let path_env = std::env::var_os("PATH"); - match resolve_binary_in_wsl_safe_path(name, path_env.as_deref(), true) { - Some(p) => p.into_os_string(), - None => { - log::warn!( - "wsl: no Linux-side `{name}` found on PATH (excluding /mnt/*); \ - falling back to bare `{name}` which may resolve to a Windows .exe" - ); - OsString::from(name) - } - } -} - -/// Returns the first executable named `name` on `path_env`, skipping -/// any PATH entry under `/mnt/` when `is_wsl` is true. Returns `None` -/// if no acceptable match exists. Pure — exposed for unit testing -/// without depending on a real WSL host. -pub fn resolve_binary_in_wsl_safe_path( - name: &str, - path_env: Option<&OsStr>, - is_wsl: bool, -) -> Option { - let path_env = path_env?; - for dir in std::env::split_paths(path_env) { - if is_wsl && dir.starts_with("/mnt") { - continue; - } - let candidate = dir.join(name); - if is_executable_file(&candidate) { - return Some(candidate); - } - } - None -} - -#[cfg(unix)] -fn is_executable_file(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt as _; - match std::fs::metadata(path) { - Ok(md) => md.is_file() && (md.permissions().mode() & 0o111 != 0), - Err(_) => false, - } -} - -#[cfg(not(unix))] -fn is_executable_file(_path: &Path) -> bool { - // The WSL-safe resolver only runs on Linux. Other targets short- - // circuit through `is_wsl() == false`, so this stub is unreachable - // in practice — present only to keep the crate compiling. - false -} diff --git a/crates/warpui/Cargo.toml b/crates/warpui/Cargo.toml index f95472e11..81d36a6e6 100644 --- a/crates/warpui/Cargo.toml +++ b/crates/warpui/Cargo.toml @@ -50,7 +50,6 @@ takecell = "0.1.1" thiserror.workspace = true vec1.workspace = true version-compare = { workspace = true, optional = true } -warp_util.workspace = true warpui_core.workspace = true wgpu = { workspace = true, optional = true } diff --git a/crates/warpui/src/platform/linux/mod.rs b/crates/warpui/src/platform/linux/mod.rs index 8ecab178b..e55c9c310 100644 --- a/crates/warpui/src/platform/linux/mod.rs +++ b/crates/warpui/src/platform/linux/mod.rs @@ -56,7 +56,7 @@ pub fn user_windowing_system() -> WindowingSystem { } pub fn is_wsl() -> bool { - warp_util::wsl::is_wsl() + command::wsl::is_wsl() } pub fn is_wayland_env_var_set() -> bool {