diff --git a/scripts/windows/Bootstrap-Windows.ps1 b/scripts/windows/Bootstrap-Windows.ps1 index 5474158ab..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" "bash.exe was not found under Program Files\Git" "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 2f7935482..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" "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 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 b4bdb38e9..1eef49afb 100644 --- a/scripts/windows/Test-WindowsDev.ps1 +++ b/scripts/windows/Test-WindowsDev.ps1 @@ -78,6 +78,49 @@ 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 + # 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 + } + 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..270329e37 100644 --- a/scripts/windows/WindowsDev.psm1 +++ b/scripts/windows/WindowsDev.psm1 @@ -1589,21 +1589,98 @@ 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 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 { - $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 (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)) { + $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-GitForWindowsLayout $candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return $candidate } } - return (Get-CommandSource "bash") + return $null } function Get-VsWherePath {