Skip to content

fix(git): stop silently truncating large diffs - #3022

Closed
georgebashi wants to merge 1 commit into
rtk-ai:developfrom
georgebashi:fix/compact-diff-never-truncate
Closed

georgebashi wants to merge 1 commit into
rtk-ai:developfrom
georgebashi:fix/compact-diff-never-truncate

Conversation

@georgebashi

@georgebashi georgebashi commented Jul 16, 2026 •

Copy link
Copy Markdown

Summary

Test plan

  • cargo fmt --all --check && cargo clippy --all-targets && cargo test — 2,495 passed, 8 ignored, no warnings
  • Replaced truncation assertions with content-preservation tests; added leading-context coverage
  • Added git_diff_large_change_preserves_all_content: a 1,200-line diff retains and can grep its final-line marker
  • Release binary: raw diff 2,407 lines; compact diff 2,411 lines (previously ~110); final-line grep succeeds; zero truncation markers
  • Recovery-hint/cap check: N/A because diff truncation was removed

Closes #1852.

AI-assisted contribution.

`compact_diff` (used by `git diff`, `git show`, `git stash show`,
`gh pr diff`, `glab mr diff`, and the `git-diff` pipe filter) silently
dropped diff content at two levels: a 100-line-per-hunk cap and a
500-line global cap. Content past the cap was replaced with a
"... (N lines truncated)" / "... (more changes truncated)" marker.

This caused the exact data-loss class rtk-ai#827 was meant to fix. rtk-ai#827
("never truncate diff content", closes rtk-ai#827) only patched the sibling
`condense_unified_diff` in diff_cmd.rs (the `rtk diff` stdin path) — its
message claimed it also covered the "git diff path", but that path uses
`compact_diff` in git.rs, which was never touched. The caps were also
effectively unconfigurable: every `git::run` caller passes `max_lines:
None`, so `unwrap_or(500)` always applied.

The failure is worst under a pipe: the PreToolUse hook rewrites
`gh pr diff URL | grep X` to `rtk gh pr diff URL | grep X` (rtk-ai#1560), so
grep scans the truncated output and silently finds nothing — an agent
concludes the content isn't there. Reported for `gh pr diff | grep`
returning empty while `rtk proxy gh pr diff | grep` found the matches.

Make `compact_diff` never drop diff content, matching the decision
already made for `condense_unified_diff`. Only lossless compression
remains: strip diff metadata (index/mode/`---`/`+++`) and append a
per-file `+N -M` summary. As a side effect this also fixes rtk-ai#1852
(leading context before the first change in a hunk was dropped) and
removes the misleading "[full diff: rtk git diff --no-compact]" hint
that named the wrong command on the `gh pr diff` path.

The now-unused `max_lines` parameter is retained (prefixed `_`) on
`run_diff`/`run_show` to avoid churning the `git::run` signature and
its callers; the `--no-compact` / `--stat` passthrough escape hatches
are unchanged.

- Rewrite `compact_diff(diff)` to drop no `+`/`-`/context lines
- Update all 6 call sites (git.rs x3, gh_cmd, glab_cmd, pipe_cmd)
- Replace the two truncation-asserting unit tests with
  content-preservation tests; add a leading-context test (rtk-ai#1852)
- Add an end-to-end integration test: a 1200-line `rtk git diff`
  preserves a marker near the end and stays greppable

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: George Bashi <georgeb@yelp.com>
@CLAassistant

CLAassistant commented Jul 16, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes. To be straight about where this stands: the diagnosis is right, but the patch as written does not do what it says — and I only found that by measuring, so this isn't a style objection.

I built the patched branch and compared its binary against the pre-patch binary on real diffs from this repo. The full suite passes. The problem is inline: every retained line still gains a two-space prefix, so once the caps are gone the compacted output is larger than the raw input, never_worse discards it, and rtk git diff silently becomes a raw passthrough.

The good news is that the fix is small and isn't yours to write. #3269 removes exactly that two-space prefix from hunk bodies, for unrelated reasons. With it applied, compaction is net-negative again and the arithmetic that breaks this PR stops applying. So the useful next step is to re-measure on top of it:

git fetch upstream pull/3269/head && git cherry-pick <the compact_diff commit>

then compare rtk git show <sha> byte counts against the pre-patch binary on a few commits. If you'd rather not carry a cherry-pick on your branch, say so and I'll run the measurement and post the numbers here — happy to do that either way, since it's the same handful of commands I already ran.

On the never-truncate policy itself: .claude/rules/cli-testing.md declares ≥60% savings a release blocker, and a compact_diff that never truncates can't guarantee that by construction. That's a project-level call rather than something to settle in this thread, and it's being discussed separately — so please leave your reasoning stated in the PR body. The argument that silent truncation costs more tokens downstream, because the agent re-fetches what was dropped, is the strongest form of the case and it's worth having on record.

Two smaller things that don't need their own threads:

  • Unbounded allocation. result: Vec<String> now takes one heap String per input line, plus the join("\n") copy; it was previously bounded at 500 entries. gh pr diff feeds it up to the 10 MiB RAW_CAP (src/core/stream.rs:286), roughly 200k lines — several hundred MB transient, against the <5 MB target in CLAUDE.md. Streaming into a single String with push_str and a reserved capacity removes the per-line allocation.
  • &out[out.len().saturating_sub(300)..] (tests/guard_integration_test.rs:297, same shape at git.rs:2454) is not char-boundary-safe. It's only evaluated when the assertion fails, which is exactly when you want the message rather than a byte index is not a char boundary panic. String::from_utf8_lossy on the tail fixes it.

Comment thread src/cmds/git/git.rs
/// "never truncate" rule to the sibling `condense_unified_diff` (stdin path).
/// The only compression here is lossless: dropping diff metadata and appending
/// a per-file `+N -M` summary.
pub(crate) fn compact_diff(diff: &str) -> String {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Measured, not inferred. With the caps removed, every retained line still gets format!(" {}", line) — two bytes per line — while the only bytes removed are roughly 140 per file (index, ---, +++, the shortened diff --git). Past about fifteen changed lines the compacted string exceeds the raw input, and never_worse(&raw, &printed) (git.rs:203, git.rs:318, runner.rs:141 for gh pr diff / glab mr diff, pipe_cmd.rs:276) discards it.

Bytes emitted by rtk git show <sha> on this repo:

commit raw pre-patch post-patch
87702bf 31524 19375 31140
4147e99 31548 20162 31549 (= raw)
ab48a68 26121 13112 26122 (= raw)
d3f31dd 15385 11661 15386 (= raw)

And on this PR's own 1,200-line scenario, rtk git diff emits 42,641 bytes against 42,504 raw — the output carries diff --git/index headers and zero +/ - indented lines, i.e. it is the raw fallback.

Net effect: roughly 0-1% reduction instead of 35-50% on the main path, and compact_diff becomes dead code for anything non-trivial. A second-order consequence worth knowing about: the emitted format now flips between "stat + Changes: + indented" for small diffs and plain git output for large ones, so anything parsing rtk's diff output has to handle both shapes.

This also means the new regression test at tests/guard_integration_test.rs:270 passes through the raw fallback and never exercises compact_diff at all — CHANGED_0, CHANGED_1199, _finalize and "no truncation marker" are all satisfied by unfiltered git diff output, so re-introducing a cap inside compact_diff would not fail it. Once the prefix is gone, assert on the compacted shape (the +N -M summary) so the test can see the function it's guarding.

Comment thread src/cmds/git/git.rs
/// `---`/`+++` file markers) while preserving every `+`/`-`/context line and
/// hunk header in full.
///
/// This never drops diff content. Silent truncation caused agents (and users)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"This never drops diff content" isn't accurate, and a doc comment is the wrong place to overstate it, since it's what future readers will trust.

Still dropped after this change: \ No newline at end of file (line 378), and everything between diff --git and the first @@ — Binary files a/x and b/x differ, similarity index / rename from / rename to, new file mode / deleted file mode. Verified: a staged binary change plus a rename compacts to two bare filenames, with no indication of what happened to either.

Separately, gh pr diff output above RAW_CAP (10 MiB) is still silently line-dropped in src/core/stream.rs:418, upstream of compact_diff — so "completes the never-truncate fix" overstates the reach even for the callers this does cover.

Suggest "drops diff metadata" rather than "never drops content", plus a sentence naming which metadata.

One more, low severity, but it belongs next to this comment because the comment now explicitly promises these lines survive: the !line.starts_with("+++") / !line.starts_with("---") guards mean a content line ++ foo (which arrives as +++ foo) or -- foo (arriving as --- foo) falls through to the context branch — printed, but never counted in added/removed. Verified: a diff turning -- old dashes into ++ new pluses produces no +N -M line at all, while git diff --stat reports one insertion and one deletion. Pre-existing, and orthogonal to the caps, but it undercuts the same promise.

@KuSh

KuSh commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Correction to my earlier review — I cited the wrong policy, and it was the part of my comment most likely to discourage you.

I wrote that ".claude/rules/cli-testing.md declares ≥60% savings a release blocker". That file is stale. CONTRIBUTING.md is authoritative and the figure is 20%, and it is an admission criterion for what belongs in RTK at all, not a per-release gate every filter must clear:

Commands that produce text output (typically 100+ tokens) whose bytes can be compressed 20%+ without losing essential information for the LLM.

Two further things in CONTRIBUTING.md I should have pointed you to instead, because they support your position rather than block it:

  • "When in doubt, preserve correctness."
  • Never Block already settles the truncation question: "capping output at N items is only acceptable if accompanied by a hint that lets the agent recover the hidden data."

That last line reframes this PR. Truncation is not forbidden by policy — truncation without a recovery path is. rtk git diff already satisfies it: it emits [full diff: rtk git diff --no-compact], and that flag exists.

Where the policy is actually violated is the sibling callers. gh pr diff and glab mr diff route through the same compact_diff and print the same hint, but neither has a --no-compact equivalent — so the recovery hint names a command the user cannot run. Same for rtk pipe -f git-diff.

So I would narrow the ask rather than widen it: instead of removing truncation globally, give those callers a recovery path (a --no-compact passthrough, or a hint naming the real escape hatch for each). That is a policy defect with a concrete fix, it is far easier to review than a behaviour change across every caller, and it needs nobody to grant an exemption.

Unchanged from my review: the measured finding stands on its own and is unaffected by any of this. With the caps removed, every retained line still carries a two-space prefix, so the compacted output exceeds the raw input, never_worse discards it, and rtk git diff becomes a raw passthrough — 0-1% instead of 35-50%. #3269 removes that prefix; re-measuring on top of it is still the next step, and my offer to run those numbers for you stands.

Apologies for the detour — the "project-level decision being discussed separately" I described does not exist in the form I described it.

@KuSh

KuSh commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the careful write-up on diff truncation. compact_diff was reworked in #3269 (merged 2026-09-01): hunk bodies now emit at column 0 so anchored greps work again, dropped lines are disclosed split by sign as "... (N deletions, M additions truncated)", and the leading-context bug you point at is bounded by max_lines/10 inside the same function. The "[full diff: rtk git diff --no-compact]" marker and the --no-compact escape hatch for git diff and gh pr diff were already on develop before this PR opened, so the truncation is disclosed rather than silent, and the caps were kept deliberately. The patch here also reinstates the two-space indent that #3269 removed, so it can no longer be rebased as written. Two gaps are still real and would make good narrow issues or PRs: glab mr diff compacts at 500 lines with no --no-compact of its own (src/cmds/git/glab_cmd.rs:490), and the git-diff pipe filter caps at 200 lines with no opt-out (src/cmds/system/pipe_cmd.rs:56). Please comment or reopen if part of this was missed.

@KuSh KuSh closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(git): compact_diff drops context lines before first change in hunk

3 participants