用 C# 写的 Windows 独立 bash 解释器 csharpBash
Klinkenbecker/csharpBash
GitHub 项目 csharpBash 用 C# 实现了一个 Windows 独立 bash 解释器,无需 WSL、msys2 或 Cygwin,代码由 Claude 编写、测试和优化。
(The GitHub repo is named csharpBash — GitHub doesn't allow # in repository names.)
A standalone bash interpreter for Windows, written in C# — no WSL, no msys2, no Cygwin. It targets POSIX shell core plus the most-used bashisms, and runs the common Unix command set in-process so everyday scripts don't pay Windows' per-process spawn cost.
Provenance, stated plainly. The architecture was human-directed; the code was written, tested, and optimised by Claude (Anthropic). It was developed under Mercurial — the full commit history is in
MERCURIAL_HISTORY.md, and the real repository ships as themercurial-history.hgbundle (hg unbundle mercurial-history.hg). The story behind it: It's moosh all the way down.
What it does
Language: pipelines; lists (&&, ||, ;, &); redirections including fd-aware 2>, >>, <, &>, 2>&1, 1>&2, numbered descriptors (exec 3>f, exec 3>&1, >&3); Linux's device files (/dev/null, /dev/zero, /dev/urandom, /dev/stdin, /dev/stdout, /dev/stderr, /dev/fd/N, /dev/tty) as redirect targets, as file arguments and under test; here-documents and here-strings; command substitution $(…) and backticks (nested, isolated like a subshell); arithmetic $(( … )), (( … )) and for (( ; ; )) with assignment and ++; brace expansion {a,b} / {1..9}; tilde and pathname (glob) expansion with nullglob/dotglob/globstar/nocaseglob; single / double / $'…' quoting with bash's exact backslash rules.
Variables & structure: assignments and +=, export, local, readonly, declare -i/-a/-A/-r/-x/-p/-f, special parameters ($?, $#, $@, $*, $0…, $!, $-, PIPESTATUS, BASH_REMATCH, LINENO, FUNCNAME, BASH_SOURCE, BASH_LINENO, RANDOM, PPID, UID); the parameter-expansion operators (${x:-y} ${x:n:m} ${x#pat} ${x/pat/rep} ${x^^} ${!x} ${arr[@]:n} …); indexed and associative arrays; if / while / until / for / case; functions (both syntaxes); [[ … ]] with &&/||/( ), =~ and pattern matching; set -e/-u/-x/-o pipefail, shopt.
Paths: MSYS drive-letter form (/c/Users/…) and /tmp are understood alongside native C:\…, and the conversion is symmetric — pwd, $PWD, mktemp and realpath hand back E:/dir/file, a valid Windows path containing no backslashes. That matters because backslash is the shell's escape character: a path with backslashes in it is a value the shell's own ${p#…}, globs, case and [[ … ]] would misread.
Bytes: a shell string is a byte sequence, so printf '\377' emits one byte, not the UTF-8 encoding of a character, and data that is not valid UTF-8 survives variables, $(…), pipes and files intact. (It survives NUL too, which real bash cannot hold.)
Interactive & runtime: a readline-style line editor with history (~/.bash_history), tab completion, and PS1/PS2 prompts; startup-file sourcing (login + interactive); invocation flags (-c, -l/--login, --norc, --rcfile, script files, shebang dispatch); trap (EXIT/INT); background jobs; Ctrl-C handling.
Embedded commands
To avoid the Windows process-spawn cost, the common AT&T System V toolset is implemented in-process:
- Shell:
echoprintfcdpwdpushdpopddirsexportunsetreadonlysetshoptshiftreadmapfile/readarraygetoptstest/[typecommandbuiltinaliasunaliasevalexecsource/.localdeclare/typesetlettrapjobswaitfgbgkillsleepenvhistoryexitreturnbreakcontinuetruefalse: - Text:
catheadtailwcrevtactrcutuniqnlfoldpastecommseqsplitodsortteebase64md5sum/sha1sum/sha256sum/sha512sumhexdump - Files & dirs:
touchmkdirrmdirrmmvcpcmpdulsfindstatmktemprealpathreadlinkchmodlntruncatebasenamedirname - Search & transform:
grep/egrep/fgrepsedawk(a documented subset)jq(a jq 1.6 subset)diffxargsexprfactorcal - System:
date(incl.-d"3 days ago"-style parsing)unamehostnamearchnprocttywhoamiidprintenvtimeoutwhichkillhashpgreppkillps(Windows has no procps;pkill -fnever kills this shell's own ancestors)df(every drive, mapped shares included, in GNU's format)
Every tool parses its options strictly: an option (or awk construct) that is not implemented is
never silently ignored — the tool reports it and, when a real external of the same name is on
PATH, re-runs the command there (BASH_COREUTILS=builtin forbids that, =external prefers it).
When Git for Windows is installed, its usr/bin is put on PATH exactly as Git Bash does, so
tar, perl, ssh, … and any tool outside this list resolve the same way. grep and sed
default to POSIX BRE (-E/-r for ERE). Process substitution <( ) works (temp-file
emulation); >( ) is refused with a clear message. See ARCHITECTURE.md §10.
Performance
Measured on 2026-10-10 (build rev 179) on one Windows 10 machine against the two other bash shells available on it: Git for Windows bash 4.4 (bash on the MSYS2 POSIX emulation layer) and WSL 2 bash 5.1 (native Linux bash, reaching Windows files over /mnt). Same scripts, wall clock in seconds including process startup. Every shell is launched directly with CreateProcess, the way Claude Code launches one, and timed on the high-resolution clock by tools/benchtimer; C#Bash best of nine, the other two best of three; startup is the average over 100 consecutive launches. The C#Bash build is the one that runs every pipeline stage in its own subshell, as bash does. Reproduce with tests/bench/compare3.sh, after building the timer: dotnet build tools/benchtimer -c Release -o tools/benchtimer/bin.
| benchmark | C#Bash | Git Bash | WSL bash | vs Git | vs WSL |
|---|---|---|---|---|---|
loop — 200 k while iterations |
0.158 | 1.480 | 0.421 | 9.4× | 2.7× |
arith — 200 k $(( )) evaluations |
0.239 | 2.050 | 0.621 | 8.6× | 2.6× |
func — 100 k function calls |
0.204 | 2.289 | 0.572 | 11.2× | 2.8× |
loop_big — 2 M iterations |
1.367 | 14.956 | 3.392 | 10.9× | 2.5× |
coreutils — 900 basename/dirname/wc calls |
0.056 | 14.579 | 0.827 | 260× | 14.8× |
pipeline — grep|sed|sort|uniq|awk over 50 k lines |
0.242 | 0.259 | 0.118 | 1.1× | 0.5× |
find — walk 400 files |
0.021 | 0.123 | 0.210 | 5.9× | 10.0× |
startup — bash -c 'exit 0' |
0.016 | 0.016 | 0.110 | 1.0× | 6.9× |
The outputs of every row were compared and are identical in all three shells — a fast wrong answer would not count as a win.
Where it wins, and why. Interpreter throughput is 8–11× Git Bash and 2.5–2.8× native Linux bash. Startup is level with Git Bash (16 ms each) and seven times faster than WSL. The coreutils row is the project's premise made visible: 900 tool invocations that are 900 CreateProcess calls under Git Bash and zero under C#Bash.
Where it loses, and why. One row: a long streaming text pipeline against WSL, where native C grep/sed/sort beat managed implementations per line. It does beat Git Bash there. C#Bash is fastest where a script does many small things; a pipe pushing 50 k lines through five real tools is not that shape.
Caveats worth stating, and they are in the harness rather than glossed. One machine, one run, no statistical treatment: the startup row is a single total of 100 launches, and on this machine it moves by about ±1.5 ms from run to run, so a difference that small between two runs is noise. WSL reaches these files over the 9P /mnt bridge, a real cost of using it on Windows files, and the filesystem rows are marked. The pipeline row also writes its input to $TMPDIR, which for WSL is ext4 inside the VM and for the other two is the Windows temp directory — so that row hands WSL a filesystem advantage as well as a tool-speed one. Git Bash's bash is 4.4 where WSL's is 5.1. Git Bash's own times move between runs (loop_big took 18.9 s on 2026-10-03 and 15.0 s here), and the "vs Git" ratios move with them. These numbers are for the Native AOT build; the managed builds start in 68–74 ms and are about 11 % faster than AOT past roughly 1.2 M shell operations in one invocation.
Architecture
A classic four-stage pipeline: lexer → parser → AST → tree-walking evaluator.
ARCHITECTURE.md— the conceptual model (including how it maps onto GNU bash's own modules, and where Windows forces it to diverge).IMPLEMENTATION.md— the class/method map and an end-to-end walk-through.DECISIONS.md— the append-only design log.PROJECT_CONTEXT.md— current state and scope.
Build · run · test
dotnet build -c Release # builds Bash.sln (.NET 8 SDK) Bash/bin/Release/net8.0/Bash.exe # run it (or: Bash.exe -c '<command>') pwsh tests/run-tests.ps1 # 101 self-checking cases pwsh tests/compat/run-compat.ps1 # 237-probe differential battery against a reference bash
The test suite's expected outputs are never captured from the interpreter: most are GNU bash's own output (bash 5.1, run under WSL, the reference every behaviour is matched against), the rest are authored from bash's documented behaviour where a case is Windows-specific — so a regression fails rather than being silently baked in. Windows-only.
Using it as Claude Code's shell
Claude Code on Windows needs a bash.exe; it finds Git for Windows by default, or whatever CLAUDE_CODE_GIT_BASH_PATH names. C#Bash is built to be that shell (with or without Git installed):
-
Publish the Native AOT build somewhere the build never touches:
tools\publish-aot.cmd REM -> dist\Bash.exe, ~6.8 MBOne static executable. No .NET runtime on the target, and ~16 ms startup — level with Git Bash — which matters because Claude Code spawns a shell for every tool call. The script exists because AOT needs two things on the build machine that are easy to get wrong: the MSVC C++ toolchain (
vcvars64.bat) andvswhere.exeon PATH, which the vcvars script does not add. Read the comment at the top of it before concluding your toolchain is missing.The managed alternatives, if you cannot or would rather not build AOT:
# self-contained, no runtime needed on the target: ~79 MB, ~74 ms dotnet publish Bash/Bash.csproj -c Release -r win-x64 \ -p:PublishSingleFile=true -p:PublishReadyToRun=true -p:DebugType=none -o dist # framework-dependent, smallest download, needs .NET 8 installed: ~1.7 MB, ~68 ms dotnet publish Bash/Bash.csproj -c Release -r win-x64 -p:SelfContained=false \ -p:PublishSingleFile=true -p:PublishReadyToRun=true -p:DebugType=none -o dist
ReadyToRun keeps the runtime and its JIT, so it is about 11 % faster than AOT in steady state — but only past roughly 1.2 million shell operations in a single invocation, which no Claude Code tool call approaches. Pointing Claude Code at
bin\Releaseis a bad idea either way: every rebuild replaces the file under a live session, and the test runner kills stale interpreter processes before building. -
Set the variable for your user and restart Claude Code:
setx CLAUDE_CODE_GIT_BASH_PATH "C:\path\to\Bash.exe" -
To go back to Git Bash, remove the variable (
setxto an empty value, or System Properties → Environment Variables) and restart Claude Code.
Bash.exe --version prints a third line, C#Bash build 1.0.0+hg.<hash> <rev>, stamped from the Mercurial revision at build time (a trailing + means an uncommitted tree) — quote it in any bug report so the build can be identified.
Reproduce from a script file, not through PowerShell. Windows PowerShell 5.1 strips the inner double quotes when it hands -c 'echo "x (y) z"' to a native executable, so the shell receives echo x (y) z — and then correctly reports a syntax error. A report observed only through & Bash.exe -c "..." from PowerShell measures PowerShell's quoting, not the shell (2026-09-05, one full false report).
Claude Code's per-command wrapper, its shell snapshot (bash -c -l), cwd tracking, heredocs, process substitution, timeout, and the rg/pkill shims it defines have all been verified end to end, both with Git on PATH and on a PATH with no Git at all (PROJECT_CONTEXT.md, P5).
Layout
Bash/ interpreter source (Lexer/ Parser/ Evaluator/ IO/) + Bash.csproj
docs/ architecture diagrams (SVG) + bash startup reference
tests/ PowerShell test runner + cases/ and expected/; compat/ (the differential battery); bench/compare3.sh (the benchmark)
tools/ publish-aot.cmd (the ship build); benchtimer (the benchmark's launcher and clock); bench.csx, attrib.csx
*.md README + the four project docs
mercurial-history.hg full Mercurial history (hg bundle)
On Mercurial
This was built in Mercurial, by preference. The git repository you're reading is a published snapshot; the genuine, commit-by-commit record lives in hg, preserved here in full as MERCURIAL_HISTORY.md and the mercurial-history.hg bundle — hg unbundle mercurial-history.hg reconstructs the lot.
License
MIT.
来源:GitHub · github.com