Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/examples.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
61 changes: 51 additions & 10 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
}
Expand Down Expand Up @@ -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])
Expand All @@ -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"
}

Expand All @@ -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.
//
Expand Down Expand Up @@ -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 ""
Expand Down
93 changes: 73 additions & 20 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
})
}
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
46 changes: 35 additions & 11 deletions cmd/shellenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}

Expand Down
Loading