Skip to content

fix: diff parsers keep lines starting with -- or ++ inside a hunk - #270

Merged
devlint merged 3 commits into
mainfrom
fix/diff-parser-dash-dash-lines
Oct 14, 2026
Merged

devlint merged 3 commits into
mainfrom
fix/diff-parser-dash-dash-lines

Conversation

@devlint

@devlint devlint commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Why

Every unified-diff parser in the repo applied its --- / +++ file-header filter inside hunks too. A removed line whose content starts with -- (diff line --- comment, e.g. a SQL or Lua comment) and an added line starting with ++ (diff line +++x) were silently dropped, and every later line number in the hunk shifted. Reproduced on main with git_diff on a .sql file: the hunk header said @@ -1,3 +1,3 @@ and only the context lines came back.

Some copies did worse:

  • Secrets scan (secrets.rs, CLI scan.ts): an added ++ token line was taken for the +++ token header, so the line was skipped and the file path became token.
  • useAbsorb: a removed -- a/x line moved every following hunk onto a file named x.

What

  • One rule, bounded by the hunk header. A hunk is bounded by its @@ -a,b +c,d @@ counts. Inside it, a line is classified only by its first character: + while new-side lines remain, - while old-side lines remain, context (' ', or "" when diff.suppressBlankEmpty prints a blank context line) while both remain. ---/+++ are headers only outside a hunk; diff --git resets. Combined @@@ hunks stay open until the next diff line.
  • Shared helpers instead of copies.
    • Rust git/parse.rs: HunkBody, parse_hunk_header, push_hunk_line. Used by parse_diff_hunks (git_diff, git_file_diff), git_show (now parse_multi_file_diff → parse_diff_hunks per file), secrets.rs, gitlab.rs (MR stats).
    • @gitwand/core hunk-body.ts (pure TS): parseHunkHeader, hunkBodyFromHeader, classifyHunkLine. Used by core isPureMove, CLI scan.ts, desktop unifiedDiff (PullRequestPanel.vue dropped its own copy), useAbsorb, usePrSummary, and the dev-server.
    • The dev-server's own hand-written parser copies and its four header regexes are gone: devParseDiffHunks uses core for /api/git-diff, /api/git-file-diff, /api/git-show, and /api/scan-secrets uses the helpers directly.
  • dev:web / dev:server now build packages/core first (build:core, ~4 s warm), since the dev-server diff routes import it. If core is missing or stale, those routes answer with a message saying which command to run.
  • AGENTS.md "Diff Parsing": states the bounded rule (the old "an empty string is never context" warning still holds for unbounded parsers), the header-filter-inside-a-hunk mistake, and every user of the shared parsers. Pointers added in apps/desktop/src/CLAUDE.md and packages/core/CLAUDE.md.

Tests

  • Rust, real temp repos: git_diff staged/unstaged and git_show multi-file on a .sql file removing -- old comment / --- x and adding ++x / +++ y, exact line numbers; the same with diff.suppressBlankEmpty=true; git_file_diff plain and blob-to-blob (renamed); the secrets scan finding a ++ key… secret on the right file and line. Raw-text tests for headers after an exhausted hunk, \ No newline, counts per side, @@ -1 +1 @@, combined hunks.
  • Vitest: core hunk-body + isPureMove, CLI scan, desktop unifiedDiff, usePrSummary, useAbsorb. Two fixtures were corrected because the parser now trusts counts (one header claimed fewer lines than its body, one had no @@ header).
  • Parity: git-diff, new git-show (probe gained git-show), git-file-diff, scan-secrets, including suppressBlankEmpty.
  • cargo test --lib 640, core 1294, desktop 2043, CLI green; clippy -D warnings, vue-tsc clean. Parity 181/183 locally: the 2 misses are auto-merge-refusal, where a freshly built probe waits on its first keychain prompt — unrelated to diffs.

Behaviour change to know

A diff whose header counts are wrong now gets that hunk cut where the counts end. No forge path in the app was found to send such diffs (GitLab uses glab --raw, Azure git diff, Gitea/Bitbucket git format), but it is the trade-off of trusting the header.

Follow-ups (not in this PR)

  • File status (added/deleted/renamed) is still derived in several places with different coverage.
  • git_show / the dev-server join each file section back into a string before parsing it.
  • Combined @@@ header numbers differ: Rust reads old (1,1) / new (0,1), core and the dev-server fall back to 0,1,0,1 (hunk bodies agree); isPureMove on a combined hunk.
  • The dev-server keeps a stale core dist cached until it restarts.

A removed line whose content starts with `--` (diff line `--- comment`)
and an added line starting with `++` (diff line `+++x`) were dropped,
because the `---`/`+++` file-header filter also ran inside hunks. A .sql
file losing a `-- comment` came back as a hunk with only its context
lines, and every later line number shifted.

Every parser now bounds a hunk body by the counts of its `@@` header:
while they are not used up, a line is classified by its first character
only ('+', '-', ' '; an empty string is never context). `---`/`+++` are
file headers only outside a body. A combined `@@@` hunk stays open until
the next `diff ` line, since its counts are per parent.

Fixed parsers:
- Rust: parse_diff_hunks (git_diff), git_show, extract_staged_added_lines
  (secrets scan, where `+++ token` also renamed the file),
  gl_diff_stats_from_files. Shared helpers parse_hunk_header / HunkBody.
- dev-server.mjs: /api/git-diff, /api/git-file-diff, /api/git-show,
  /api/scan-secrets (devHunkBody helpers, kept in sync with Rust).
- @gitwand/core: new hunk-body helpers (exported), used by isPureMove.
- CLI: `gitwand scan` extractStagedAddedLines.
- Desktop: utils/unifiedDiff parseFileDiff, useAbsorb parseDiffHunks
  (`--- a/x` content reassigned hunks to file `x`), usePrSummary
  countChanges; PullRequestPanel drops its copy for utils/unifiedDiff.

Tests: Rust unit tests on real temp repos (git_diff staged/unstaged,
multi-file git_show, secrets scan) and on raw text (multi-file headers,
no-newline marker, empty line, combined hunk); vitest for every TS parser;
parity cases for git-diff and scan-secrets, plus a new git-show parity
test (probe gains a git-show command).
Review follow-up to the `--`/`++` hunk-line fix.

- Blank context lines: with `diff.suppressBlankEmpty=true` git prints a
  blank context line as "". The TS parser counted it as context, while
  Rust `HunkBody` and the dev-server dropped it without consuming a count,
  so later line numbers shifted and rebuilt patches failed `git apply`.
  One rule everywhere now: inside a hunk, while counts remain on both the
  old and new side, "" is a context line; outside a hunk or once a count
  is used up, it is ignored. Implemented in core `classifyHunkLine` (so
  unifiedDiff no longer maps "" to " " itself) and Rust `HunkBody::classify`
  (`push_hunk_line` no longer slices an empty line).
- AGENTS.md "Diff Parsing" states the bounded rule, keeping the warning
  against unbounded `!startsWith('\\')` parsers; core and desktop CLAUDE.md
  point to it.
- Core API: `hunkBodyFromHeader(line)` parses the header itself (counts
  default to 1) and replaces the parse-then-build pairs at every call site;
  `hunkBodyActive` is no longer exported. Rust gains `HunkBody::from_header`.
- dev-server.mjs: the devHunkBody/devClassifyHunkLine copies are gone; a
  single `devParseDiffHunks` built on @gitwand/core serves /api/git-diff,
  /api/git-file-diff and /api/git-show, and /api/scan-secrets uses core's
  helpers and `parseHunkHeader` instead of its own regex.
- git_show now splits on `diff --git` and parses each section with
  `parse_diff_hunks` (new `parse_multi_file_diff`), like /api/git-show,
  instead of carrying its own hunk loop.

Tests: Rust real-repo tests with diff.suppressBlankEmpty for git_diff
(staged and unstaged) and git_show, plus raw-text cases; core and desktop
vitest for the bounded "" rule and hunkBodyFromHeader; parity cases for
git-diff and git-show with diff.suppressBlankEmpty.
Second review follow-up.

- Strict counts: core `classifyHunkLine` and Rust `HunkBody::classify`
  accept '+' only while new-side lines remain, '-' only while old-side
  lines remain, and context (' ' or "") only while both do. A header that
  undercounts its body now ends the hunk there, so a following `--- a/x`
  is read as a header (fails closed) instead of a deletion. A combined
  `@@@` hunk keeps first-character classification. Rust drops the now
  unused `HunkBody::active`.
- dev:web / dev:server build @gitwand/core first (`build:core`, ~4 s):
  dev-server.mjs reads the shared diff parser from packages/core/dist.
  `loadDevCore()` imports core once per process, checks the helpers it
  needs and fails with "run `pnpm --filter @gitwand/core build`" when dist
  is missing or stale; a failed load is retried on the next request.
- `devParseDiffHunks(core, text)` is synchronous; /api/git-show loads core
  once per request instead of once per file section; /api/scan-secrets
  goes through `loadDevCore()` as well.
- git_file_diff (#269): Rust tests and parity cases with `-- x`/`--- x`
  removed and `++x`/`+++ y` added, plain and blob-to-blob (renamed), with
  and without diff.suppressBlankEmpty.
- AGENTS.md "Diff Parsing" states the strict per-side rule and lists every
  user of the shared parsers; apps/desktop/CLAUDE.md documents the core
  build that dev:web and dev:server now run.
@devlint
devlint merged commit 40ab96f into main Oct 14, 2026
10 checks passed
@devlint
devlint deleted the fix/diff-parser-dash-dash-lines branch October 14, 2026 15:49
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.

1 participant