I run Claude Code with worktree isolation: a session works in its own git worktree under the repository’s .claude/worktrees folder, so parallel agents cannot trample each other’s checkouts. I also run rtk, an open-source CLI proxy that compresses command output before it reaches the model’s context. Its README promises output up to 90% smaller and support for more than a hundred commands, and in an agent loop, where the whole conversation is resent on every turn, that saving compounds. I wrote about that arithmetic in A Cache Miss Now Costs Fifty Hits.
In early September I found that the two did not work together. Every git command in an isolated session was refused with the same error, whatever its form. The bug had already been reported as issue #3864, opened on September 4. I had the usual three options: wait for a maintainer, work around it on my own machine, or fix it. This is the story of choosing the third, and why I think it is usually the cheapest of the three.
Two correct tools, one broken seam#
rtk plugs into Claude Code as a PreToolUse hook. Before a shell command runs, the hook receives it and may rewrite it, so git status becomes rtk git status and the model reads the compact version of the output. Worktree isolation has its own guard: an isolated session’s git operations must target its own worktree, so the guard inspects each command and refuses any whose git it cannot verify.
The guard evaluates the command after the hook has rewritten it, and it can only verify git when git is spelled plainly. In a rewritten command, git is an operand of another program, and the guard cannot see what that program will do with it. So every rewritten git command was refused as one that “cannot be shown not to be git,” with advice to run the plain command instead. Retrying with plain git changed nothing, because the hook rewrote that too.
Neither tool was wrong. The hook did exactly what it was built to do, and the guard did exactly what a safety check should do with a command it cannot read. The bug lived in the seam between them, which is why the first theory in the issue, that the nested worktree path tripped the check, did not hold up. The path was fine. The order of operations was the problem.
The thread became a catalog of workarounds#
The issue went on to collect 19 thumbs-up and reproductions from nine more people across macOS, Windows and Linux. One of them put a number on it: in a single parallel run of 11 worktree-isolated subagents, about 75 tool calls were refused, and each agent separately worked out that calling /usr/bin/git directly got around it. Every workaround in the thread worked, and every one of them cost something.
| Option | What it fixes | What it costs |
|---|---|---|
| Call /usr/bin/git directly | The current command | Skips rtk for that call, and every agent has to rediscover it |
| Exclude git in the rtk config | Every isolated session | Turns off git compression everywhere, not only in worktrees |
| Run git from a shell script | The current command | Hides git from the isolation guard, so the safety check is gone |
| Wrap the hook in a script | Your own machine | rtk reports no hook installed, and reinstalling it brings the bug back |
| Leave the worktree, or drop rtk | The refusals | You give up isolation or token savings; at least one project removed rtk |
| Fix the hook upstream | Everyone, on upgrade | One pull request and some patience |
The fix used a signal the hook already had#
The payload Claude Code sends to the hook carries no isolation flag, but it does carry the session’s working directory, and Claude Code creates its managed worktrees with a fixed path shape: the repository, then .claude/worktrees, then the worktree’s name. When the working directory has that shape, the Claude hook now adds git to its exclude list for that one decision. Git passes through untouched, the guard can verify it, and the rest of the chain is still rewritten, so git status && cargo build becomes git status && rtk cargo build.
pub(crate) fn decide_for_agent_at(
cmd: &str,
verdict: PermissionVerdict,
cwd: Option<&str>,
) -> HookDecision {
let (mut excluded, transparent_prefixes) = crate::core::config::hook_rewrite_params();
if cwd.is_some_and(in_claude_worktree) {
excluded.push("git".to_string());
}
suppress_identity(
cmd,
decide_with_params(cmd, verdict, &excluded, &transparent_prefixes),
)
}
/// Whether `cwd` is inside a worktree Claude Code manages
/// (`<repo>/.claude/worktrees/<name>`, or deeper).
pub(crate) fn in_claude_worktree(cwd: &str) -> bool {
let parts: Vec<&str> = std::path::Path::new(cwd)
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
parts
.windows(3)
.any(|w| w[0] == ".claude" && w[1] == "worktrees")
}For every other host rtk supports, from Cursor and Gemini to Copilot and Codex, the decision gets no working directory and behaves exactly as before. The trade-off is explicit: inside a managed worktree, git output is no longer compressed, while every other command’s output still is. A few uncompressed git outputs are a far better deal than a session that cannot run git at all.
The fix also has known limits. The pull request named one up front: a model that types rtk git itself is still refused. A tester found the other, which the code now documents: a worktree created somewhere other than .claude/worktrees is not detected. A community member suggested a more general detection that compares git’s own directories, and the remaining cases are tracked as follow-up issues. A scoped fix that names its scope can be merged now; a general one has more cases to settle first.
What got the pull request merged#
I opened pull request #3879 on September 5, the day after the issue. It merged on October 17, was in the project’s pre-release builds the same day (dev-1.0.2-rc.552 onward), and shipped in the stable rtk v1.1.0 on October 25. Nothing that happened in between was clever, and all of it mattered.
- Root cause before code. The first theory blamed the worktree path; the cause turned out to be the order in which the hook and the guard ran.
- One behavior, one host. The change adds an optional working directory to the decision function, and only the Claude Code hook passes one.
- Tests for what must not change. They pin that paths merely resembling a worktree do not match, and that outside a worktree git is still rewritten.
- A test plan and a scope statement in the description: formatter, linter and test suite runs, real hook payloads piped through by hand, and the case the fix does not cover.
- The boring work. The project had moved on underneath the branch, so I rebased, re-applied the fix where the decision logic now lived, dropped the hand-written changelog entry that the release tooling generates anyway, and listed three test failures that also failed on the development branch, so nobody had to wonder whether they were mine.
A community member tested the branch on macOS and tested it again after the rebase, another proposed the more general detection, and the maintainer approved the pull request, added a commit pinning a few more cases with tests, and merged it with 15 checks green. That is the part a workaround never gets: other people’s eyes on your fix before it ships to everyone.
Waiting is also a decision#
Popular open-source tools run on maintainer attention, and that attention is spread across far more than your bug: rtk’s issue and pull request numbers passed 4,000 this autumn. A thumbs-up tells the maintainers a bug hurts, and that is useful signal. It does not write the fix. Six weeks passed between my pull request and the merge, and the thread kept collecting reproductions and workarounds meanwhile. Each of those people had the same three options I had.
This is the second time I have done this. Earlier this year a performance fix of mine landed in Hasura GraphQL Engine, a story I told in The ORDER BY That Hid Our SQL Server Indexes. The pattern held both times: the fix was smaller than the workaround, someone with more context than me reviewed it, and everyone who upgraded got it for free. A workaround protects one machine. A merged fix protects every user of the tool, including the ones who never found the issue.
Not every blocker deserves a pull request. If the right fix needs a design decision only the maintainers can make, the most useful contribution is a sharp issue: a minimal reproduction and a root-cause analysis. But when the cause is clear and the change is small, the shortest path to a fixed release usually runs through your own editor.
Before you open the pull request#
- Search the issues first. If the bug is reported, read every comment: the workarounds people share show you the shape of the bug.
- Reproduce it yourself and find the layer where the fix belongs, not the one that printed the error.
- Read the contributing guide and a few recently merged pull requests: target branch, commit style, changelog tooling, and any contributor license agreement.
- Change one behavior, and add tests for the cases it must not touch, not only the case it fixes.
- Put a test plan and a scope statement in the description, and link the follow-ups you are leaving open.
- While the pull request waits, run your branch locally so you are unblocked today.
- When the fix ships, say in the issue which release has it, so the people still waiting know they can stop.
A thumbs-up tells the maintainers it hurts. A tested pull request tells them how to stop it.