Skip to content

Commit fd60dc7

Browse files
fix: zsh doctor on windows (#2433)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e37bbb9 commit fd60dc7

3 files changed

Lines changed: 66 additions & 13 deletions

File tree

crates/forge_main/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ forge_markdown_stream.workspace = true
6565
strip-ansi-escapes.workspace = true
6666
terminal_size = "0.4"
6767
rustls.workspace = true
68+
tempfile.workspace = true
6869

6970
[target.'cfg(windows)'.dependencies]
7071
enable-ansi-support.workspace = true
@@ -76,7 +77,6 @@ arboard = "3.4"
7677
tokio = { workspace = true, features = ["macros", "rt", "time", "test-util"] }
7778
insta.workspace = true
7879
pretty_assertions.workspace = true
79-
tempfile.workspace = true
8080
serial_test = "3.4"
8181
fake = { version = "5.1.0", features = ["derive"] }
8282
forge_domain = { path = "../forge_domain" }

crates/forge_main/src/zsh/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ mod plugin;
1111
mod rprompt;
1212
mod style;
1313

14+
/// Normalizes shell script content for cross-platform compatibility.
15+
///
16+
/// Strips carriage returns (`\r`) that appear when `include_str!` or
17+
/// `include_dir!` embed files on Windows (where `git core.autocrlf=true`
18+
/// converts LF to CRLF on checkout). Zsh cannot parse `\r` in scripts.
19+
pub(crate) fn normalize_script(content: &str) -> String {
20+
content.replace("\r\n", "\n").replace('\r', "\n")
21+
}
22+
1423
pub use plugin::{
1524
generate_zsh_plugin, generate_zsh_theme, run_zsh_doctor, run_zsh_keyboard,
1625
setup_zsh_integration,

crates/forge_main/src/zsh/plugin.rs

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub fn generate_zsh_plugin() -> Result<String> {
2222
// Iterate through all embedded files in shell-plugin/lib, stripping comments
2323
// and empty lines. All files in this directory are .zsh files.
2424
for file in forge_embed::files(&ZSH_PLUGIN_LIB) {
25-
let content = std::str::from_utf8(file.contents())?;
25+
let content = super::normalize_script(std::str::from_utf8(file.contents())?);
2626
for line in content.lines() {
2727
let trimmed = line.trim();
2828
// Skip empty lines and comment lines
@@ -51,14 +51,28 @@ pub fn generate_zsh_plugin() -> Result<String> {
5151

5252
/// Generates the ZSH theme for Forge
5353
pub fn generate_zsh_theme() -> Result<String> {
54-
let mut content = include_str!("../../../../shell-plugin/forge.theme.zsh").to_string();
54+
let mut content =
55+
super::normalize_script(include_str!("../../../../shell-plugin/forge.theme.zsh"));
5556

5657
// Set environment variable to indicate theme is loaded (with timestamp)
5758
content.push_str("\n_FORGE_THEME_LOADED=$(date +%s)\n");
5859

5960
Ok(content)
6061
}
6162

63+
/// Creates a temporary zsh script file for Windows execution
64+
fn create_temp_zsh_script(script_content: &str) -> Result<(tempfile::TempDir, PathBuf)> {
65+
use std::io::Write;
66+
67+
let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
68+
let script_path = temp_dir.path().join("forge_script.zsh");
69+
let mut file = fs::File::create(&script_path).context("Failed to create temp script file")?;
70+
file.write_all(script_content.as_bytes())
71+
.context("Failed to write temp script")?;
72+
73+
Ok((temp_dir, script_path))
74+
}
75+
6276
/// Executes a ZSH script with streaming output
6377
///
6478
/// # Arguments
@@ -71,14 +85,43 @@ pub fn generate_zsh_theme() -> Result<String> {
7185
/// Returns error if the script cannot be executed, if output streaming fails,
7286
/// or if the script exits with a non-zero status code
7387
fn execute_zsh_script_with_streaming(script_content: &str, script_name: &str) -> Result<()> {
74-
// Execute the script in a zsh subprocess with piped output
75-
let mut child = std::process::Command::new("zsh")
76-
.arg("-c")
77-
.arg(script_content)
78-
.stdout(Stdio::piped())
79-
.stderr(Stdio::piped())
80-
.spawn()
81-
.context(format!("Failed to execute zsh {} script", script_name))?;
88+
let script_content = super::normalize_script(script_content);
89+
90+
// On Unix, pass the script via `zsh -c`. Command::arg() uses execve,
91+
// which forwards arguments directly without shell interpretation, so
92+
// embedded quotes are safe.
93+
//
94+
// On Windows, we write the script to a temp file and run `zsh -f <file>`
95+
// instead. A temp file is necessary because:
96+
// 1. CI has core.autocrlf=true, so checked-out files contain CRLF; writing
97+
// through normalize_script ensures the temp file has LF.
98+
// 2. CreateProcess mangles quotes, so passing the script via -c corrupts any
99+
// embedded quoting.
100+
// 3. Piping via stdin is unreliable -- Windows caps pipe buffer size, which
101+
// can truncate or block on larger scripts.
102+
// The -f flag also prevents ~/.zshrc from loading during execution.
103+
let (_temp_dir, mut child) = if cfg!(windows) {
104+
let (temp_dir, script_path) = create_temp_zsh_script(&script_content)?;
105+
let child = std::process::Command::new("zsh")
106+
// -f: don't load ~/.zshrc (prevents theme loading during doctor)
107+
.arg("-f")
108+
.arg(script_path.to_string_lossy().as_ref())
109+
.stdout(Stdio::piped())
110+
.stderr(Stdio::piped())
111+
.spawn()
112+
.context(format!("Failed to execute zsh {} script", script_name))?;
113+
// Keep temp_dir alive by boxing it in the tuple
114+
(Some(temp_dir), child)
115+
} else {
116+
let child = std::process::Command::new("zsh")
117+
.arg("-c")
118+
.arg(&script_content)
119+
.stdout(Stdio::piped())
120+
.stderr(Stdio::piped())
121+
.spawn()
122+
.context(format!("Failed to execute zsh {} script", script_name))?;
123+
(None, child)
124+
};
82125

83126
// Get stdout and stderr handles
84127
let stdout = child.stdout.take().context("Failed to capture stdout")?;
@@ -209,7 +252,8 @@ pub fn setup_zsh_integration(
209252
) -> Result<ZshSetupResult> {
210253
const START_MARKER: &str = "# >>> forge initialize >>>";
211254
const END_MARKER: &str = "# <<< forge initialize <<<";
212-
const FORGE_INIT_CONFIG: &str = include_str!("../../../../shell-plugin/forge.setup.zsh");
255+
const FORGE_INIT_CONFIG_RAW: &str = include_str!("../../../../shell-plugin/forge.setup.zsh");
256+
let forge_init_config = super::normalize_script(FORGE_INIT_CONFIG_RAW);
213257

214258
let home = std::env::var("HOME").context("HOME environment variable not set")?;
215259
let zdotdir = std::env::var("ZDOTDIR").unwrap_or_else(|_| home.clone());
@@ -230,7 +274,7 @@ pub fn setup_zsh_integration(
230274

231275
// Build the forge config block with markers
232276
let mut forge_config: Vec<String> = vec![START_MARKER.to_string()];
233-
forge_config.extend(FORGE_INIT_CONFIG.lines().map(String::from));
277+
forge_config.extend(forge_init_config.lines().map(String::from));
234278

235279
// Add nerd font configuration if requested
236280
if disable_nerd_font {

0 commit comments

Comments
 (0)