Skip to content

fix(plugin): run the Codex Windows hooks through Git Bash, not the WSL launcher - #4008

Merged
max-sixty merged 9 commits into
mainfrom
fix/issue-4007
Sep 6, 2026
Merged

max-sixty merged 9 commits into
mainfrom
fix/issue-4007

Conversation

@worktrunk-bot

@worktrunk-bot worktrunk-bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Codex resolves a hook command through the platform shell — /bin/sh -lc on Unix, cmd.exe /C on Windows (default_shell_command). All four Codex hooks in the plugin manifest lead with a bare bash, and under cmd.exe that resolves through the Windows PATH to System32\bash.exe — the WSL launcher, not Git Bash. In a sandboxed session the launcher refuses to start (Access is denied. Error code: Bash/Service/CreateInstance/E_ACCESSDENIED), so every prompt, permission request, turn end, and session end raises a Hook failed banner (#4007).

Solution

Git for Windows stays a requirement; only the bare name bash is the problem, since cmd.exe resolves it through PATH. Each hook now also carries Codex's per-handler commandWindows, which replaces command on Windows (command_windows.unwrap_or(command) in codex-rs/hooks/src/engine/discovery.rs). It calls a new cmd.exe shim, plugins/worktrunk/hooks/wt.cmd, that locates bash.exe by path the way find_git_bash does in src/shell_exec.rs — derived from git.exe's install directory, then the system-wide and per-user install defaults — and then runs the same hooks/wt.sh that Claude, Gemini, and Unix Codex already go through. Worktrunk's own binary resolution stays in that one script instead of being spelled a second time in cmd.

Two details the shim inherits from the Rust resolver: Git\bin\bash.exe before Git\usr\bin\bash.exe, because the former is the wrapper that sets up the MSYS environment for a caller outside Git Bash (which is what puts uname within reach of wt.sh); and a PATH-scoped lookup, because an unscoped where searches the current directory first — for a hook that's the user's project, so a git.exe committed to a repo would otherwise choose the bash every event runs. The lookup is spelled "%SystemRoot%\System32\where.exe" "$PATH:git.exe" on both counts: the $PATH: prefix scopes what is searched, and the absolute path to where.exe closes the same surface one level up — where is System32\where.exe rather than a cmd built-in, so cmd resolves that bare name from the current directory too.

wt.sh now clears WT before its branches. On Windows all of them can be skipped (neither git-wt.exe nor wt on PATH), and a hook is handed the caller's whole environment, so an inherited WT was what the final command -v "$WT" check accepted and ran.

The Windows commands brace the plugin root as ${PLUGIN_ROOT} because Codex substitutes only that form textually, before the shell runs; the unbraced $PLUGIN_ROOT the Unix commands use survives to /bin/sh, and cmd.exe would pass it through literally. The tail is || exit /b 0, the cmd.exe spelling of the Unix || true: a marker is decoration, and a nonzero exit is what raises the banner. .gitattributes pins plugins/worktrunk/hooks/*.cmd to a CRLF checkout, since cmd.exe resolves a goto label by seeking through the file and can fail that search on an LF-only batch file.

Two doc changes ride along. plugins/worktrunk/CLAUDE.md records why every hook carries commandWindows, the shim's resolution order and the two lookup-scoping decisions, and why SessionEnd keeps timeout: 3 (Codex's ceiling for that event, so the longer Windows chain has no more budget to ask for). And because the CHANGELOG entry opens an ## Unreleased section, .claude/skills/release/SKILL.md step 9 now says to rename that heading at release time rather than insert a new one above it — otherwise the release ships a stale ## Unreleased.

Testing

Six tests, all new:

  • test_codex_hooks_carry_windows_commands (all platforms) — pins that every Codex command hook has a commandWindows, that it names neither bash nor bare wt, that it calls the shim, and that the two PLUGIN_ROOT spellings stay on their respective sides. Written first: it failed on the manifest as shipped, with the exact hook command from the report.
  • test_codex_windows_hook_commands_set_the_marker (Windows leg of CI) — runs the real commandWindows the way Codex spawns it, reproducing both steps: the ${PLUGIN_ROOT} substitution and cmd.exe /C "<command>" with the command line as a single quoted raw argument. It asserts UserPromptSubmit stores 🤖, Stop replaces it with 💬, SessionEnd clears it, and that a hook which cannot find worktrunk still exits 0. PATH is pinned to the shape a default Git for Windows install produces — Git\cmd and nothing else from the install — so the run covers cmd.exe's quote handling, the shim's search for bash, wt.sh running under the bash it picks, and the emoji surviving both hops.
  • test_wt_sh_ignores_an_inherited_wt (Windows leg) — points WT at a real worktrunk, spelled the way bash can run it, on a PATH where wt.sh finds none itself, and pins that the inherited value is not what runs.
  • test_shim_ignores_a_git_in_the_current_directory (Windows leg) — plants an unrunnable git.exe in the hook's current directory plus the bash.exe the shim would derive from it, puts a real Git and worktrunk on the pinned PATH, and asserts the shim prints a version line — which it could not do had it taken the decoy.
  • test_shim_ignores_a_where_in_the_current_directory (Windows leg) — the same question one level up: a where.bat in the hook's current directory names a decoy Git install whose bin\bash.exe exists, so an unscoped where would reach the derive branch and leave BASH set and unrunnable rather than falling through to a real install. A separate probe pins the premise — that the planted where really does shadow System32\where.exe — so the test cannot go green because the decoy was never consulted.
  • test_shim_derives_bash_from_the_git_on_path (Windows leg) — points ProgramFiles and LOCALAPPDATA at an empty directory, which leaves the derive branch as the only route to a bash. The two tests above go red only when the shim picks the wrong bash; this one goes red when the lookup finds nothing, so it is what observes that "%SystemRoot%\System32\where.exe" "$PATH:git.exe" survives the quoting for /f wraps it in.
What this does not verify

Nothing here drives a real Codex session on Windows, so the end-to-end claim — that Codex selects commandWindows and that the banner stops — rests on reading codex-rs/hooks/src/engine/{discovery,command_runner}.rs rather than on observation. What CI does exercise is the command string itself, executed the way that source says Codex executes it.

The shim's two standard-install fallbacks (%ProgramFiles%\Git, %LOCALAPPDATA%\Programs\Git) are unexercised by CI. Every test that reaches a bash takes the derive branch above them, and the one test that touches the fallbacks empties them to force that branch rather than to exercise them; they share the whole tail with it.

Two adjacent things are deliberately left alone, as separate concerns: the Gemini hooks at the repo-root hooks/hooks.json use the same bare bash (Gemini's Windows hook execution isn't established here), and Claude's hooks/hooks.json is unaffected because Claude Code runs hook commands through Git Bash on Windows.


Closes #4007 — automated triage

Codex resolves a hook `command` through the platform shell — `/bin/sh -lc`
on Unix, `cmd.exe /C` on Windows. Under cmd.exe the leading bare `bash`
resolves through the Windows PATH to System32\bash.exe, the WSL launcher
rather than Git Bash, which refuses to start in a sandboxed session
(Bash/Service/CreateInstance/E_ACCESSDENIED). Every prompt, permission
request, turn end, and session end then raised a "Hook failed" banner.

Each hook now carries Codex's per-handler `commandWindows`, which replaces
`command` on Windows. It calls a new cmd.exe shim, hooks/wt.cmd, that
mirrors wt.sh's resolution — WORKTRUNK_BIN, else git-wt.exe, else a `wt.exe`
PATH entry that isn't Windows Terminal's app alias — and tails with
`|| exit /b 0` so a missing install stays best-effort, as `|| true` does on
Unix.

Closes #4007
@worktrunk-bot worktrunk-bot added the automated-fix Automated CI fix label Sep 4, 2026

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the mechanism against the upstream sources the PR cites, since the whole change rests on them: HookHandlerConfig::Command in codex-rs/config/src/hook_config.rs carries #[serde(default, rename = "commandWindows", alias = "command_windows")], discovery.rs does command_windows.unwrap_or(command) under cfg!(windows), its substitution fold is command.replace(&format!("${{{key}}}"), value) — braced form only — and command_runner.rs spawns cmd.exe /C with raw_arg(format!(r#""{command_line}""#)). All four claims hold, and the enum has no deny_unknown_fields, so an older Codex ignores the new key rather than failing the manifest. test (windows) is green on this head, which is what exercises the cmd.exe quoting, the goto resolution, and the || exit /b 0 tail.

One finding, inline: the shim doesn't clear its own locals, so an inherited WT bypasses the PATH scan it exists to perform.

Comment thread plugins/worktrunk/hooks/wt.cmd
…ass the PATH scan

setlocal scopes writes but inherits the caller's environment, and Codex hands each hook the session env snapshot, so a user's own WT arrives defined. The PATH scan then never runs: 'if not defined WT' is false on every iteration, 'if defined WT goto :run' fires, and the shim executes the inherited value — skipping the Windows Terminal rejection that block exists for. WT_SEEN had the mirror problem, picking the 'resolves to Windows Terminal' message when nothing was found.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inherited-WT finding from the last pass is addressed in c9a1721, and test_shim_ignores_an_inherited_wt is a real regression test for it — it fails on the pre-fix shim, and it stays honest about the runner's own PATH by pinning "the sentinel did not run" rather than "worktrunk ran".

Two things from this pass, both inline. The one worth a look before merge is the shim's binary resolution: where and cmd's bare-name lookup both reach the current directory, which is the repo the hook is running in — that is new surface relative to wt.sh, whose command -v is PATH-only. It is my own judgement that this wants a human rather than a repo rule; the deletion-surface hold in the review guidance doesn't cover it, since nothing here can delete anything. The other is the changelog entry.

Self-authored, so no approval either way. test (linux|macos|windows) and code-coverage were still in flight at this head; nothing had gone red.

Comment thread plugins/worktrunk/hooks/wt.cmd Outdated
Comment thread CHANGELOG.md Outdated
`where git-wt.exe` searches the current directory before PATH, and cmd
resolves the bare `git-wt.exe` that `:gitwt` stored the same way. A hook
runs with the user's project as its current directory, so a `git-wt.exe`
-- or a `wt.exe`, through the scan below it -- committed to a repo was
what every prompt, permission request, stop, and session end executed.
`wt.sh` has no such surface: `command -v` consults PATH only.

Both lookups now use `where`'s `$PATH:` prefix, which searches only the
directories that variable names, and `:run` executes the absolute path
they return, so nothing re-resolves a bare name. `:gitwt` is gone with
its bare-name store.

`test_shim_ignores_a_binary_in_the_current_directory` plants decoys in
the cwd and a real worktrunk on PATH; the decoys are not valid
executables, so a version line pins that the shim resolved through PATH.

Also folds in the review's changelog edit -- the entry cites its own PR
and comes back under the 60-word ceiling -- and teaches the release
skill to rename an existing `## Unreleased` heading rather than insert
`## X.Y.Z` above it, which would ship the stale heading in the notes.
The new cwd test asserted the `--version` output carried
CARGO_PKG_VERSION. CI's checkout embeds a bare commit hash instead
(`wt ad1a14b`), so the assertion failed on the Windows leg even though
the shim resolved correctly through PATH. Pin the `wt ` prefix, which
is the part a decoy could never produce.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test (windows) was red at fd89ee1b on this PR's own new test, and 9e7458d3 fixes it correctly — starts_with("wt ") is the pin test_mock_dispatch_ignores_degenerate_argv0 in tests/integration_tests/init.rs already uses for the same question. Recording why it failed, since the old assertion could never have passed here: version_str() in src/cli/mod.rs resolves option_env!("VERGEN_GIT_DESCRIBE") first and falls back to CARGO_PKG_VERSION only when the build script couldn't set it — the crates.io-package case, not CI.

That failed run is also the evidence the $PATH: prefix was still missing. The shim exited 0 and printed wt ad1a14b, so where "$PATH:git-wt.exe" resolved to the copy on the pinned PATH and not to the invalid git-wt.exe decoy in the cwd — had it taken the decoy, cmd could not have run it at all. The syntax no longer rests on the where documentation; it ran, and 4332 of 4333 tests passed on that leg with nothing else red.

One finding on this pass, inline: eight lines in the new test that restate a guarantee std::process::Command already gives.

Self-authored, so no approval either way. CI at 9e7458d3 is still in flight.

Comment thread tests/integration_tests/config_show.rs Outdated
std::process::Command's environment map is case-insensitive on Windows (EnvKey compares with CompareStringOrdinal, bIgnoreCase = TRUE), so .env("PATH", ...) replaces an inherited `Path` rather than landing beside it. The vars_os scan restated that guarantee.
@max-sixty

Copy link
Copy Markdown
Owner

but don't we already require git bash? and so avoiding using bash seems unnecessary?

(I thought I asked this before so apologies if this was answered somewhere else)

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

We do require Git for Windows, and this PR doesn't relax that — wt's own hook and alias bodies still run through Git Bash. What breaks on Windows isn't whether bash exists; it's whether the bare name bash resolves to it. Codex spawns hook commands through cmd.exe /C, so bash is a Windows PATH lookup: Git for Windows' default install puts Git\cmd on PATH (which holds git.exe, not bash.exe), while WSL puts C:\Windows\System32\bash.exe there — so on a machine with WSL, the name lands on the WSL launcher with Git Bash installed and working, which is exactly what @McNultyyy hit in #4007.

We already treat those as two different claims elsewhere in the codebase: find_git_bash deliberately does not use which bash, deriving bash.exe from git.exe's install directory instead, with the same WSL reason in its doc comment. That was a real bug in wt's own hook execution once (b48b0ba); the Codex manifest is the one place still spelling bash as a bare name for a shell that resolves it by PATH. Claude's hooks are fine as they are because Claude Code runs hook commands through Git Bash itself rather than cmd.exe.

The bash-preserving alternative is real, and I'm happy to switch if you prefer its shape: commandWindows locates Git Bash and hands off to wt.sh as today. It costs about the same, just placed differently — details below.

Why the shim rather than locating Git Bash

To keep wt.sh, the Windows command has to find Git Bash by path, which means a cmd script doing roughly the three-candidate search find_git_bash does in Rust: derive ..\bin\bash.exe / ..\usr\bin\bash.exe from where git.exe, then %ProgramFiles%\Git\bin, then %LOCALAPPDATA%\Programs\Git\bin for a per-user install (#1259 was that last one). So it's a cmd shim either way — the choice is what the shim resolves, not whether there is one.

Given that, resolving the wt binary directly is the shorter path: each hook body is a single wt config state marker set … call with no shell syntax in it, so the bash hop buys nothing but a process launch, MSYS path translation on ${PLUGIN_ROOT}, and a Git Bash dependency for the marker hooks specifically. wt.cmd mirrors what wt.sh already decides on Windows (WORKTRUNK_BIN, else git-wt.exe, else a wt.exe that isn't Windows Terminal's alias), and the where "$PATH:…" prefix keeps the lookup off the hook's current directory.

The trade against it: wt.sh's Windows branch and wt.cmd now encode the same resolution order in two languages, and they can drift. If you'd rather have the single resolver, the Git-Bash-locating variant is the version to take, and the four tests on this PR port over to it with only the command string changing.

On "I thought I asked this before" — I don't find it on this PR or #4007, and no other thread I can see covers it, so nothing was dropped; it just hadn't been asked yet.

All checks are green at 86d3115, including test (windows) and codecov/patch.

@max-sixty

Copy link
Copy Markdown
Owner

The bash-preserving alternative is real, and I'm happy to switch if you prefer its shape: commandWindows locates Git Bash and hands off to wt.sh as today. It costs about the same, just placed differently — details below.

this seems reasonable, arguably simpler, but up to you if you've thought about it a lot

Replaces the cmd.exe shim's own worktrunk resolution with the Git Bash
resolution `find_git_bash` performs in src/shell_exec.rs, then hands off
to the same `wt.sh` every other integration runs. Only the bare name
`bash` was the problem under cmd.exe -- Git for Windows is a requirement
either way -- so worktrunk's own resolution order now lives in one
script rather than in two languages.

`wt.sh` clears `WT` up front: on Windows every branch that assigns it
can be skipped, and a hook is handed the caller's whole environment, so
an inherited `WT` was what the final check accepted and ran.
@worktrunk-bot worktrunk-bot changed the title fix(plugin): give the Codex hooks a Windows command that avoids WSL bash fix(plugin): run the Codex Windows hooks through Git Bash, not the WSL launcher Sep 6, 2026

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The switch to the bash-preserving shape reads well, and the shim tracks find_git_bash faithfully: same derive-from-git.exe primary, same Git\bin before Git\usr\bin, same two install fallbacks — with %ProgramFiles% where the Rust version hardcodes C:\Program Files, which is the better of the two.

One finding, inline: where is itself still spelled as a bare name, so cmd resolves it from the hook's current directory — the same surface the $PATH: prefix closes one level down, and the mechanism is the one this file's own comment states.

Two things I could not settle from here, both bearing on that line rather than arguing against it:

  • Neither Windows test would go red if the where lookup broke. The runner's Git is at C:\Program Files\Git, so a lookup that returns nothing falls straight through to the %ProgramFiles%\Git\bin\bash.exe fallback, which resolves — and test_codex_windows_hook_commands_set_the_marker and test_shim_ignores_a_git_in_the_current_directory both still pass. That is true of the line as it stands too: the evidence that $PATH: works is the earlier red run at fd89ee1b, not the suite. So whichever way the line ends up, the derive branch is unpinned, and a test that plants a bash.exe somewhere the fallbacks can't reach would be what makes it observable.
  • SessionEnd keeps "timeout": 3, and the chain under it on Windows is now cmd.exe → for /f's child cmd → where.exe → bash.exe → wt.exe, against Unix's /bin/sh → bash → wt. I have no measurement of that on a cold Windows box, and I could not establish from here what Codex does when a hook times out — if it is the "Hook failed" banner, that budget is worth a look.

Self-authored, so no approval either way. At 4041caa, gate, fast-checks, feature-check, pre-commit.ci and codecov/patch are green; test (linux) and the advisory legs are still running.

Comment thread plugins/worktrunk/hooks/wt.cmd Outdated
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Switched to it — pushed as 4041caa.

wt.cmd now resolves bash.exe by path (the same order as find_git_bash: derived from where git.exe, then %ProgramFiles%\Git, then the per-user install) and then runs wt.sh, so the worktrunk-binary resolution lives only in that one script and Windows-Codex enters through the same door as Claude, Gemini, and Unix-Codex. The manifest commands didn't change. Net effect on the diff is smaller: the shim lost the WORKTRUNK_BIN → git-wt.exe → not-Windows-Terminal ladder and gained the bash lookup.

One thing the switch turned up: wt.sh never cleared WT, and on Windows every branch that assigns it can be skipped (neither git-wt.exe nor wt on PATH), so an inherited WT was what the final command -v "$WT" check accepted and ran. One line, and it now benefits all four integrations rather than just the cmd path — which is the argument for this shape, more or less.

All checks are green at 4041caa, including test (windows) and codecov/patch. One open review finding on the new commit (bare where resolves from the hook's cwd, the same surface the $PATH: prefix closes one level down) — that's being picked up separately, not folded in here.

What the Windows leg actually confirmed

The marker test pins PATH to the shape a default Git for Windows install produces — Git\cmd and nothing else from the install — rather than the runner's ambient PATH, so passing it settles two things I was inferring:

  • Git\bin\bash.exe really is the wrapper that sets up the MSYS environment for a caller outside Git Bash. wt.sh branches on uname -o, and uname lives in Git\usr\bin, which a default install does not put on the Windows PATH. Had the wrapper not fixed that up, wt.sh would have fallen to its non-Windows branch, reached for bare wt, and the marker would never have been set. That is why the shim keeps find_git_bash's Git\bin before Git\usr\bin order — the raw usr\bin\bash.exe would not have done.
  • The emoji survives the extra hop. It now crosses cmd.exe → Git Bash → wt instead of cmd.exe → wt, and MSYS converts the command line through its own charset, so this was the failure mode I most expected from the bash-preserving shape. test_codex_windows_hook_commands_set_the_marker asserts the stored marker contains('🤖'), not merely that the hook exited 0, so mojibake would have been red.

Still unverified, unchanged from before: nothing here drives a real Codex session on Windows, so "Codex picks commandWindows and the banner stops" rests on reading codex-rs/hooks/src/engine/{discovery,command_runner}.rs. CI exercises the command string executed the way that source says Codex executes it.

`where` is not a cmd built-in — it is `System32\where.exe` — so cmd
resolves that bare name the way it resolves any other: current directory
first, then PATH. The hook's current directory is the user's project, so
a `where` committed to a repo chose the `git.exe` that chose the
`bash.exe` every prompt, permission request, stop, and session end ran.
That is the surface the `$PATH:` prefix closes one level down, still open
on the command doing the closing.

`test_shim_ignores_a_where_in_the_current_directory` reaches the derive
branch specifically, which is what the existing Windows tests do not: an
unrunnable decoy leaves the lookup empty and the shim falls through to
`%ProgramFiles%\Git\bin\bash.exe`, which resolves on any Windows box, so
the test would pass on the unscoped shim for the wrong reason. The decoy
is a `.bat` naming a Git install whose `bin\bash.exe` exists, so an
unscoped `where` leaves `BASH` set and unrunnable. A probe pins the
premise separately, so the test cannot go green because the planted
`where` was never the one cmd would have chosen.

A spoofed `%SystemRoot%` degrades safely: `where.exe` is not found, the
lookup is empty, and the shim falls through to the `%ProgramFiles%`
install it already trusts.
…l defaults

The two scoping tests see only the wrong bash: they go red because the shim picks a decoy. A lookup that finds nothing — a mis-quoted `for /f`, a `where` cmd cannot run — falls through to %ProgramFiles%\Git\bin\bash.exe, which resolves on any Windows box, so the derive branch's success path was unobserved. Pointing ProgramFiles and LOCALAPPDATA at an empty directory leaves :derive as the only route to a bash.
…eout ceiling

SessionEnd's `timeout: 3` is Codex's ceiling for the event (SESSION_END_MAX_TIMEOUT_SEC in codex-rs/hooks/src/events/session_end.rs): omitting the key takes a 1s default, and a larger value is clamped with a warning. A hook that exceeds it is reported as an error, which is the same "Hook failed" banner #4007 is about.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both findings from the last pass are closed, and the way the second one closed also settles what I said I could not check from here.

test_shim_derives_bash_from_the_git_on_path points ProgramFiles and LOCALAPPDATA at an empty directory, leaving :derive as the only route to a bash — the test I named as missing last pass ("a test that plants a bash.exe somewhere the fallbacks can't reach would be what makes it observable"). It also answers the quoting question I could only reason about: test (windows) is green at 2405b8b, so '"%SystemRoot%\System32\where.exe" "$PATH:git.exe" 2^>nul' does survive the cmd /c "…" wrapping for /f applies. Had it not, the shim would have reached :missing and exited 1 with both fallbacks emptied.

One finding, and it is the PR description rather than the code: it predates the last two commits. Testing still opens "Four tests, all new" and lists four; there are six, and the two it omits are this increment's substance — test_shim_ignores_a_where_in_the_current_directory and test_shim_derives_bash_from_the_git_on_path. Solution likewise still describes the lookup as where "$PATH:git.exe" rather than the %SystemRoot%\System32\where.exe it is now. The "what this does not verify" block does not need touching: the two install-default fallbacks genuinely remain unexercised, since the new test empties them rather than reaching them.

Nothing else this pass — no code findings. One thing worth naming for whoever merges, since it is not a defect: this diff edits plugins/worktrunk/hooks/wt.sh, which the review guidance lists as shipped automation and which the Claude WorktreeRemove hook runs wt remove --foreground "$p" through. The WT="" line changes which binary that resolves to, strictly narrowing it — an inherited WT could previously be executed with those arguments, and now cannot.

Self-authored, so no approval either way. At 2405b8b every check is green except test (linux), still running.

Upstream claims the new CLAUDE.md prose asserts, re-checked

The added paragraph makes three claims about code outside this repo that a reader can't cheaply confirm. All hold:

  • SessionEnd's timeout ceiling. SESSION_END_DEFAULT_TIMEOUT_SEC = 1 and SESSION_END_MAX_TIMEOUT_SEC = 3 in codex-rs/hooks/src/events/session_end.rs; normalize_command_hook in codex-rs/hooks/src/engine/discovery.rs pushes "clamping {} hook timeout to {max_timeout_sec}s in {}" for anything larger, then .clamp(1, max_timeout_sec). So "omitting the key takes the 1s default, and a larger value is clamped with a warning" is exact, and timeout: 3 really is the whole budget the longer Windows chain can be given.
  • The env vars test_shim_derives_bash_from_the_git_on_path relies on reaching a real hook. WINDOWS_CORE_ENV_VARS in codex-rs/protocol/src/shell_environment.rs lists SYSTEMROOT, PROGRAMFILES and LOCALAPPDATA. Worth adding to the test's own note: the inherit = "core" filter is followed by a default exclude of *KEY* / *SECRET* / *TOKEN*, case-insensitive — none of the three match, so the premise survives that step too.
  • commandWindows replacing command, verified on an earlier pass and unchanged.

Separately, the .gitattributes CRLF pin is load-bearing rather than decorative: codex plugin marketplace add max-sixty/worktrunk reaches clone_git_source in codex-rs/core-plugins/src/marketplace_add/install.rs, which shells out to git clone (and git sparse-checkout / git checkout on the sparse path). That is a checkout, so plugins/worktrunk/hooks/*.cmd text eol=crlf applies on the user's disk and the goto labels resolve.

@max-sixty
max-sixty merged commit 80e795b into main Sep 6, 2026
46 checks passed
@max-sixty
max-sixty deleted the fix/issue-4007 branch September 6, 2026 23:26
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
worktrunk 0.77.0

Created-by: HarmonybrewBot
Commit-by: HarmonybrewBot
Merged-by: HarmonybrewBot
Description: Created by `brew bump`

---

Created with `brew bump-formula-pr`.<details>
  <summary>release notes</summary>
  <pre>## Release Notes


### Improved

- **`wt list` sizes and aligns its columns to the terminal width**: one long branch name no longer sizes the Branch column for every row — it caps at 32 characters and elides, so a narrow terminal keeps its other columns instead of degenerating into a branch list. `Remote⇅` stops holding blank space open in a repo with no remote, and alignment now follows the value type. `--format=json` still carries the whole name. ([#3998](max-sixty/worktrunk#3998))

- **`wt list` names what it hid and marks detached worktrees**: the summary reads `hidden: Path, Commit` instead of counting columns, a detached worktree shows `⊘` rather than borrowing `⚑`, and a prunable row leaves its cells blank instead of showing loading dots that never resolve. ([#3998](max-sixty/worktrunk#3998))

- **`wt list --format=json` gains a `marker` field and stops reporting `detached` twice**: the branch marker set by `wt config state marker` is now readable without parsing it out of `symbols`, and `state` no longer reports a detached worktree as `branch_worktree_mismatch` — the sibling `detached` boolean carries it. ([#3998](max-sixty/worktrunk#3998))

- **`wt list --format=json` now defaults to schema 2**: callers get the envelope with repository metadata and orthogonal per-item facts without configuring `[list] json-schema`. `wt list statusline --format=json` follows the same key. Set `json-schema = 1` to retain the original bare-array format. (Breaking.) ([#4038](max-sixty/worktrunk#4038))

- **`wt config show` fails on a broken config**: it exits 1 on an unreadable or invalid config, an invalid `[list] columns`, or an invalid `approvals.toml`. Unknown keys and deprecations stay warnings and still exit 0. A new `APPROVALS` section counts project commands awaiting approval. (Breaking: it always exited 0 before.) ([#3999](max-sixty/worktrunk#3999))

- **Config migration output names its destination**: `wt config update --output <path>` writes the migration artifact to that file instead of applying it in place, and `--output=-` writes it to stdout. Output mode includes project config when run from a linked worktree. (Breaking: `--print` was removed.) ([#4021](max-sixty/worktrunk#4021))

- **Pi joins the agent integrations**: `wt config plugins pi install` writes an activity hook to `~/.omp/agent/hooks/pre/worktrunk.ts`, so Pi sessions show 🤖/💬 markers in `wt list` like Claude Code, Codex, OpenCode, and Gemini. `$PI_CONFIG_DIR`, `$PI_CODING_AGENT_DIR`, and named `$OMP_PROFILE`/`$PI_PROFILE` profiles are honored. [Docs](https://worktrunk.dev/claude-code/) ([#3594](max-sixty/worktrunk#3594), thanks @adity982, and @ortonomy for the request)

- **`wt config plugins codex install` installs the plugin too**: it previously registered the marketplace and left you to run `/plugins` in Codex yourself. Uninstall follows for both Codex and Claude Code, removing the plugin and then the marketplace, so the two commands are inverses. (Breaking: Codex uninstall previously left an installed plugin alone; Claude uninstall now also removes the marketplace.) ([#4019](max-sixty/worktrunk#4019))

- **`wt step copy-ignored` says whether it reflinked or copied**: the summary now reads `(reflinked, no extra disk)`, `(full copy)`, or a partial count, so the same `29.5 GB` line distinguishes a free copy from one that actually wrote the bytes. `--format=json` gains `reflinked` and `written`; a `--dry-run` plan no longer reports `files`/`bytes`. ([#4025](max-sixty/worktrunk#4025))

- **`wt config update` removes keys its destination cannot hold**: migrating `[select]` or `[commit-generation]` drops keys the new section has no field for, reporting each one. Otherwise the key landed at a path the user never typed and warned on every command, with no way to clear it. ([#3994](max-sixty/worktrunk#3994))

### Fixed

- **A failed `wt switch pr:<n>` against a fork no longer deletes a branch it didn't create**: the rollback force-deleted the branch on any setup error, including the one case where the branch wasn't its own — a `pre-switch` hook or a concurrent session claiming the name in the window after the forge answered. Branch and worktree are now created in one `git worktree add -b`, which writes nothing when the name is taken. ([#3984](max-sixty/worktrunk#3984))

- **Codex on Windows: the activity hooks no longer fail on every event**: each hook led with a bare `bash`, which `cmd.exe` resolves to the WSL launcher rather than Git Bash, so every event raised a `Hook failed` banner. Hooks now run `wt.sh` through a shim that finds Git Bash by path. ([#4008](max-sixty/worktrunk#4008), fixes [#4007](max-sixty/worktrunk#4007), thanks @McNultyyy for reporting and diagnosing)

- **Templates leave `branch` unset in a detached worktree**: aliases and hooks rendered `{{ branch }}` as the literal `HEAD`, which git resolves, so an unguarded template ran against the wrong ref — the reported case ended in `git push origin --delete HEAD`, which git refused. `base` and `target` follow. (Breaking: an unguarded `{{ branch }}` now errors.) ([#4010](max-sixty/worktrunk#4010), fixes [#4009](max-sixty/worktrunk#4009))

- **The OpenCode plugin loads under OpenCode 2**: OpenCode 2's loader dropped the old bare-function export silently, so activity markers stopped appearing. The plugin now exports one object carrying both the v2 `setup` and v1 `server` entry points, and spawns `wt` through `node:child_process` rather than the Bun shell. Re-run `wt config plugins opencode install` to pick it up; the one file covers OpenCode 2 and OpenCode 1.16 or later. ([#4018](max-sixty/worktrunk#4018), thanks @pragmaticivan for the request)

- **`wt step commit --branch` builds its prompt from the worktree being committed**: prompt-building git plumbing ran in the invoking worktree, so with nothing staged there the generated message described none of the changes it committed. `wt step relocate --commit` had the same bug, and `--dry-run`/`--show-prompt` now preview the worktree `--branch` names. ([#3996](max-sixty/worktrunk#3996))

- **LLM commit messages keep diffs for quoted paths**: a file whose name git quotes in the `diff --git` header — any non-ASCII name under the default `core.quotePath` — had its whole diff section dropped from the prompt once the diff exceeded the 100 KB budget. ([#4041](max-sixty/worktrunk#4041))

- **An unparsable user config is reported, not a panic**: `wt config show --format=json` and `--full`, plus `wt step prune`, `relocate`, `eval`, and `for-each`, panicked on a debug build and printed a bare `✗ Command failed` on a release one. They now report `✗ Failed to load config` with the parser's caret diagram. ([#4002](max-sixty/worktrunk#4002))

- **`wt switch --execute` names the directory the program actually starts in**: the `Executing (--execute) @ …` header rendered the worktree the background hooks run in, not the program's own directory — which differs under `--no-cd` or a switch from a subdirectory. The path is omitted when the shell already stands there. ([#4043](max-sixty/worktrunk#4043), fixes [#4042](max-sixty/worktrunk#4042), thanks @yajo for reporting)

- **`wt config plugins claude|codex uninstall` is safe to re-run**: removing an already-absent marketplace reported a failure, and a Claude uninstall that removed the plugin and then failed left the marketplace with no way to clear it. Both now finish a half-done uninstall and succeed when there is nothing left to remove. ([#4033](max-sixty/worktrunk#4033), [#4034](max-sixty/worktrunk#4034))

- **`wt config state marker` set and clear no-op outside a git repository**: they exited 1 with a git error, so an agent plugin running outside a repository printed one every turn (the hook's `|| true` kept the session going). ([#3981](max-sixty/worktrunk#3981), thanks @mahirhir)

- **`wt list` produces output in a bare repo with no worktrees**: it printed nothing at all and exited 0. It now reports `○ No worktrees` with a hint, `--branches` lists the branches, and `--format json` emits the envelope with an empty `items`. ([#3992](max-sixty/worktrunk#3992))

- **`wt list` warns when it can't parse the project config** rather than ignoring it silently; the listing still succeeds. ([#3993](max-sixty/worktrunk#3993))

- **Relayed command output no longer prints ahead of the delayed-stream progress line**, and delayed-stream output routes through anstream so color is stripped consistently when redirected. ([#4037](max-sixty/worktrunk#4037), [#3995](max-sixty/worktrunk#3995))

- **A hook filter that matches nothing names the unconfigured source**, rather than reporting no match without saying which source was empty. ([#3997](max-sixty/worktrunk#3997))

### Documentation

- **`wt hook` says that `post-*` hooks from the two sources run concurrently**, so dependent commands belong in one source; `wt merge` records that `post-commit` can't run when the merge removes the worktree it anchors on. ([#4020](max-sixty/worktrunk#4020), [#4026](max-sixty/worktrunk#4026))

- **`wt step` help pages describe what the commands actually do**: `wt step prune` says it removes br

See merge request: Harmonybrew/homebrew-core!18763
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automated-fix Automated CI fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codex activity hooks fail on Windows because bare bash resolves to WSL

2 participants