From cc218f5c4c4febfd798a7d03305c5784341f8a8a Mon Sep 17 00:00:00 2001 From: Tim Van Wassenhove Date: Mon, 10 Aug 2026 11:24:34 +0200 Subject: [PATCH] fix(init): detect Git Bash on Windows instead of assuming PowerShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `wt init` and `wt shellenv` decided the target shell from GOOS alone, so on Windows they always chose PowerShell. Under Git Bash / MSYS2 that means `wt init` writes the integration into the PowerShell $PROFILE, which the bash session never loads — leaving `wt co` with no wrapper function, so it prints "wt navigating to: ..." and the shell never changes directory. Naming the shell explicitly did not help either: the installed bash block was `eval "$(wt shellenv)"`, and shellenv re-ran the same GOOS-only detection at every startup, evaluating PowerShell code inside bash. - Detect POSIX shell environments on Windows via MSYSTEM and $SHELL, and only fall back to PowerShell when neither indicates one. $SHELL is matched on its basename so "powershell" is not read as a POSIX shell. - Write the resolved shell into the config block (`wt shellenv bash`), so the installed line no longer depends on startup-time detection. - Fall back to a non-PTY mode when script(1) is absent, as it is in Git Bash. stdout is redirected and replayed rather than piped through tee, since a pipeline would report tee's exit status and PIPESTATUS is clobbered by the next command run. - Translate native Windows paths through cygpath before cd, when available. Fixes #112 --- cmd/examples.go | 2 +- cmd/init.go | 61 ++++++++++++++++++++++++----- cmd/init_test.go | 93 ++++++++++++++++++++++++++++++++++---------- cmd/shellenv.go | 46 ++++++++++++++++------ cmd/shellenv_test.go | 26 ++++++++----- docs/installation.md | 28 ++++++++++++- 6 files changed, 203 insertions(+), 53 deletions(-) diff --git a/cmd/examples.go b/cmd/examples.go index 82dbf24..c06bb24 100644 --- a/cmd/examples.go +++ b/cmd/examples.go @@ -291,7 +291,7 @@ var exampleCatalog = map[string]exampleTopic{ Purpose: "Preview shell profile changes before writing anything.", Outcome: "Shows what would be added/updated in the detected shell profile.", ExitCode: "0 on success; non-zero if shell/config path detection fails.", - TextExample: "Would append to ~/.bashrc:\n\n# >>> wt initialize >>>\neval \"$(wt shellenv)\"\n# <<< wt initialize <<<", + TextExample: "Would append to ~/.bashrc:\n\n# >>> wt initialize >>>\neval \"$(wt shellenv bash)\"\n# <<< wt initialize <<<", Preconditions: []string{"Run in an interactive environment where shell/profile can be detected."}, FailureModes: []string{"Unsupported shell argument.", "PowerShell integration requested on non-Windows host."}, FollowUp: []string{"wt init", "wt init --uninstall"}, diff --git a/cmd/init.go b/cmd/init.go index 808915e..86449b5 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -37,6 +37,9 @@ Automatically detects your shell and updates the appropriate config file: - fish: ~/.config/fish/config.fish (or $XDG_CONFIG_HOME/fish/config.fish) - powershell: $PROFILE (Windows only) +On Windows the shell defaults to PowerShell, unless a Git Bash or MSYS2 +environment is detected, in which case bash is configured instead. + The configuration is wrapped in markers so it can be safely updated or removed. Examples: @@ -47,7 +50,7 @@ Examples: wt init --uninstall # Remove wt configuration from shell`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - shell := detectShell(args) + shell := detectShell(args, runtime.GOOS) if shell == "" { return fmt.Errorf("could not detect shell. Please specify: wt init bash|zsh|fish|powershell") } @@ -119,8 +122,10 @@ const ( markerEnd = "# <<< wt initialize <<<" ) -// detectShell determines which shell to configure based on args or environment -func detectShell(args []string) string { +// detectShell determines which shell to configure based on args or environment. +// The goos parameter (normally runtime.GOOS) is injected so the decision can be +// unit-tested independently of the host OS. +func detectShell(args []string, goos string) string { // 1. Explicit argument if len(args) > 0 { shell := strings.ToLower(args[0]) @@ -133,8 +138,10 @@ func detectShell(args []string) string { fmt.Fprintf(os.Stderr, "Warning: unknown shell '%s', attempting auto-detection\n", args[0]) } - // 2. On Windows, default to PowerShell - if runtime.GOOS == "windows" { + // 2. On Windows, default to PowerShell — unless we are running under a + // POSIX shell environment such as Git Bash, in which case fall through to + // $SHELL detection so we configure the shell the user is actually using. + if goos == "windows" && !isPOSIXShellEnv() { return "powershell" } @@ -150,10 +157,39 @@ func detectShell(args []string) string { return "bash" } - // 4. Default to bash on Unix + // 4. Default to bash return "bash" } +// isPOSIXShellEnv reports whether the current process was started from a POSIX +// shell environment. It only affects Windows, where GOOS alone cannot +// distinguish PowerShell/cmd from Git Bash, MSYS2 or Cygwin — all of which run +// native Windows binaries but need the bash integration, not the PowerShell one. +// +// Git Bash and MSYS2 export MSYSTEM (e.g. "MINGW64"). Cygwin does not, but every +// one of these environments sets $SHELL to a Unix-style shell path, which +// neither PowerShell nor cmd does. +func isPOSIXShellEnv() bool { + if strings.TrimSpace(os.Getenv("MSYSTEM")) != "" { + return true + } + + // Compare against the basename only. A substring match would treat any + // directory component containing a shell name as a match — and "powershell" + // itself ends in "shell". + shellEnv := strings.TrimSpace(os.Getenv("SHELL")) + if i := strings.LastIndexAny(shellEnv, `/\`); i >= 0 { + shellEnv = shellEnv[i+1:] + } + shellEnv = strings.TrimSuffix(strings.ToLower(shellEnv), ".exe") + + switch shellEnv { + case "sh", "bash", "zsh", "fish", "dash", "ash", "ksh": + return true + } + return false +} + // validateShellEnv rejects environment misconfigurations that would make // getShellConfigPath resolve a path the target shell never actually loads. // @@ -236,20 +272,25 @@ func getShellConfigPath(shell string) string { return "" } -// getShellConfigContent returns the shell configuration block to add +// getShellConfigContent returns the shell configuration block to add. +// +// The shell is always passed to shellenv explicitly. Without it, shellenv +// re-runs its own auto-detection at every shell startup, which on Windows +// resolves to PowerShell and would eval PowerShell code into a Git Bash +// session. Naming the shell here makes the installed block unambiguous. func getShellConfigContent(shell string) string { switch shell { case "bash", "zsh": return fmt.Sprintf(`%s -eval "$(wt shellenv)" -%s`, markerStart, markerEnd) +eval "$(wt shellenv %s)" +%s`, markerStart, shell, markerEnd) case "fish": return fmt.Sprintf(`%s wt shellenv fish | source %s`, markerStart, markerEnd) case "powershell": return fmt.Sprintf(`%s -Invoke-Expression (& wt shellenv) +Invoke-Expression (& wt shellenv powershell) %s`, markerStart, markerEnd) } return "" diff --git a/cmd/init_test.go b/cmd/init_test.go index d62593c..8a07e52 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -5,17 +5,18 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "testing" ) func TestDetectShell(t *testing.T) { tests := []struct { - name string - args []string - envShell string - want string + name string + args []string + goos string + envShell string + envMsystem string + want string }{ { name: "explicit bash argument", @@ -65,25 +66,77 @@ func TestDetectShell(t *testing.T) { envShell: "/usr/bin/fish", want: "fish", }, + { + name: "windows with no POSIX shell env defaults to powershell", + args: []string{}, + goos: "windows", + want: "powershell", + }, + { + // Regression test for #112: under Git Bash, wt init wrote a + // PowerShell profile because GOOS alone decided the shell. + name: "windows under git bash detects bash via MSYSTEM", + args: []string{}, + goos: "windows", + envMsystem: "MINGW64", + envShell: "/usr/bin/bash", + want: "bash", + }, + { + name: "windows with unix SHELL and no MSYSTEM detects bash", + args: []string{}, + goos: "windows", + envShell: "/usr/bin/bash", + want: "bash", + }, + { + name: "windows under git bash still honours explicit powershell", + args: []string{"powershell"}, + goos: "windows", + envMsystem: "MINGW64", + envShell: "/usr/bin/bash", + want: "powershell", + }, + { + name: "windows git bash with .exe suffix detects bash", + args: []string{}, + goos: "windows", + envShell: `C:\Program Files\Git\usr\bin\bash.exe`, + want: "bash", + }, + { + // "powershell" ends in "shell"; a substring match would misread it. + name: "windows with powershell in SHELL stays on powershell", + args: []string{}, + goos: "windows", + envShell: `C:\Program Files\PowerShell\7\pwsh.exe`, + want: "powershell", + }, + { + name: "windows with a bash-named directory stays on powershell", + args: []string{}, + goos: "windows", + envShell: `C:\bash-tools\cmd.exe`, + want: "powershell", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Skip Windows-specific tests on non-Windows - if runtime.GOOS == "windows" && tt.envShell != "" { - t.Skip("Skipping SHELL env test on Windows") - } - - // Save and restore SHELL env var - origShell := os.Getenv("SHELL") - if tt.envShell != "" { - os.Setenv("SHELL", tt.envShell) + // Env is set unconditionally (including to "") so ambient values on + // the host — a real MSYSTEM on a Windows runner — cannot leak in. + t.Setenv("SHELL", tt.envShell) + t.Setenv("MSYSTEM", tt.envMsystem) + + goos := tt.goos + if goos == "" { + goos = "linux" } - defer os.Setenv("SHELL", origShell) - got := detectShell(tt.args) + got := detectShell(tt.args, goos) if got != tt.want { - t.Errorf("detectShell(%v) = %q, want %q", tt.args, got, tt.want) + t.Errorf("detectShell(%v, %q) with SHELL=%q MSYSTEM=%q = %q, want %q", + tt.args, goos, tt.envShell, tt.envMsystem, got, tt.want) } }) } @@ -241,12 +294,12 @@ func TestGetShellConfigContent(t *testing.T) { { name: "bash content", shell: "bash", - contains: []string{markerStart, markerEnd, "wt shellenv"}, + contains: []string{markerStart, markerEnd, `eval "$(wt shellenv bash)"`}, }, { name: "zsh content", shell: "zsh", - contains: []string{markerStart, markerEnd, "wt shellenv"}, + contains: []string{markerStart, markerEnd, `eval "$(wt shellenv zsh)"`}, }, { name: "fish content", @@ -256,7 +309,7 @@ func TestGetShellConfigContent(t *testing.T) { { name: "powershell content", shell: "powershell", - contains: []string{markerStart, markerEnd, "wt shellenv", "Invoke-Expression"}, + contains: []string{markerStart, markerEnd, "wt shellenv powershell", "Invoke-Expression"}, }, { name: "unsupported shell returns empty", diff --git a/cmd/shellenv.go b/cmd/shellenv.go index f3566e1..6da9049 100644 --- a/cmd/shellenv.go +++ b/cmd/shellenv.go @@ -15,18 +15,19 @@ var shellenvCmd = &cobra.Command{ Long: `Output shell integration code for automatic directory navigation. Add this to the END of your ~/.bashrc or ~/.zshrc: - source <(wt shellenv) + eval "$(wt shellenv bash)" For fish, add this to your ~/.config/fish/config.fish: wt shellenv fish | source For PowerShell, add this to your $PROFILE: - Invoke-Expression (& wt shellenv) + Invoke-Expression (& wt shellenv powershell) Note: For zsh, place this AFTER compinit to enable tab completion. -An optional shell argument (bash, zsh, fish, powershell/pwsh) can be given to -override auto-detection, e.g. 'wt shellenv fish'. +The shell argument (bash, zsh, fish, powershell/pwsh) overrides auto-detection and +is recommended: without it, detection re-runs on every shell startup. On Windows, +detection picks PowerShell unless it finds a Git Bash/MSYS2 environment. This enables: - Automatic cd to worktree after checkout/create/pr/mr commands @@ -155,10 +156,24 @@ func writeBashZshShellenv() { local log_file exit_code cd_path log_file=$(mktemp -t wt.XXXXXX) - # Detect OS to use correct script syntax (macOS vs Linux) - if [ "$(uname)" = "Darwin" ]; then + # script(1) may be missing entirely, and its syntax differs (macOS vs Linux) + if ! command -v script >/dev/null 2>&1; then + # No script(1) available (Git Bash on Windows does not ship it, nor do + # some minimal containers). Interactive prompts that require a TTY will + # not work here, but ordinary commands and auto-cd do. + # + # stdout is redirected and replayed rather than piped through tee: a + # pipeline would make $? tee's status, and PIPESTATUS/pipestatus are + # clobbered by the next command run — including the test needed to pick + # between the two shells' spellings. stderr is left alone so errors + # still stream live. + command wt "$@" > "$log_file" + exit_code=$? + cat "$log_file" + elif [ "$(uname)" = "Darwin" ]; then # macOS: script -q file command args script -q "$log_file" /bin/sh -c 'command wt "$@"' wt "$@" + exit_code=$? else # Linux: script -q -c "..." file — must pass command as single string, # so we shell-quote each argument to preserve spaces and special chars. @@ -167,14 +182,20 @@ func writeBashZshShellenv() { quoted_args="$quoted_args $(printf '%q' "$arg")" done script -q -c "command wt$quoted_args" "$log_file" + exit_code=$? fi - exit_code=$? # Extract the navigation marker for auto-cd cd_path=$(grep '^wt navigating to: ' "$log_file" | tail -1 | sed 's/^wt navigating to: //') rm -f "$log_file" cd_path=${cd_path%$'\r'} + # Git Bash / MSYS2 / Cygwin: wt is a native Windows binary and prints native + # Windows paths (C:\...). Translate them to the POSIX form cd understands. + if [ -n "$cd_path" ] && command -v cygpath >/dev/null 2>&1; then + cd_path=$(cygpath -u "$cd_path") + fi + if [ $exit_code -eq 0 ] && [ -n "$cd_path" ]; then cd "$cd_path" fi @@ -283,9 +304,10 @@ fi } // shellenvTargetShell determines which shell's integration script shellenv -// should output. Priority: explicit argument > GOOS (Windows -> PowerShell) -// > $SHELL detection. The goos parameter (normally runtime.GOOS) is injected -// so the decision can be unit-tested independently of the host OS. +// should output. Priority: explicit argument > GOOS (Windows -> PowerShell, +// unless running under a POSIX shell environment) > $SHELL detection. The goos +// parameter (normally runtime.GOOS) is injected so the decision can be +// unit-tested independently of the host OS. // // The generated PowerShell block invokes wt.exe, which only exists on Windows, // so an explicit powershell/pwsh target is rejected on non-Windows systems, @@ -307,7 +329,9 @@ func shellenvTargetShell(args []string, goos string) (string, error) { } } - if goos == "windows" { + // Windows defaults to PowerShell, but Git Bash / MSYS2 / Cygwin run the + // same native binary and need the bash integration instead. + if goos == "windows" && !isPOSIXShellEnv() { return "powershell", nil } diff --git a/cmd/shellenv_test.go b/cmd/shellenv_test.go index 0b4a89a..4840df3 100644 --- a/cmd/shellenv_test.go +++ b/cmd/shellenv_test.go @@ -205,16 +205,14 @@ func TestShellenvBypassesWrapperForShellenv(t *testing.T) { // TestShellenvTargetShell verifies the priority order used to determine // which shell integration to output: explicit argument > $SHELL > GOOS. func TestShellenvTargetShell(t *testing.T) { - origShell := os.Getenv("SHELL") - t.Cleanup(func() { os.Setenv("SHELL", origShell) }) - tests := []struct { - name string - args []string - goos string - envShell string - want string - wantErr bool + name string + args []string + goos string + envShell string + envMsystem string + want string + wantErr bool }{ {name: "explicit fish argument", args: []string{"fish"}, goos: "linux", want: "fish"}, {name: "explicit bash argument", args: []string{"bash"}, goos: "linux", want: "bash"}, @@ -226,6 +224,11 @@ func TestShellenvTargetShell(t *testing.T) { {name: "explicit pwsh on darwin is rejected", args: []string{"pwsh"}, goos: "darwin", wantErr: true}, {name: "unknown explicit argument falls back to detection", args: []string{"tcsh"}, goos: "linux", envShell: "/bin/bash", want: "bash"}, {name: "no args on windows defaults to powershell", args: []string{}, goos: "windows", want: "powershell"}, + // Regression tests for #112: shellenv emitted PowerShell under Git Bash, + // so `eval "$(wt shellenv)"` in .bashrc evaluated PowerShell code. + {name: "no args on windows under git bash outputs bash", args: []string{}, goos: "windows", envMsystem: "MINGW64", envShell: "/usr/bin/bash", want: "bash"}, + {name: "no args on windows with unix SHELL outputs bash", args: []string{}, goos: "windows", envShell: "/usr/bin/bash", want: "bash"}, + {name: "explicit powershell on windows under git bash is honoured", args: []string{"powershell"}, goos: "windows", envMsystem: "MINGW64", want: "powershell"}, {name: "no args, SHELL=fish", args: []string{}, goos: "linux", envShell: "/usr/bin/fish", want: "fish"}, {name: "no args, SHELL=bash", args: []string{}, goos: "linux", envShell: "/bin/bash", want: "bash"}, {name: "no args, no SHELL", args: []string{}, goos: "linux", envShell: "", want: "bash"}, @@ -233,7 +236,10 @@ func TestShellenvTargetShell(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - os.Setenv("SHELL", tt.envShell) + // Set unconditionally so a real MSYSTEM/SHELL on the host runner + // cannot leak into cases that expect them unset. + t.Setenv("SHELL", tt.envShell) + t.Setenv("MSYSTEM", tt.envMsystem) got, err := shellenvTargetShell(tt.args, tt.goos) if tt.wantErr { if err == nil { diff --git a/docs/installation.md b/docs/installation.md index d332c98..8d99a82 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -136,7 +136,7 @@ Shell integration enables: **Manual setup** (alternative to `wt init`): Add this to the **END** of your shell config: ```bash -eval "$(wt shellenv)" +eval "$(wt shellenv bash)" # use 'zsh' for zsh ``` For fish, add this instead: @@ -145,4 +145,30 @@ For fish, add this instead: wt shellenv fish | source ``` +For PowerShell, add this to your `$PROFILE`: + +```powershell +Invoke-Expression (& wt shellenv powershell) +``` + +Naming the shell explicitly is recommended. Without it, `shellenv` re-runs auto-detection +on every shell startup. + **Note for zsh users:** Place this after `compinit` in your config file. + +### Windows: Git Bash vs PowerShell + +`wt` is a native Windows binary, so it behaves the same whether you launch it from +PowerShell, Git Bash, or MSYS2 — but the shell integration differs. `wt init` detects +Git Bash and MSYS2 (via `MSYSTEM`/`SHELL`) and configures `~/.bashrc`; otherwise it +configures your PowerShell `$PROFILE`. Pass the shell explicitly to override: + +```bash +wt init bash # from Git Bash +wt init powershell # from PowerShell +``` + +Git Bash does not ship `script(1)`, so the integration falls back to a non-PTY mode +there. Auto-`cd` and tab completion work; the interactive selection menus +(`wt checkout` with no arguments) need a TTY and are unavailable — pass a branch name +explicitly instead.