From 930ec175f14b77fd700f06fdc02b8719282ea5a2 Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Mon, 9 Feb 2026 09:48:52 +0100 Subject: [PATCH] term, cli: `winsplit` over `shlex` on Windows Command line parsing differs on Windows vs. POSIX compliant operating systems (just consider the differences in handling `\` for paths vs. as a marker for escape sequences). Thus, on Windows use `winsplit` to split command arguments instead of `shlex` for Unix-like OSes. `winsplit` is a small crate with no other dependencies. --- CHANGELOG.md | 6 ++++++ Cargo.lock | 9 +++++++++ Cargo.toml | 1 + crates/radicle-cli-test/Cargo.toml | 9 +++++++-- crates/radicle-cli-test/src/lib.rs | 6 ++++++ crates/radicle-cli/Cargo.toml | 7 ++++++- crates/radicle-cli/src/pager.rs | 5 +++++ crates/radicle-term/Cargo.toml | 5 ++++- crates/radicle-term/src/editor.rs | 10 +++++++++- 9 files changed, 53 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f04e634..5728f96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed Bugs +- When preparing commands to execute, the `shlex` crate was used on all platforms. + The semantics on Windows are different (e.g. '\' is a path separator on Windows + but marks an escape sequence on Unix-like systems), which lead to issues when + attempting to execute child processes. + This is fixed by using `winsplit` on Windows instead. + ## Deprecations - The `rad fork` command was confusing, and mislead users as to what its purpose diff --git a/Cargo.lock b/Cargo.lock index fa9043f9..c0fce330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2888,6 +2888,7 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-toml-ng", "tree-sitter-typescript", + "winsplit", "zeroize", ] @@ -2902,6 +2903,7 @@ dependencies = [ "shlex", "snapbox", "thiserror 2.0.17", + "winsplit", ] [[package]] @@ -3201,6 +3203,7 @@ dependencies = [ "thiserror 2.0.17", "unicode-display-width", "unicode-segmentation", + "winsplit", "zeroize", ] @@ -5107,6 +5110,12 @@ dependencies = [ "windows", ] +[[package]] +name = "winsplit" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab703352da6a72f35c39a533526393725640575bb211f61987a2748323ad956" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index e4a84362..7c0ac0cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ sqlite = "0.32.0" tempfile = "3.3.0" thiserror = { version = "2", default-features = false } winpipe = "0.1.1" +winsplit = "0.1.0" zeroize = "1.5.7" # Crates from the "radicle-git" workspace. These should be synced manually. diff --git a/crates/radicle-cli-test/Cargo.toml b/crates/radicle-cli-test/Cargo.toml index 60c15811..1bb09fc4 100644 --- a/crates/radicle-cli-test/Cargo.toml +++ b/crates/radicle-cli-test/Cargo.toml @@ -16,6 +16,11 @@ escargot = "0.5.7" log = { workspace = true, features = ["std"] } pretty_assertions = { workspace = true } radicle = { workspace = true, features = ["logger", "test"]} -shlex = { workspace = true } snapbox = { workspace = true } -thiserror = { workspace = true, default-features = true } \ No newline at end of file +thiserror = { workspace = true, default-features = true } + +[target.'cfg(unix)'.dependencies] +shlex = { workspace = true } + +[target.'cfg(windows)'.dependencies] +winsplit = { workspace = true } diff --git a/crates/radicle-cli-test/src/lib.rs b/crates/radicle-cli-test/src/lib.rs index ccc8a9c5..83facd69 100644 --- a/crates/radicle-cli-test/src/lib.rs +++ b/crates/radicle-cli-test/src/lib.rs @@ -353,7 +353,13 @@ impl TestFormula { content.push('\n'); } else if let Some(line) = line.strip_prefix('$') { let line = line.trim(); + + #[cfg(unix)] let parts = shlex::split(line).ok_or(Error::Parse)?; + + #[cfg(windows)] + let parts = winsplit::split(line); + let (cmd, args) = parts.split_first().ok_or(Error::Parse)?; test.assertions.push(Assertion { diff --git a/crates/radicle-cli/Cargo.toml b/crates/radicle-cli/Cargo.toml index e147155d..33f541cf 100644 --- a/crates/radicle-cli/Cargo.toml +++ b/crates/radicle-cli/Cargo.toml @@ -33,7 +33,6 @@ radicle-term = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -shlex = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true, default-features = true } timeago = { version = "0.4.2", default-features = false } @@ -53,6 +52,12 @@ tree-sitter-toml-ng = "0.6.0" tree-sitter-typescript = "0.23.2" zeroize = { workspace = true } +[target.'cfg(unix)'.dependencies] +shlex = { workspace = true } + +[target.'cfg(windows)'.dependencies] +winsplit = { workspace = true } + [dev-dependencies] pretty_assertions = { workspace = true } radicle = { workspace = true, features = ["test"] } diff --git a/crates/radicle-cli/src/pager.rs b/crates/radicle-cli/src/pager.rs index 35bf358b..4742995d 100644 --- a/crates/radicle-cli/src/pager.rs +++ b/crates/radicle-cli/src/pager.rs @@ -21,9 +21,14 @@ pub fn run(elem: impl Element) -> io::Result<()> { let Some(pager) = radicle::profile::env::pager() else { return elem.write(Constraint::UNBOUNDED); }; + + #[cfg(unix)] let Some(parts) = shlex::split(&pager) else { return elem.write(Constraint::UNBOUNDED); }; + #[cfg(windows)] + let parts = winsplit::split(&pager); + let Some((program, args)) = parts.split_first() else { return elem.write(Constraint::UNBOUNDED); }; diff --git a/crates/radicle-term/Cargo.toml b/crates/radicle-term/Cargo.toml index a128c0bb..cb33f521 100644 --- a/crates/radicle-term/Cargo.toml +++ b/crates/radicle-term/Cargo.toml @@ -25,12 +25,15 @@ unicode-display-width = "0.3.0" unicode-segmentation = "1.7.1" zeroize = { workspace = true } git2 = { workspace = true, optional = true } -shlex = { workspace = true } [target.'cfg(unix)'.dependencies] crossbeam-channel = { workspace = true } libc = { workspace = true } radicle-signals = { workspace = true } +shlex = { workspace = true } + +[target.'cfg(windows)'.dependencies] +winsplit = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/crates/radicle-term/src/editor.rs b/crates/radicle-term/src/editor.rs index 4b4d4fd0..4e275379 100644 --- a/crates/radicle-term/src/editor.rs +++ b/crates/radicle-term/src/editor.rs @@ -110,12 +110,20 @@ impl Editor { "editor not configured: the `EDITOR` environment variable is not set", )); }; - let Some(parts) = shlex::split(cmd.to_string_lossy().as_ref()) else { + + let lossy = cmd.to_string_lossy(); + + #[cfg(unix)] + let Some(parts) = shlex::split(&lossy) else { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("invalid editor command {cmd:?}"), )); }; + + #[cfg(windows)] + let parts = winsplit::split(&lossy); + let Some((program, args)) = parts.split_first() else { return Err(io::Error::new( io::ErrorKind::InvalidInput,