From 6f7216fb7fc48517779b8a5c059e71d57f90adf3 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 23:50:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(terminal):=20git=20bash=20=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E2=80=94=E2=80=94Windows=20=E4=B8=8A=20POSIX=20shell?= =?UTF-8?q?=20=E4=B9=9F=E4=BB=A5=E7=99=BB=E5=BD=95=20shell=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=20(#1036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): start POSIX shells as login shells on Windows too Git Bash started argument-less on Windows (resolve_shell only passed -l on Unix), so it ran as a non-login shell: /etc/profile never ran, git-completion.bash was not loaded, and git had no subcommand suggestions. Start POSIX shells with -l everywhere, like the Git Bash shortcut, Windows Terminal and IDEA do. A bare login bash also runs with Git for Windows' MSYS default profile, whose PATH leaves git and the mingw64 tools out unless \cmd is inherited; default MSYSTEM to MINGW64 (the shortcut's value) unless the user or the launching process already set it. * fix(terminal): keep MSYS2 login shells in the working directory MSYS2's /etc/profile runs cd "$HOME" on a login start unless CHERE_INVOKING is set, so with the new login args an MSYS2 bash/sh project terminal would open in the home directory instead of the project. Default CHERE_INVOKING=1 for Windows bash/sh next to the MSYSTEM default, still deferring to a value the user or the launching process set. Git for Windows' /etc/profile has no such cd (verified locally: a login bash stays in the project directory either way), so Git Bash users see no change. --- crates/ide-terminal/src/lib.rs | 91 +++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 21ae833c..f08b3a06 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -94,6 +94,12 @@ impl TerminalManager { let (shell, mut args) = resolve_shell(options.shell.as_deref(), &options.args); let mut env = terminal_env(&options.env); + if let Some(msystem) = default_msystem(&shell, &options.env, std::env::var("MSYSTEM").ok().as_deref()) { + env.insert("MSYSTEM".to_owned(), msystem.to_owned()); + } + if default_chere_invoking(&shell, &options.env, std::env::var("CHERE_INVOKING").ok().as_deref()) { + env.insert("CHERE_INVOKING".to_owned(), "1".to_owned()); + } if options.shell_integration { integration::apply(&shell_title(&shell), &mut args, &mut env); } @@ -513,7 +519,9 @@ pub fn available_shells() -> Vec { } /// Picks the shell binary and its arguments. A default (or explicitly chosen but -/// argument-less) POSIX shell is started as a login shell, like Terminal.app/IDEA. +/// argument-less) POSIX shell is started as a login shell, like Terminal.app, +/// the Git Bash shortcut and IDEA: login start-up is what loads the completions +/// and prompts users expect (`/etc/profile`, Git for Windows' git-completion). pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) { let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell); if !args.is_empty() { @@ -521,7 +529,7 @@ pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) -> BTreeMap { env } +/// Git Bash's MSYSTEM profile on Windows, when nobody chose one. A bare +/// `bash.exe` would run with the `MSYS` default, whose PATH leaves `git` and +/// the mingw64 tools out unless `\cmd` happens to be inherited — the Git +/// Bash shortcut instead sets `MSYSTEM=MINGW64`, and that is the environment +/// users expect from a "git bash" terminal (needed for git's Tab completion). +/// `None` for other shells and on Unix, or when the user or the launching +/// process already set `MSYSTEM`. +fn default_msystem(shell: &str, user: &BTreeMap, inherited: Option<&str>) -> Option<&'static str> { + if !cfg!(windows) || !matches!(shell_title(shell).as_str(), "bash" | "sh") { + return None; + } + let set = |v: Option<&str>| v.is_some_and(|v| !v.is_empty()); + if set(user.get("MSYSTEM").map(String::as_str)) || set(inherited) { + return None; + } + Some("MINGW64") +} + +/// MSYS2 login shells `cd "$HOME"` in `/etc/profile` unless `CHERE_INVOKING` +/// is set, so without it a login `bash` / `sh` project terminal (MSYS2's +/// `bash.exe`, Git for Windows included) would open in the home directory +/// instead of the project. `false` for other shells and on Unix, or when the +/// user or the launching process already set it. +fn default_chere_invoking(shell: &str, user: &BTreeMap, inherited: Option<&str>) -> bool { + if !cfg!(windows) || !matches!(shell_title(shell).as_str(), "bash" | "sh") { + return false; + } + let set = |v: Option<&str>| v.is_some_and(|v| !v.is_empty()); + !set(user.get("CHERE_INVOKING").map(String::as_str)) && !set(inherited) +} + #[cfg(all(test, unix))] mod tests { use super::*; @@ -838,3 +877,51 @@ mod tests { assert_eq!(env["COLORTERM"], "truecolor"); } } + +/// `resolve_shell` / `default_msystem` / `default_chere_invoking` behave the +/// same on every platform, so their Windows side (Git Bash login start-up, +/// MSYSTEM, CHERE_INVOKING) is tested everywhere. +#[cfg(test)] +mod shell_start_on_every_platform { + use super::*; + + #[test] + fn posix_shells_start_as_login_shells_everywhere() { + let (shell, args) = resolve_shell(Some("C:\\Program Files\\Git\\bin\\bash.exe"), &[]); + assert_eq!((shell.as_str(), args), ("C:\\Program Files\\Git\\bin\\bash.exe", vec!["-l".to_owned()])); + let (_, args) = resolve_shell(Some("D:\\msys64\\usr\\bin\\sh.exe"), &[]); + assert_eq!(args, vec!["-l".to_owned()]); + // Non-POSIX shells keep their plain start. + for shell in ["pwsh.exe", "C:\\Windows\\system32\\cmd.exe", "wsl.exe"] { + assert!(resolve_shell(Some(shell), &[]).1.is_empty(), "{shell} must start without arguments"); + } + } + + #[test] + fn windows_bash_defaults_to_the_mingw64_profile() { + let none: BTreeMap = BTreeMap::new(); + let chose = BTreeMap::from([("MSYSTEM".into(), "MSYS".into())]); + assert_eq!(default_msystem("pwsh.exe", &none, None), None); + assert_eq!(default_msystem("C:\\Windows\\system32\\cmd.exe", &none, None), None); + // An MSYSTEM the user (terminal env) or the launching process set wins. + assert_eq!(default_msystem("bash.exe", &chose, None), None); + assert_eq!(default_msystem("bash.exe", &none, Some("UCRT64")), None); + // On Unix there is no MSYSTEM concept: never defaulted. + let (inherited, want) = if cfg!(windows) { (None, Some("MINGW64")) } else { (Some("MSYS"), None) }; + assert_eq!(default_msystem("bash.exe", &none, inherited), want); + } + + #[test] + fn windows_bash_login_starts_in_the_working_directory() { + let none: BTreeMap = BTreeMap::new(); + let chose = BTreeMap::from([("CHERE_INVOKING".into(), "0".into())]); + assert!(!default_chere_invoking("pwsh.exe", &none, None)); + assert!(!default_chere_invoking("C:\\Windows\\system32\\cmd.exe", &none, None)); + // A CHERE_INVOKING the user (terminal env) or the launching process set wins. + assert!(!default_chere_invoking("bash.exe", &chose, None)); + assert!(!default_chere_invoking("bash.exe", &none, Some("0"))); + // On Unix there is no MSYS2 login cd: never defaulted. + let (inherited, want) = if cfg!(windows) { (None, true) } else { (Some("1"), false) }; + assert_eq!(default_chere_invoking("bash.exe", &none, inherited), want); + } +}