From d9292a440c0ffa45a792f9f567bc482b739e5c05 Mon Sep 17 00:00:00 2001 From: Cory Douthat <80133232+corydouthat-sq@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:19:15 -0400 Subject: [PATCH 1/2] fix(windows): never resolve Git Bash to the WSL or Store bash.exe stubs Get-GitBashPath tried two Program Files literals and then fell back to whatever `bash` resolved to on PATH. On a stock Windows 11 machine that is the Microsoft Store app-execution alias under WindowsApps, or the WSL launcher in System32, both of which precede Git for Windows on PATH and cannot run repository scripts. Per-user Git installs (winget user scope, %LOCALAPPDATA%\Programs\Git) were never considered at all. c174a7d0 fixed the same confusion on the `just` side by pinning just 1.48.0 but left this diagnostic helper alone, so bootstrap and doctor kept reporting the stub as a healthy Git Bash. Model the lookup on Find-RunnablePython: gather candidates from the machine-wide and per-user install roots, from the root of the git.exe on PATH (\bin and \usr\bin), and from every `where.exe bash` hit, then drop anything a new pure helper, Test-GitBashCandidatePath, recognises as a stub (WindowsApps, System32 or SysWOW64 bash.exe, Codex runtimes). When nothing usable remains the helper returns $null and bootstrap/doctor say so instead of pointing at Program Files\Git. Test-WindowsDev.ps1 covers the filter and checks the lookup applies it. Co-Authored-By: Claude Fable 5.1 --- scripts/windows/Bootstrap-Windows.ps1 | 2 +- scripts/windows/Doctor-Windows.ps1 | 2 +- scripts/windows/Test-WindowsDev.ps1 | 21 +++++++++ scripts/windows/WindowsDev.psm1 | 64 +++++++++++++++++++++++---- 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/scripts/windows/Bootstrap-Windows.ps1 b/scripts/windows/Bootstrap-Windows.ps1 index 5474158ab..e3df3e3f4 100644 --- a/scripts/windows/Bootstrap-Windows.ps1 +++ b/scripts/windows/Bootstrap-Windows.ps1 @@ -137,7 +137,7 @@ function Invoke-PrerequisiteEvaluation { $gitBash = $prereqs.GitBash.Path if (-not $prereqs.GitBash.Found) { - Add-Failure "Git Bash" "bash.exe was not found under Program Files\Git" "Git.Git" + Add-Failure "Git Bash" "Git for Windows bash.exe was not found (the WSL and Microsoft Store bash.exe stubs do not count)" "Git.Git" } else { Add-Pass "Git Bash" $gitBash } diff --git a/scripts/windows/Doctor-Windows.ps1 b/scripts/windows/Doctor-Windows.ps1 index 2f7935482..3d6bfa2f9 100644 --- a/scripts/windows/Doctor-Windows.ps1 +++ b/scripts/windows/Doctor-Windows.ps1 @@ -70,7 +70,7 @@ Check-Command "git" "Run: just bootstrap-windows install" $prereqs.Git | Out-Nul $gitBash = $prereqs.GitBash.Path if (-not $prereqs.GitBash.Found) { - Fail "Git Bash" "bash.exe not found. Install Git for Windows with: winget install --id Git.Git -e" + Fail "Git Bash" "Git for Windows bash.exe not found (the WSL and Microsoft Store bash.exe stubs do not count). Install Git for Windows with: winget install --id Git.Git -e" } else { Pass "Git Bash" $gitBash } diff --git a/scripts/windows/Test-WindowsDev.ps1 b/scripts/windows/Test-WindowsDev.ps1 index b4bdb38e9..6437db007 100644 --- a/scripts/windows/Test-WindowsDev.ps1 +++ b/scripts/windows/Test-WindowsDev.ps1 @@ -78,6 +78,27 @@ try { Assert-Equal "process args: trailing backslash doubled inside quotes" (Join-WindowsProcessArguments -Arguments @("C:\Program Files\")) '"C:\Program Files\\"' Assert-Equal "process args: embedded quote escaped" (Join-WindowsProcessArguments -Arguments @('say "hi"')) '"say \"hi\""' + # Git Bash discovery must never hand back the bash.exe stubs Windows puts + # ahead of Git on PATH (the WSL launcher, the Store app-execution alias). + Assert-Equal "git bash filter rejects the WSL launcher" (Test-GitBashCandidatePath "C:\Windows\System32\bash.exe") $false + Assert-Equal "git bash filter rejects the 32-bit WSL launcher" (Test-GitBashCandidatePath "C:\Windows\SysWOW64\bash.exe") $false + Assert-Equal "git bash filter rejects the Store app-execution alias" ` + (Test-GitBashCandidatePath "C:\Users\dev\AppData\Local\Microsoft\WindowsApps\bash.exe") $false + Assert-Equal "git bash filter rejects Codex runtimes" (Test-GitBashCandidatePath "C:\Users\dev\.cache\codex-runtimes\git\bin\bash.exe") $false + Assert-Equal "git bash filter rejects blank paths" (Test-GitBashCandidatePath "") $false + Assert-Equal "git bash filter accepts the machine-wide Git install" (Test-GitBashCandidatePath "C:\Program Files\Git\bin\bash.exe") $true + Assert-Equal "git bash filter accepts a per-user Git install" ` + (Test-GitBashCandidatePath "C:\Users\dev\AppData\Local\Programs\Git\usr\bin\bash.exe") $true + Assert-Equal "git bash lookup applies the filter to every candidate" ` + ((Get-Command Get-GitBashPath -CommandType Function).Definition -match 'Test-GitBashCandidatePath \$candidate') $true + Assert-Equal "git bash lookup derives candidates from git.exe" ` + ((Get-Command Get-GitBashPath -CommandType Function).Definition -match 'Get-CommandSource "git"') $true + $gitBashPath = Get-GitBashPath + if (-not [string]::IsNullOrWhiteSpace($gitBashPath)) { + Assert-Equal "git bash lookup result passes its own filter" (Test-GitBashCandidatePath $gitBashPath) $true + Assert-Equal "git bash lookup result exists" (Test-Path -LiteralPath $gitBashPath -PathType Leaf) $true + } + Assert-Equal "public app feature defaults fail closed" (Get-BerdAppFeatures) "berdctl,app-test-driver" $featureGateNames = @("VITE_AGENT_TOOLS", "VITE_AUTOMATIONS", "VITE_BUILDERBOT", "VITE_FEEDBACK", "VITE_MANAGED_CONNECTIONS", "VITE_SKILL_DISCOVERY", "VITE_TELEMETRY_ENFORCED", "VITE_VOICE_DICTATION") $savedFeatureGates = @{} diff --git a/scripts/windows/WindowsDev.psm1 b/scripts/windows/WindowsDev.psm1 index f9b2f9a8a..8caa69150 100644 --- a/scripts/windows/WindowsDev.psm1 +++ b/scripts/windows/WindowsDev.psm1 @@ -1589,21 +1589,67 @@ function Get-RustHostTriple { return $null } +function Test-GitBashCandidatePath { + # True when a bash.exe path can plausibly run repository scripts. Windows + # ships two bash.exe stubs that often precede Git for Windows on PATH and + # cannot: the WSL launcher in System32 and the Microsoft Store + # app-execution alias under WindowsApps. Pure string check so it is + # testable without those files present. + param([AllowNull()][string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + if ($Path -match '\\WindowsApps\\') { + return $false + } + if ($Path -match '\\(System32|SysWOW64)\\bash\.exe$') { + return $false + } + if (Test-CodexRuntimePath $Path) { + return $false + } + return $true +} + function Get-GitBashPath { - $candidates = @() - if (-not [string]::IsNullOrWhiteSpace($env:ProgramFiles)) { - $candidates += (Join-Path $env:ProgramFiles "Git\bin\bash.exe") + # Prefer Git for Windows' own bash.exe. Machine-wide and per-user (winget + # user scope) install roots come first, then the root of whichever git.exe + # is on PATH, then every bash.exe PATH resolves to; stubs that cannot run + # scripts are filtered out. Returns $null when nothing usable exists so + # bootstrap and doctor report a real failure instead of the WSL launcher. + $candidates = New-Object System.Collections.Generic.List[string] + foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, (Join-Path (Get-LocalAppDataRoot) "Programs"))) { + if (-not [string]::IsNullOrWhiteSpace($root)) { + $candidates.Add((Join-Path $root "Git\bin\bash.exe")) + } } - $programFilesX86 = ${env:ProgramFiles(x86)} - if (-not [string]::IsNullOrWhiteSpace($programFilesX86)) { - $candidates += (Join-Path $programFilesX86 "Git\bin\bash.exe") + + $git = Get-CommandSource "git" + if (-not [string]::IsNullOrWhiteSpace($git) -and -not (Test-CodexRuntimePath $git)) { + # \cmd\git.exe or \bin\git.exe; bash lives under \bin + # and \usr\bin. + $gitRoot = Split-Path -Parent (Split-Path -Parent $git) + if (-not [string]::IsNullOrWhiteSpace($gitRoot)) { + $candidates.Add((Join-Path $gitRoot "bin\bash.exe")) + $candidates.Add((Join-Path $gitRoot "usr\bin\bash.exe")) + } } - foreach ($candidate in $candidates) { - if (Test-Path $candidate -PathType Leaf) { + + $whereBash = Invoke-CaptureCommand -FilePath "where.exe" -ArgumentList @("bash") + if ($whereBash.ExitCode -eq 0) { + foreach ($line in ($whereBash.Output -split "`r?`n")) { + if (-not [string]::IsNullOrWhiteSpace($line)) { + $candidates.Add($line.Trim()) + } + } + } + + foreach ($candidate in ($candidates | Select-Object -Unique)) { + if ((Test-GitBashCandidatePath $candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return $candidate } } - return (Get-CommandSource "bash") + return $null } function Get-VsWherePath { From 792becc063f09cd89a325f9e3892d424e1bf3025 Mon Sep 17 00:00:00 2001 From: Cory Douthat <80133232+corydouthat-sq@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:52:31 -0400 Subject: [PATCH 2/2] fix(windows): accept only Git for Windows layouts when discovering bash.exe Get-GitBashPath added every path returned by `where.exe bash` and only filtered out the WSL/Store stubs and Codex runtimes, so a Cygwin, MSYS2, or standalone bash.exe on PATH passed as the Git for Windows prerequisite even when Git Bash was absent. Add Test-GitForWindowsLayout, a pure check that accepts a bash.exe only when it sits at \bin\bash.exe or \usr\bin\bash.exe and \cmd\git.exe exists beside it. That file is shipped by every Git for Windows install (installed and portable) and by neither Cygwin nor MSYS2, so it positively identifies the layout instead of trusting PATH. The file-exists probe is injectable so the check is deterministic in tests. Get-GitBashPath now requires both the string filter and the layout check for every candidate; the git.exe-derived and where.exe candidates stay, since the layout check makes them safe. Tests cover Cygwin and MSYS2 rejection, portable Git acceptance, the machine-wide install, a Git-shaped tree missing cmd\git.exe, and a bash.exe outside a bin directory. Bootstrap and doctor messages name the locations that are searched and the bash flavors that do not count. Co-Authored-By: Claude Fable 5.1 --- scripts/windows/Bootstrap-Windows.ps1 | 2 +- scripts/windows/Doctor-Windows.ps1 | 2 +- scripts/windows/Test-WindowsDev.ps1 | 22 +++++++++++++++ scripts/windows/WindowsDev.psm1 | 39 ++++++++++++++++++++++++--- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/scripts/windows/Bootstrap-Windows.ps1 b/scripts/windows/Bootstrap-Windows.ps1 index e3df3e3f4..77eec017c 100644 --- a/scripts/windows/Bootstrap-Windows.ps1 +++ b/scripts/windows/Bootstrap-Windows.ps1 @@ -137,7 +137,7 @@ function Invoke-PrerequisiteEvaluation { $gitBash = $prereqs.GitBash.Path if (-not $prereqs.GitBash.Found) { - Add-Failure "Git Bash" "Git for Windows bash.exe was not found (the WSL and Microsoft Store bash.exe stubs do not count)" "Git.Git" + Add-Failure "Git Bash" "Git for Windows bash.exe was not found in an installed or portable Git, or beside the git.exe on PATH (WSL, Microsoft Store, Cygwin, and MSYS2 bash.exe do not count)" "Git.Git" } else { Add-Pass "Git Bash" $gitBash } diff --git a/scripts/windows/Doctor-Windows.ps1 b/scripts/windows/Doctor-Windows.ps1 index 3d6bfa2f9..0a394a080 100644 --- a/scripts/windows/Doctor-Windows.ps1 +++ b/scripts/windows/Doctor-Windows.ps1 @@ -70,7 +70,7 @@ Check-Command "git" "Run: just bootstrap-windows install" $prereqs.Git | Out-Nul $gitBash = $prereqs.GitBash.Path if (-not $prereqs.GitBash.Found) { - Fail "Git Bash" "Git for Windows bash.exe not found (the WSL and Microsoft Store bash.exe stubs do not count). Install Git for Windows with: winget install --id Git.Git -e" + Fail "Git Bash" "Git for Windows bash.exe not found in an installed or portable Git, or beside the git.exe on PATH (WSL, Microsoft Store, Cygwin, and MSYS2 bash.exe do not count). Install Git for Windows with: winget install --id Git.Git -e" } else { Pass "Git Bash" $gitBash } diff --git a/scripts/windows/Test-WindowsDev.ps1 b/scripts/windows/Test-WindowsDev.ps1 index 6437db007..1eef49afb 100644 --- a/scripts/windows/Test-WindowsDev.ps1 +++ b/scripts/windows/Test-WindowsDev.ps1 @@ -89,13 +89,35 @@ try { Assert-Equal "git bash filter accepts the machine-wide Git install" (Test-GitBashCandidatePath "C:\Program Files\Git\bin\bash.exe") $true Assert-Equal "git bash filter accepts a per-user Git install" ` (Test-GitBashCandidatePath "C:\Users\dev\AppData\Local\Programs\Git\usr\bin\bash.exe") $true + # Cygwin and MSYS2 ship bash.exe in the same bin\ and usr\bin\ shapes as + # Git for Windows, so the layout check must positively require the + # \cmd\git.exe that only Git for Windows (installed or portable) + # provides. The file-exists probe is injected so no real installs are + # needed and the outcome is deterministic on every machine. + $gitForWindowsFixture = { param($p) $p -in @("D:\PortableGit\cmd\git.exe", "C:\Program Files\Git\cmd\git.exe") } + Assert-Equal "git bash layout rejects Cygwin bash" ` + (Test-GitForWindowsLayout "C:\cygwin64\bin\bash.exe" -FileExists $gitForWindowsFixture) $false + Assert-Equal "git bash layout rejects MSYS2 bash" ` + (Test-GitForWindowsLayout "C:\msys64\usr\bin\bash.exe" -FileExists $gitForWindowsFixture) $false + Assert-Equal "git bash layout accepts portable Git derived from git.exe" ` + (Test-GitForWindowsLayout "D:\PortableGit\usr\bin\bash.exe" -FileExists $gitForWindowsFixture) $true + Assert-Equal "git bash layout accepts the machine-wide Git install" ` + (Test-GitForWindowsLayout "C:\Program Files\Git\bin\bash.exe" -FileExists $gitForWindowsFixture) $true + Assert-Equal "git bash layout rejects a Git-shaped tree without cmd\git.exe" ` + (Test-GitForWindowsLayout "C:\Program Files\Git\bin\bash.exe" -FileExists { param($p) $false }) $false + Assert-Equal "git bash layout rejects bash outside a bin directory" ` + (Test-GitForWindowsLayout "C:\tools\bash.exe" -FileExists { param($p) $true }) $false + Assert-Equal "git bash layout rejects blank paths" (Test-GitForWindowsLayout "" -FileExists { param($p) $true }) $false Assert-Equal "git bash lookup applies the filter to every candidate" ` ((Get-Command Get-GitBashPath -CommandType Function).Definition -match 'Test-GitBashCandidatePath \$candidate') $true + Assert-Equal "git bash lookup verifies the Git for Windows layout of every candidate" ` + ((Get-Command Get-GitBashPath -CommandType Function).Definition -match 'Test-GitForWindowsLayout \$candidate') $true Assert-Equal "git bash lookup derives candidates from git.exe" ` ((Get-Command Get-GitBashPath -CommandType Function).Definition -match 'Get-CommandSource "git"') $true $gitBashPath = Get-GitBashPath if (-not [string]::IsNullOrWhiteSpace($gitBashPath)) { Assert-Equal "git bash lookup result passes its own filter" (Test-GitBashCandidatePath $gitBashPath) $true + Assert-Equal "git bash lookup result is a Git for Windows layout" (Test-GitForWindowsLayout $gitBashPath) $true Assert-Equal "git bash lookup result exists" (Test-Path -LiteralPath $gitBashPath -PathType Leaf) $true } diff --git a/scripts/windows/WindowsDev.psm1 b/scripts/windows/WindowsDev.psm1 index 8caa69150..270329e37 100644 --- a/scripts/windows/WindowsDev.psm1 +++ b/scripts/windows/WindowsDev.psm1 @@ -1611,12 +1611,43 @@ function Test-GitBashCandidatePath { return $true } +function Test-GitForWindowsLayout { + # True only when a bash.exe path sits inside a Git for Windows tree. Git + # for Windows (installed or portable) places bash.exe at \bin\bash.exe + # and \usr\bin\bash.exe, and always ships \cmd\git.exe beside + # them. Cygwin (C:\cygwin64\bin\bash.exe) and MSYS2 + # (C:\msys64\usr\bin\bash.exe) use the same bin\ and usr\bin\ shapes but + # have no cmd\git.exe, so requiring that file positively identifies Git for + # Windows instead of trusting whichever bash.exe happens to be on PATH. + # $FileExists is injectable so the check is testable without the files. + param( + [AllowNull()][string]$BashPath, + [scriptblock]$FileExists = { param($p) Test-Path -LiteralPath $p -PathType Leaf } + ) + if ([string]::IsNullOrWhiteSpace($BashPath)) { + return $false + } + # Lazy root so `\usr\bin\bash.exe` yields , not \usr. + if ($BashPath -notmatch '^(?.+?)\\(usr\\)?bin\\bash\.exe$') { + return $false + } + # String concatenation instead of Join-Path: Windows PowerShell 5.1's + # Join-Path fails when the drive letter does not exist on this machine, + # which would break the pure check for injected fixtures. + $gitExe = $Matches['root'].TrimEnd('\') + '\cmd\git.exe' + return [bool](& $FileExists $gitExe) +} + function Get-GitBashPath { # Prefer Git for Windows' own bash.exe. Machine-wide and per-user (winget # user scope) install roots come first, then the root of whichever git.exe - # is on PATH, then every bash.exe PATH resolves to; stubs that cannot run - # scripts are filtered out. Returns $null when nothing usable exists so - # bootstrap and doctor report a real failure instead of the WSL launcher. + # is on PATH (covers portable Git), then every bash.exe PATH resolves to. + # Every candidate must pass two checks before it is accepted: the string + # filter that drops the WSL/Store stubs and Codex runtimes, and a positive + # Git for Windows layout check (\cmd\git.exe must exist beside it) so + # Cygwin, MSYS2, or a standalone bash.exe on PATH never passes as the Git + # Bash prerequisite. Returns $null when nothing usable exists so bootstrap + # and doctor report a real failure instead of an unrelated bash. $candidates = New-Object System.Collections.Generic.List[string] foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, (Join-Path (Get-LocalAppDataRoot) "Programs"))) { if (-not [string]::IsNullOrWhiteSpace($root)) { @@ -1645,7 +1676,7 @@ function Get-GitBashPath { } foreach ($candidate in ($candidates | Select-Object -Unique)) { - if ((Test-GitBashCandidatePath $candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { + if ((Test-GitBashCandidatePath $candidate) -and (Test-GitForWindowsLayout $candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return $candidate } }