Skip to content

rtk compacts machine-bound git output (--porcelain / --name-only / piped), silently corrupting consumers #2487

Open

Description

@raulmol

Summary

The Claude Code rewrite hook turns git X into rtk git X and rtk git
compacts the output. That's the right behavior when the model reads
the output. But rtk also rewrites git commands whose output is bound for
a program — piped into another command, or carrying an explicit
machine-readable format flag — and there its compacted, non-byte-exact
output silently corrupts the consumer.

This is not a request to weaken compaction. rtk already draws a
model-vs-machine line (see below); it's just drawing it in the wrong
place for a few cases.

Evidence that rtk already distinguishes machine output

git rev-parse is treated as machine data and passed through unchanged:

$ rtk rewrite 'git rev-parse HEAD'              # exit 1 → pass through
$ rtk rewrite 'git rev-parse HEAD | head -1'    # exit 1 → pass through

So "don't rewrite this git command" is already a first-class outcome in
the registry. The bug is that the same courtesy isn't extended to other
machine-output cases.

The mis-drawn cases

rtk rewrites (and thus compacts) commands that explicitly request
parseable output or feed a parser:

$ rtk rewrite 'git status --porcelain | wc -l'   → rtk git status --porcelain | wc -l
$ rtk rewrite 'git log --format=%H | sort'       → rtk git log --format=%H | sort
$ rtk rewrite 'git diff | wc -l'                 → rtk git diff | wc -l

--porcelain is literally git's stable machine-readable contract;
--format=%H / --name-only / -z are the same intent. Compacting
them overrides an explicit "this output is for a program" signal.

Concrete corruption — rtk appends a --- Changes --- footer to diff
output, so a file-list count is wrong:

$ rtk proxy git diff --name-only HEAD~1..HEAD | grep -c .   # raw git
17
$ rtk git diff --name-only HEAD~1..HEAD | grep -c .         # via rtk
20
$ diff <(rtk proxy git diff --name-only HEAD~1..HEAD) \
       <(rtk git diff --name-only HEAD~1..HEAD)
17a18,20
>
> --- Changes ---
>

Secondary, intermittent: the first rtk git diff ... in a fresh
shell sometimes returns empty output (looks like a cold-cache miss);
repeated identical calls then return the full (footer-decorated) result.
... | wc -l → 1 when the truth is 17.

All of these fail silently — a plausible wrong number, no error.
Anything scripted that counts/parses git output gets bad data.

Environment

  • rtk 0.36.0, git 2.53.0, macOS (Darwin 25.5.0)
  • Hook: rtk-rewrite.sh → rtk rewrite prepends rtk to git commands

Proposed fix

Extend rtk's existing pass-through logic (the rev-parse exit-1 path)
to decline rewriting when the command signals a machine consumer:

  1. an explicit machine-format flag is present —
    --porcelain, --format=/--pretty=format:, --name-only,
    --name-status, -z, --no-color; or
  2. the git command's stdout is piped into another command or redirected
    into a file (rtk rewrite already has the full command string, so a
    trailing | <cmd> / > file after the git call is detectable).

In those cases, emit the command as raw git. Keep compaction fully on
for the common, high-token, model-facing case (a bare git diff /
git status / git log whose output the model reads directly). Token
savings are essentially unchanged; correctness for programmatic
consumers is restored.

Separately, the intermittent empty-output-on-first-call should be fixed
regardless — empty is never a valid compaction of a non-empty diff.

Workaround (current)

rtk proxy git ... to bypass the wrapper, or append | cat.

Activity

  1. pszymkowiak commented on Jun 25, 2026

    @pszymkowiak
    Collaborator

    wshm · Automated triage by AI

    🔍 Automated Triage

    🐛 Category bug
    🟠 Priority high
    🎯 Confidence 95%

    Summary

    rtk applies compaction to git output even when machine-readable flags (--porcelain, --format=, --name-only, -z) or pipes are present, silently corrupting consumers with wrong counts and injected footers; PR #2573 already in progress.


    Triaged automatically by wshm · This is an automated analysis, not a human review.

  2. added 3 commits that reference this issue on Jul 18, 2026
    e8b699a
    bb4a8c2
    4439c4e
  3. macblackstuff commented on Aug 1, 2026

    @macblackstuff

    Still present on 0.44.1 (macOS, Homebrew), and there is a sharper minimal case than the --porcelain ones above: an empty result becomes one line.

    $ rtk git log --oneline -0 | wc -l
    1
    $ rtk proxy git log --oneline -0 | wc -l
    0
    $ /usr/bin/git log --oneline -0 | wc -l
    0
    

    git log --oneline -0 prints nothing. rtk git prints its status line on stdout, so anything counting lines sees 1 where the real answer is 0.

    Why this one is worse than truncation: the corruption is not a compressed variant of the right answer, it is the inverse of it. Every emptiness test built on this shape silently flips:

    git log origin/main..HEAD --oneline | wc -l    # 0 commits unpushed -> reads as 1
    

    We hit exactly that — a session reporting unpushed work on a fully pushed branch — and it took a while to trace, because both the command and the pipeline are correct and no error is emitted anywhere.

    Two observations that may help scope a fix:

    1. The status line is diagnostic, not command output. Sending it to stderr would fix this whole class without touching compaction at all, and without needing to detect intent. It also matches what every other wrapper-style tool does with its own chatter.
    2. rtk proxy is already correct here (0), so the passthrough path is sound — the divergence is only in what the wrapped path writes to stdout.

    Our house workaround, in case it is useful as a data point on the cost side: our agent instructions now carry a standing "never pipe rtk-wrapped output into a line counter" rule with a list of safe substitutes (git rev-list --count, rtk proxy). It works, but it is a rule every agent has to remember on every call, and the failure is silent when they forget.

  4. febpetroni commented on Sep 28, 2026

    @febpetroni

    All three "mis-drawn cases" in the description now pass through on 0.50.0. The underlying principle still holds, but via a different family — so the repro block is stale and someone trying to reproduce it will conclude the issue is invalid.

    Environment: rtk 0.50.0, Darwin 25.3.0 arm64, hook-version 3.

    $ rtk rewrite 'git status --porcelain | wc -l' ; echo $?
    1
    $ rtk rewrite 'git log --format=%H | sort' ; echo $?
    1
    $ rtk rewrite 'git diff | wc -l' ; echo $?
    1

    All three are exit 1 (pass through), matching the git rev-parse control the description cites as the correct behavior. Redirections are covered too:

    $ rtk rewrite 'git diff --name-only > /tmp/x.txt' ; echo $?
    1
    $ rtk rewrite 'cat file.txt > out.txt' ; echo $?
    1

    That last one mattered: on 0.34.2 it rewrote to rtk read file.txt > out.txt, which is not a byte-exact copy — I measured 200 B of binary in, 0 B out, exit 0.

    What still reproduces is the same principle with a positional consumer instead of an aggregating one:

    $ rtk rewrite 'git status --porcelain | head -5' ; echo $?
    rtk git status --porcelain | head -5
    3
    
    $ rtk rewrite 'git diff --name-only | tail -3' ; echo $?
    rtk git diff --name-only | tail -3
    3

    --porcelain is an explicit machine-format flag, so this is exactly the case the description argues against — it just now survives only through head/tail, which #4207 and #4026 already own.

    Suggestion: either close this and let #4207/#4026 carry the remainder, or swap the repro block for the head/tail forms above so the issue stays reproducible. Happy to do whichever you prefer.

  5. sholsinger commented on Sep 30, 2026

    @sholsinger

    Still happening on 0.49.0, and the symptom is worse than the footer case: the patch body is gone entirely while the header still claims it.

    $ git diff next-env.d.ts | cat
    next-env.d.ts | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    Changes:
    
    $ echo $?
    0

    The file had a real one-line change. A Changes: header with nothing under it is worse than empty output, because it reads as "I looked, and there's nothing there." Two identical invocations in a row both lost it, and rtk proxy git diff showed the patch every time.

    I couldn't turn this into a reproduction. A fresh repo with the same filename and content behaves correctly, and recreating the same change in the same repo later behaved correctly too. That fits the cold-cache note above, so I assume it's the same bug rather than a new one.

    Environment: rtk 0.49.0, git 2.50.1 (Apple Git-155), Darwin 24.6.0, Claude Code rewrite hook.

    I'm happy to capture cache or state the next time it happens. Tell me what would be useful and I'll grab it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions