Repository navigation
buffer_diff: Canonicalize ambiguous hunk placement to fix staging corruption - #60584
Conversation
|
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'. |
|
@cla-bot check |
|
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
548fa20 to
f67e3fc
Compare
|
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
$ 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 |
…placement # Conflicts: # Cargo.toml
|
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. |
…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]>
…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]>


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\nline 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:hunks_intersecting_range_implfound no unstaged hunk at the uncommitted hunk's rows and reported it as staged (NoSecondaryHunk);compute_uncommitted_index_editstreated 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-difffrom 0.1.8 to 0.2.0 and runDiff::postprocess_lines(imara-diff's port of git's xdiff slider/indent heuristic) after computing hunks inbuffer_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 matchesgit diff's output for such cases (git has used the indent heuristic by default since 2.11).As defense in depth,
compute_uncommitted_index_editsnow 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
Sinktrait and the top-leveldiff()function, so the other call sites (language/text_diff.rs,zeta_prompt/udiff.rs,edit_prediction_metrics/reversal.rs) are migrated mechanically toDiff::compute+hunks()with unchanged behavior (0.2 also renamedlines_with_terminatortolinesand changed the default&strtokenization to include terminators; the unified-diff builders keep terminator-less tokens viastr::lines()).Testing
test_staging_hunks_with_ambiguous_placementreplays 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 theprojectintegration suite pass.editor::test_fold_function_bodiesasserted the old placement of an ambiguous deletion (blank line before comment); the canonicalized placement (comment before blank line) matches whatgit diffproduces for the same texts.Release Notes: