Goal: type .\some-script.sh in a Windows Terminal PowerShell tab and have it run in that same window using Git Bash — instead of a separate Git Bash window that flashes open and closes.
Tested on Windows 10/11, Windows PowerShell 5.1 and PowerShell 7, with Git for Windows.
Two ways, pick one (or both):
- Explicit helper — call a
gbashfunction:gbash ./scripts/build.sh - Transparent hook (recommended) — a
$PROFILEhook so.\scripts/build.shjust works like any command.
Both run Git Bash as a console child process in the current tab, not as a popup window, and not WSL.
When you run .\build.sh in PowerShell, .sh is not in $env:PATHEXT, so PowerShell doesn't treat it as an executable. It falls back to the Windows file association for .sh, which Git for Windows points at git-bash.exe — and git-bash.exe opens a new terminal window (then closes when the script ends).
The fix is to invoke bash.exe (a console program) instead of git-bash.exe (a window launcher):
git-bash.exe # opens a NEW window
& "C:\Program Files\Git\bin\bash.exe" -lc "…" # runs INLINE in the current console& is PowerShell's call operator (needed because the path has spaces). -l = login shell (sources your profile so e.g. docker is on PATH); -c = run the following command string.
On a typical machine there are three bash.exe on PATH, and a bare bash resolves to the first one:
C:\Windows\system32\bash.exe ← WSL bash (WRONG for these scripts)
C:\Program Files\Git\bin\bash.exe ← Git Bash (what you want)
C:\Users\<you>\AppData\Local\Microsoft\WindowsApps\bash.exe ← MS-Store WSL stub
system32\bash.exe is WSL, not Git Bash. Scripts written for Git Bash (MSYS/MinGW path handling, Docker Desktop on Windows, python vs python3 quirks) misbehave under WSL. Always pin the full Git path — never rely on a bare bash.
Tell them apart at runtime: Git Bash mounts your drive as
/c,/e, … (pwd→/e/proj). WSL mounts as/mnt/c,/mnt/e(pwd→/mnt/e/proj).
Put this in your PowerShell profile (notepad $PROFILE; create it first with New-Item -ItemType File -Force $PROFILE if missing):
function gbash {
param([Parameter(ValueFromRemainingArguments = $true)][string[]] $Command)
& "C:\Program Files\Git\bin\bash.exe" -lc ($Command -join ' ')
if ($LASTEXITCODE) { Write-Error "bash exited $LASTEXITCODE" }
}Usage:
gbash ./scripts/build.sh
gbash "git status && ./scripts/deploy.sh --force"Simple and explicit. The downside: you have to prefix with gbash — .\build.sh alone still pops a window.
This makes .\anything.sh run inline automatically, with no prefix. It uses PowerShell's PreCommandLookupAction, which fires before PowerShell falls back to the file-association popup, and reroutes any *.sh command to Git Bash.
Add to your $PROFILE:
# >>> sh-inline hook >>>
# Run *.sh scripts inline via Git Bash (not a popup window, not WSL).
# To disable: delete this block, or run
# $ExecutionContext.InvokeCommand.PreCommandLookupAction = $null
$ExecutionContext.InvokeCommand.PreCommandLookupAction = {
param($CommandName, $eventArgs)
if ($CommandName -like '*.sh') {
$scriptPath = $CommandName
$eventArgs.CommandScriptBlock = {
$resolved = Resolve-Path -LiteralPath $scriptPath -ErrorAction SilentlyContinue
$p = if ($resolved) { $resolved.Path -replace '\\','/' } else { $scriptPath -replace '\\','/' }
& 'C:\Program Files\Git\bin\bash.exe' -l $p @args
}.GetNewClosure()
$eventArgs.StopSearch = $true
}
}
# <<< sh-inline hook <<<Reload (. $PROFILE) or open a new tab, then:
.\scripts\build.sh # inline, in this window
.\scripts\local-start.sh --force # extra args pass straight throughPreCommandLookupActionruns for every command lookup. When the command ends in.sh, it replaces the invocation with an inline Git Bash call and stops the normal search (StopSearch = $true) — so the popup association never fires.- Path fix:
Resolve-Pathturns.\scripts\build.shinto an absolute path, and-replace '\\','/'swaps backslashes for forward slashes so Git Bash doesn't treat\as an escape (C:/proj/scripts/build.shis accepted as-is). .GetNewClosure()captures the script path per-invocation;@argsforwards your arguments;$LASTEXITCODEpropagates the script's exit code back to PowerShell.-lloads the login profile (sodocker, etc. are onPATH); the Git path is pinned, so it never falls through to WSL.
- PowerShell 5.1 vs 7 have different
$PROFILEpaths. If you use both, add the block to each:- 5.1:
…\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 - 7:
…\Documents\PowerShell\Microsoft.PowerShell_profile.ps1 - Check yours with
echo $PROFILEin each. (In Windows Terminal, the built-in "Windows PowerShell" profile is 5.1; "PowerShell" is 7.)
- 5.1:
- Invoke the script as a path PowerShell recognizes:
.\foo.sh, a full path, orfoo.shin the current directory. A bare name not in the cwd won't resolve. - It only affects PowerShell (it's in your profile).
cmd.exeand other shells are untouched. - If Git isn't at
C:\Program Files\Git, update that one path. - WSL vs Git Bash for the script itself: Git Bash covers
sed/awk/grep/find/cp/rm/mkdir -p, etc. If a script needs Linux-only things (apt,systemctl,/proc, real symlinks, Docker-in-Linux), use WSL2 instead.
# Quick check that a .sh runs inline and reports Git Bash (not WSL):
Set-Content .\_t.sh "echo \"inline ok: bash=$(command -v bash) cwd=$(pwd)\""
.\_t.sh # expect: cwd=/c/... or /e/... (NOT /mnt/...)
Remove-Item .\_t.shDelete the # >>> sh-inline hook >>> … <<< block from your $PROFILE (and/or the gbash function), then open a new tab. To disable for just the current session:
$ExecutionContext.InvokeCommand.PreCommandLookupAction = $null