Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
term, cli: Handle pager and editor configuration on Windows
Merged lorenz opened 2 months ago
10 files changed +96 -9 537eaba8 50fb228a
modified 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
modified 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",
]

@@ -5108,6 +5111,12 @@ dependencies = [
]

[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified 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.
modified 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 }
modified 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 {
modified 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"] }
modified 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);
    };
modified 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 }
modified 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,
@@ -203,26 +211,47 @@ fn default_editor() -> Option<OsString> {
            return Some(editor.into());
        }
    }
+

    // Check Git. The user might have configured their editor there.
-
    #[cfg(feature = "git2")]
+
    // On Windows, custom editors configured via Git are not supported,
+
    // because of the complexity surrounding how the editor command is
+
    // parsed and executed. See also <https://stackoverflow.com/a/773973/1835188>.
+
    #[cfg(all(feature = "git2", not(windows)))]
    if let Ok(path) = git2::Config::open_default().and_then(|cfg| cfg.get_path("core.editor")) {
        return Some(path.into_os_string());
    }
+

    // On macOS, `nano` is installed by default and it's what most users are used to
    // in the terminal.
-
    if cfg!(target_os = "macos") && exists("nano") {
+
    #[cfg(target_os = "macos")]
+
    if exists("nano") {
        return Some("nano".into());
    }
+

+
    // On Windows, `edit` is available by default, see <https://learn.microsoft.com/windows/edit>.
+
    #[cfg(windows)]
+
    if exists("edit.exe") {
+
        return Some("edit.exe".into());
+
    }
+

+
    // On Windows, `notepad` is commonly available for decades, see <https://apps.microsoft.com/detail/9msmlrh6lzf3>.
+
    #[cfg(windows)]
+
    if exists("notepad.exe") {
+
        return Some("notepad.exe".into());
+
    }
+

    // If all else fails, we try `vi`. It's usually installed on most unix-based systems.
    if exists("vi") {
        return Some("vi".into());
    }
+

    None
}

-
/// Check whether a binary can be found in the most common paths.
-
/// We don't bother checking the $PATH variable, as we're only looking for very standard tools
+
/// Check whether a binary can be found in the most common paths on Unix-like systems.
+
/// We don't bother checking the `$PATH` variable, as we're only looking for very standard tools
/// and prefer not to make this too complex.
+
#[cfg(unix)]
fn exists(cmd: &str) -> bool {
    // Some common paths where system-installed binaries are found.
    const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
@@ -234,3 +263,17 @@ fn exists(cmd: &str) -> bool {
    }
    false
}
+

+
/// Check whether a binary can be found on `$PATH`.
+
/// See:
+
///  - <https://devblogs.microsoft.com/scripting/weekend-scripter-where-exethe-what-why-and-how/>
+
///  - <https://learn.microsoft.com/windows-server/administration/windows-commands/where>
+
#[cfg(windows)]
+
fn exists(cmd: &str) -> bool {
+
    std::process::Command::new("where.exe")
+
        .arg("/q")
+
        .arg("$PATH:".to_owned() + cmd)
+
        .output()
+
        .map(|output| output.status.success())
+
        .unwrap_or_default()
+
}
modified crates/radicle/src/profile.rs
@@ -88,6 +88,10 @@ pub mod env {

    /// Get the configured pager program from the environment.
    pub fn pager() -> Option<String> {
+
        // On Windows, custom pagers configured via Git are not supported,
+
        // because of the complexity surrounding how the pager command is
+
        // parsed and executed. See also <https://stackoverflow.com/a/773973/1835188>.
+
        #[cfg(not(windows))]
        if let Ok(cfg) = crate::git::raw::Config::open_default() {
            if let Ok(pager) = cfg.get_string("core.pager") {
                return Some(pager);