@@ -255,121 +255,5 @@ Reuses the universal rules:
255255
256256## Cross-platform shell (Windows support)
257257
258−**Status:** not implemented.
259−
260−### Problem
261−
262−Today the hook runner uses `exec.Command("sh", "-c", hook.Command)`. On Windows
263−this fails without WSL or Git Bash on PATH. Even with `sh.exe` available,
264−Windows has no kernel shebang handling — `./hooks/foo.sh` can't be exec'd
265−directly the way it can on Unix. Hooks are effectively Unix-only.
266−
267−### Approach
268−
269−Keep the `command` field as a string. Tokenize it shell-style, examine
270−`argv[0]`, and branch:
271−
272−- If `argv[0]` starts with `./`, `../`, `/`, or `~/` — treat it as a **file
273− invocation**. Read the first ≤128 bytes, parse a shebang if present, and
274− dispatch to the named interpreter via `os/exec`. Extra args from the command
275− string pass through to the interpreter.
276−- Otherwise — treat the whole string as **shell code** and hand it to mvdan's
277− in-process interpreter. mvdan resolves `node`, `bash`, `jq`, builtins,
278− pipelines, redirects, etc. via its own exec handler.
279−
280−No sentinel: a script with no shebang defaults to mvdan. A script with an
281−explicit shebang (`#!/bin/bash`, `#!/usr/bin/env python3`, etc.) uses the named
282−interpreter, which the user is responsible for having on PATH. Same contract on
283−every platform.
284−
285−### Dispatch examples
286−
287−| `command` | `argv[0]` | Route |
288−| ---------------------------------------- | -------------- | ------------------------ |
289−| `ls -la` | `ls` | mvdan |
290−| `bash -c 'ls'` | `bash` | mvdan (which execs bash) |
291−| `node ./script.js` | `node` | mvdan (which execs node) |
292−| `./script.sh` (no shebang) | `./script.sh` | mvdan, fed the file |
293−| `./script.sh` (`#!/bin/bash`) | `./script.sh` | `bash ./script.sh` |
294−| `./script.py` (`#!/usr/bin/env python3`) | `./script.py` | `python3 ./script.py` |
295−| `./script.exe` | `./script.exe` | `os/exec` direct |
296−
297−### Contract on Windows
298−
299−- Inline shell runs through mvdan natively. No external dependency.
300−- Shebang-dispatched scripts require the named interpreter on PATH (`bash.exe`,
301− `python.exe`, `node.exe`, etc.). Crush does the dispatch that the Windows
302− kernel won't.
303−- Shebang-less scripts run through mvdan regardless of extension. CRLF line
304− endings are tolerated.
305−
306−### Implementation sketch
307−
308−- New function
309− `dispatch(ctx, cmd string, env []string, stdin io.Reader) (stdout, stderr string, exitCode int, err error)`
310− in `internal/hooks/`.
311−- Tokenize using mvdan's parser (already a dep) for consistent quoting/escape
312− behavior with shell intuition.
313−- Path-prefix check on `argv[0]`; if path, read shebang with a bounded
314− `io.LimitReader` and parse. Support:
315− - `#!/absolute/interpreter args…`
316− - `#!/usr/bin/env NAME` → resolve `NAME` on PATH
317− - `#!/usr/bin/env -S NAME args…` → treat as above; `-S` is common enough to
318− handle. Other `env` flags can error.
319−- Unified exit-code helper. mvdan's `interp.ExitStatus` and `os/exec`'s
320− `ProcessState.ExitCode()` both become a single `int`.
321−- Context cancellation: mvdan's exec handler uses `exec.CommandContext` for its
322− children, so a cancelled hook kills both the interpreter and any children.
323− Verify with a `sleep 60` test.
324−- One fresh `interp.Runner` per hook invocation (parallel hooks must not share
325− state).
326−
327−### Swap the call site
328−
329−`Runner.runOne` in `internal/hooks/runner.go` replaces its
330−`exec.Command("sh", "-c", …)` with a call to `dispatch(…)`. Everything
331−downstream (exit-code 2 / 49 / other dispatch, stdout JSON parsing,
332−stderr-as-reason) stays identical.
333−
334−### Tests
335−
336−Cross-platform matrix:
337−
338−- Inline: `echo hi`; `exit 2`; pipelines; redirections.
339−- File, no shebang: treated as shell source through mvdan.
340−- File, `#!/bin/bash` on Unix — invokes system bash.
341−- File, `#!/usr/bin/env python3` — invokes python if present, skips if not.
342−- File, `#!/usr/bin/env -S node --foo` — extra flags preserved.
343−- File with CRLF line endings in the shebang.
344−- `./missing-file` — non-blocking error, hook proceeds as "no opinion".
345−- Timeout: hook that sleeps past its timeout gets killed; context cancellation
346− kills the interpreter and its children.
347−- Concurrency: 10 hooks in parallel don't leak env/cwd/state between runners.
348−- Windows-specific: `./script.exe` exec'd directly; bash-shebang script fails
349− gracefully when bash isn't on PATH.
350−
351−### Pitfalls to watch
352−
353−- **Userland shebang parsing is now our problem.** Edge cases around `env`
354− flags, args with spaces, CRLF, missing interpreter. Well-trodden but needs
355− real tests.
356−- **The path-prefix heuristic is a heuristic.** `relative/path.sh` (no leading
357− `./`) gets mvdan'd, not file-dispatched. Matches shell intuition — at a bash
358− prompt, `relative/path.sh` doesn't run unless `.` is on PATH — but worth
359− documenting.
360−- **Kernel shebang handling is bypassed on Unix.** Today a chmod+x'd script is
361− exec'd by the kernel; after this change, by our parser. Behavior should be
362− byte-identical; verify with tests.
363−- **Two code paths.** mvdan vs direct-exec. Exit codes, stdin, signal
364− propagation, env inheritance must be aligned. Discipline, not cleverness.
365−
366−### Explicit non-goals
367−
368−- No bundled `bash.exe` or `python.exe`. Users bring their own interpreters.
369−- No custom mvdan builtins (`crush_approve` etc.). Hooks stay portable and
370− testable under bare `bash`.
371−- No `.sh`-extension filter on discovery. Hook file shape is driven by shebang,
372− not filename.
373−- No Crush-as-script-interpreter mode (users can't write `#!/usr/bin/env crush`
374− and have it mean something). If we want that later, it's an additive feature,
375− not a dependency of this work.
258+**Status:** implemented. See the [Execution model](README.md#execution-model)
259+section in `README.md` for the current behavior and contract.