Skip to content

buffer_diff: Canonicalize ambiguous hunk placement to fix staging corruption - #60584

Merged
cole-miller merged 4 commits into
zed-industries:mainfrom
abendrothj:fix-ambiguous-hunk-placement
Jul 18, 2026
Merged

cole-miller merged 4 commits into
zed-industries:mainfrom
abendrothj:fix-ambiguous-hunk-placement

Conversation

@abendrothj

@abendrothj abendrothj commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes #60424

Problem

With the repro from #60424, staging the first deletion hunk marked the second deletion as staged in the UI even though git still had it unstaged, and clicking the (incorrectly shown) Unstage button inserted a duplicate copy of the hunk's contents into the git index on every click.

The root cause is ambiguous hunk placement. The committed text contains repeated end\n\n line runs, so the deletion hunks can "slide": more than one placement produces a minimal diff. The uncommitted diff (HEAD vs worktree) anchored the remaining deletion at one row while the unstaged diff (index vs worktree), recomputed after the partial stage, anchored the same logical deletion at a different row. Everything that correlates hunks across those two diffs assumes they agree on positions:

  • the secondary-status matching in hunks_intersecting_range_impl found no unstaged hunk at the uncommitted hunk's rows and reported it as staged (NoSecondaryHunk);
  • the worktree→index projection in compute_uncommitted_index_edits treated the hunk's position as unchanged text and, on unstage, inserted the hunk's HEAD content at an index position that already contained it, duplicating it on every request.

Fix

Upgrade imara-diff from 0.1.8 to 0.2.0 and run Diff::postprocess_lines (imara-diff's port of git's xdiff slider/indent heuristic) after computing hunks in buffer_diff. This canonicalizes the placement of ambiguous hunks based only on their local content, so diffs of the same buffer against different base texts anchor the same logical change at the same rows. A bonus is that Zed's hunk placement now matches git diff's output for such cases (git has used the indent heuristic by default since 2.11).

As defense in depth, compute_uncommitted_index_edits now drops a pure-insertion index edit whose content is already present at the target position, so a stale secondary status can no longer duplicate index content.

The imara-diff 0.2 API removed the Sink trait and the top-level diff() function, so the other call sites (language/text_diff.rs, zeta_prompt/udiff.rs, edit_prediction_metrics/reversal.rs) are migrated mechanically to Diff::compute + hunks() with unchanged behavior (0.2 also renamed lines_with_terminator to lines and changed the default &str tokenization to include terminators; the unified-diff builders keep terminator-less tokens via str::lines()).

Testing

  • New regression test test_staging_hunks_with_ambiguous_placement replays the exact repro from Stage button stages part of the hunk in git but marks whole hunk as staged in UI #60424 (same file contents): stages the first deletion, asserts the remaining hunks keep their unstaged status and the index matches exactly, then issues repeated unstage requests for the unstaged hunk and asserts the index is unchanged. Before the fix this test showed the second hunk flipping to staged and the index growing by one copy of the deleted block per unstage request.
  • buffer_diff, language, zeta_prompt, edit_prediction_metrics, multi_buffer, git_ui, editor, and the project integration suite pass.
  • One test expectation updated: editor::test_fold_function_bodies asserted the old placement of an ambiguous deletion (blank line before comment); the canonicalized placement (comment before blank line) matches what git diff produces for the same texts.

Release Notes:

  • Fixed staging a hunk sometimes marking a different hunk as staged (and subsequent unstaging corrupting the git index) when the diff contained repeated lines (#60424).

@cla-bot

cla-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

We require contributors to sign our Contributor License Agreement, and we don't have @abendrothj on file. You can sign our CLA at https://zed.dev/cla. Once you've signed, post a comment here that says '@cla-bot check'.

@zed-community-bot zed-community-bot Bot added the first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions label Jul 8, 2026
@abendrothj

Copy link
Copy Markdown
Contributor Author

@cla-bot check

@cla-bot cla-bot Bot added the cla-signed The user has signed the Contributor License Agreement label Jul 8, 2026
@cla-bot

cla-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

The cla-bot has been summoned, and re-checked this pull request!

Upgrade imara-diff from 0.1.8 to 0.2.0 and run its port of git's xdiff
slider/indent heuristic (Diff::postprocess_lines) when computing buffer
diff hunks. Without this, diffs of the same buffer against different
base texts (e.g. HEAD vs index) could anchor the same logical change at
different rows when the surrounding lines repeat, and code correlating
hunks across the uncommitted and unstaged diffs misbehaved: after a
partial stage, an unstaged hunk rendered as staged, and unstaging it
inserted a duplicate copy of its contents into the git index on every
request.

Also drop pure-insertion index edits whose content is already present
at the target position, so a stale secondary status can no longer
duplicate index content, and migrate the remaining imara-diff call
sites to the 0.2 API with unchanged behavior.

Closes zed-industries#60424
@abendrothj
abendrothj force-pushed the fix-ambiguous-hunk-placement branch from 548fa20 to f67e3fc Compare July 8, 2026 10:01
@dinocosta

Copy link
Copy Markdown
Member

Hey @abendrothj ! Thank you for taking a stab at fixing the reported issue 🙂

Before someone chimes in with a review, would you be able to share an updated screenshot using the same example used by Kunall in this comment? Makes it easier for us to confirm the fix is working as intended and we can dive into the code. Thanks!

@dinocosta dinocosta added the area:integrations/git Git integration feedback label Jul 8, 2026
@abendrothj

abendrothj commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Hey @abendrothj ! Thank you for taking a stab at fixing the reported issue 🙂

Before someone chimes in with a review, would you be able to share an updated screenshot using the same example used by Kunall in this comment? Makes it easier for us to confirm the fix is working as intended and we can dive into the code. Thanks!

Sure, here's the updated screenshot, as well as a LazyGit one for consistency.

After staging the first deletion block in Zed, only that block is staged; the table:contains deletion and has_class edit remain unstaged.

Screenshot 2026-07-08 at 5 17 30 PM Screenshot 2026-07-08 at 5 25 53 PM
  $ git status --short
  MM test.txt

  $ git diff --staged -- test.txt
  diff --git a/test.txt b/test.txt
  index a5c7ac0..372ece4 100644
  --- a/test.txt
  +++ b/test.txt
  @@ -1,16 +1,3 @@
  -function is_empty(str)
  -    assert(type(str) == string)
  -    return str == nil or str == ""
  -end
  -
  -function string:starts_with(str)
  -    return self:sub(1, #str) == str
  -end
  -
  -function string:ends_with(str)
  -    return self:sub(-#str) == str
  -end
  -
   function table:append(other)
       for _, val in ipairs(other) do table.insert(self, val) end
   end

  $ git diff -- test.txt
  diff --git a/test.txt b/test.txt
  index 372ece4..0f91d3d 100644
  --- a/test.txt
  +++ b/test.txt
  @@ -2,15 +2,6 @@ function table:append(other)
       for _, val in ipairs(other) do table.insert(self, val) end
   end

  -function table:contains(elem)
  -    assert(type(self) == "table")
  -
  -    for _, val in ipairs(self) do
  -        if val == elem then return true end
  -    end
  -    return false
  -end
  -
   function table:keys()
       local keys = {}
       for key, _ in pairs(self) do
  @@ -19,4 +10,4 @@ function table:keys()
       return keys
   end

  -function has_class(el, class) return el.classes[0] == class end
  +function has_class(el, class) return el.classes:includes(class) end

@cole-miller

Copy link
Copy Markdown
Member

Thanks! I removed the content-based safeguard because it seems like that would reject valid index edits in some cases, so it seems best not to have it. Otherwise this fix seems great.

@cole-miller
cole-miller enabled auto-merge July 18, 2026 18:39
@cole-miller
cole-miller added this pull request to the merge queue Jul 18, 2026
Merged via the queue into zed-industries:main with commit eb96279 Jul 18, 2026
34 checks passed
jolutz pushed a commit to jolutz/zed that referenced this pull request Aug 8, 2026
…ruption (zed-industries#60584)

Closes zed-industries#60424

## Problem

With the repro from zed-industries#60424, staging the first deletion hunk marked the
second deletion as staged in the UI even though git still had it
unstaged, and clicking the (incorrectly shown) Unstage button inserted a
duplicate copy of the hunk's contents into the git index on every click.

The root cause is ambiguous hunk placement. The committed text contains
repeated `end\n\n` line runs, so the deletion hunks can "slide": more
than one placement produces a minimal diff. The uncommitted diff (HEAD
vs worktree) anchored the remaining deletion at one row while the
unstaged diff (index vs worktree), recomputed after the partial stage,
anchored the same logical deletion at a different row. Everything that
correlates hunks across those two diffs assumes they agree on positions:

- the secondary-status matching in `hunks_intersecting_range_impl` found
no unstaged hunk at the uncommitted hunk's rows and reported it as
staged (`NoSecondaryHunk`);
- the worktree→index projection in `compute_uncommitted_index_edits`
treated the hunk's position as unchanged text and, on unstage, inserted
the hunk's HEAD content at an index position that already contained it,
duplicating it on every request.

## Fix

Upgrade `imara-diff` from 0.1.8 to 0.2.0 and run
`Diff::postprocess_lines` (imara-diff's port of git's xdiff
slider/indent heuristic) after computing hunks in `buffer_diff`. This
canonicalizes the placement of ambiguous hunks based only on their local
content, so diffs of the same buffer against different base texts anchor
the same logical change at the same rows. A bonus is that Zed's hunk
placement now matches `git diff`'s output for such cases (git has used
the indent heuristic by default since 2.11).

As defense in depth, `compute_uncommitted_index_edits` now drops a
pure-insertion index edit whose content is already present at the target
position, so a stale secondary status can no longer duplicate index
content.

The imara-diff 0.2 API removed the `Sink` trait and the top-level
`diff()` function, so the other call sites (`language/text_diff.rs`,
`zeta_prompt/udiff.rs`, `edit_prediction_metrics/reversal.rs`) are
migrated mechanically to `Diff::compute` + `hunks()` with unchanged
behavior (0.2 also renamed `lines_with_terminator` to `lines` and
changed the default `&str` tokenization to include terminators; the
unified-diff builders keep terminator-less tokens via `str::lines()`).

## Testing

- New regression test `test_staging_hunks_with_ambiguous_placement`
replays the exact repro from zed-industries#60424 (same file contents): stages the
first deletion, asserts the remaining hunks keep their unstaged status
and the index matches exactly, then issues repeated unstage requests for
the unstaged hunk and asserts the index is unchanged. Before the fix
this test showed the second hunk flipping to staged and the index
growing by one copy of the deleted block per unstage request.
- `buffer_diff`, `language`, `zeta_prompt`, `edit_prediction_metrics`,
`multi_buffer`, `git_ui`, `editor`, and the `project` integration suite
pass.
- One test expectation updated: `editor::test_fold_function_bodies`
asserted the old placement of an ambiguous deletion (blank line before
comment); the canonicalized placement (comment before blank line)
matches what `git diff` produces for the same texts.

Release Notes:

- Fixed staging a hunk sometimes marking a different hunk as staged (and
subsequent unstaging corrupting the git index) when the diff contained
repeated lines
([zed-industries#60424](zed-industries#60424)).

---------

Co-authored-by: Cole Miller <[email protected]>
playdohface pushed a commit to playdohface/zed that referenced this pull request Aug 29, 2026
…ruption (zed-industries#60584)

Closes zed-industries#60424

## Problem

With the repro from zed-industries#60424, staging the first deletion hunk marked the
second deletion as staged in the UI even though git still had it
unstaged, and clicking the (incorrectly shown) Unstage button inserted a
duplicate copy of the hunk's contents into the git index on every click.

The root cause is ambiguous hunk placement. The committed text contains
repeated `end\n\n` line runs, so the deletion hunks can "slide": more
than one placement produces a minimal diff. The uncommitted diff (HEAD
vs worktree) anchored the remaining deletion at one row while the
unstaged diff (index vs worktree), recomputed after the partial stage,
anchored the same logical deletion at a different row. Everything that
correlates hunks across those two diffs assumes they agree on positions:

- the secondary-status matching in `hunks_intersecting_range_impl` found
no unstaged hunk at the uncommitted hunk's rows and reported it as
staged (`NoSecondaryHunk`);
- the worktree→index projection in `compute_uncommitted_index_edits`
treated the hunk's position as unchanged text and, on unstage, inserted
the hunk's HEAD content at an index position that already contained it,
duplicating it on every request.

## Fix

Upgrade `imara-diff` from 0.1.8 to 0.2.0 and run
`Diff::postprocess_lines` (imara-diff's port of git's xdiff
slider/indent heuristic) after computing hunks in `buffer_diff`. This
canonicalizes the placement of ambiguous hunks based only on their local
content, so diffs of the same buffer against different base texts anchor
the same logical change at the same rows. A bonus is that Zed's hunk
placement now matches `git diff`'s output for such cases (git has used
the indent heuristic by default since 2.11).

As defense in depth, `compute_uncommitted_index_edits` now drops a
pure-insertion index edit whose content is already present at the target
position, so a stale secondary status can no longer duplicate index
content.

The imara-diff 0.2 API removed the `Sink` trait and the top-level
`diff()` function, so the other call sites (`language/text_diff.rs`,
`zeta_prompt/udiff.rs`, `edit_prediction_metrics/reversal.rs`) are
migrated mechanically to `Diff::compute` + `hunks()` with unchanged
behavior (0.2 also renamed `lines_with_terminator` to `lines` and
changed the default `&str` tokenization to include terminators; the
unified-diff builders keep terminator-less tokens via `str::lines()`).

## Testing

- New regression test `test_staging_hunks_with_ambiguous_placement`
replays the exact repro from zed-industries#60424 (same file contents): stages the
first deletion, asserts the remaining hunks keep their unstaged status
and the index matches exactly, then issues repeated unstage requests for
the unstaged hunk and asserts the index is unchanged. Before the fix
this test showed the second hunk flipping to staged and the index
growing by one copy of the deleted block per unstage request.
- `buffer_diff`, `language`, `zeta_prompt`, `edit_prediction_metrics`,
`multi_buffer`, `git_ui`, `editor`, and the `project` integration suite
pass.
- One test expectation updated: `editor::test_fold_function_bodies`
asserted the old placement of an ambiguous deletion (blank line before
comment); the canonicalized placement (comment before blank line)
matches what `git diff` produces for the same texts.

Release Notes:

- Fixed staging a hunk sometimes marking a different hunk as staged (and
subsequent unstaging corrupting the git index) when the diff contained
repeated lines
([zed-industries#60424](zed-industries#60424)).

---------

Co-authored-by: Cole Miller <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:integrations/git Git integration feedback cla-signed The user has signed the Contributor License Agreement first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Stage button stages part of the hunk in git but marks whole hunk as staged in UI

3 participants