Skip to content

Git - re-read the index base when staging, reverting or unstaging ranges#326397

Open
soreavis wants to merge 1 commit into
microsoft:mainfrom
soreavis:fix/64027-stage-selected-ranges-stale-index
Open

Git - re-read the index base when staging, reverting or unstaging ranges#326397
soreavis wants to merge 1 commit into
microsoft:mainfrom
soreavis:fix/64027-stage-selected-ranges-stale-index

Conversation

@soreavis

Copy link
Copy Markdown

Fixes #64027

The bug

Stage a selected range, commit, then stage a second range with nothing in between, and the second stage also stages an exact revert of the just-committed region (it shows up in git diff --cached and gets committed). Reliably reproducible; @joaomoreno diagnosed the mechanism back in 2018 ("VS Code fails to refresh the index version of the file in time for that second stage").

Root cause

_stageChanges/_revertChanges/the two unstage paths build the staged content by applying the selected line changes onto the index/HEAD base, which they obtain via openTextDocument(toGitUri(uri, '~' | 'HEAD')). The git file system provider serves that content fresh (readFile runs git show every time), but the workbench caches the TextDocument, and the provider's invalidation event is @debounce(1100). So within ~1.1s of a commit the cached base is stale — the second stage applies the new hunk onto the pre-commit base and reverts the first commit. workspace.fs.readFile on the same git: URI bypasses the document cache and is always fresh.

The fix

Read the base directly with workspace.fs.readFile + workspace.decode({ encoding: modifiedDocument.encoding }) (the inverse of the workspace.encode({ encoding }) already used on the stage write path), instead of opening a document for it. applyLineChanges now takes the two sides as strings, backed by a small LineIndex helper that provides the lineCount/line-length/getText behaviour it relied on — so the freshly read base can be applied without a TextDocument. Both parameters became strings because the unstage sites pass the git side as the modified argument.

I kept LineIndex rather than reaching for openTextDocument({ content }) deliberately: an untitled document per stage would land in workspace.textDocuments and fire onDidOpenTextDocument to every extension on a frequently-run command, and slicing the string preserves the base bytes/EOL exactly (an untitled doc infers and can normalize EOL). LineIndex is verified byte-identical to the previous TextDocument-based algorithm.

Scope

This changes the four applyLineChanges-based range/hunk commands (stage/revert/unstage selected ranges and the gutter stage/revert/unstage-change actions). The diff-editor toolbar's git.diff.stageHunk/stageSelection path stages a string precomputed by the diff editor and never went through this base read, so it is unaffected.

Tests

  • extensions/git/src/test/smoke.test.ts — an end-to-end regression: stage a region, commit, stage a second region, and assert the index contains only the second region. Verified it fails on the current code (stages 1\n… — line 1 reverted) and passes with the fix.
  • extensions/git/src/test/staging.test.ts — unit coverage for the applyLineChanges string refactor: mid-file, end-of-file insert/delete (Can't stage empty removed last line #59670), CRLF, and multiple selected changes.

The end-to-end test covers git.stageSelectedRanges (the ~/index base), which is the reliably reproducible case since the working-tree quick diff always keeps that document open. The revert and unstage sites take the identical fresh read against the HEAD base, whose staleness is impractical to drive in an end-to-end test, so they lean on the applyLineChanges byte-parity units plus the shared read path.

Staging, reverting and unstaging selected line ranges computed the
result against a document opened for the git index/HEAD content
(openTextDocument on a git: URI). That content is served fresh by the
git file system provider, but the workbench caches the document and its
invalidation after a repository change is debounced, so a
stage -> commit -> stage sequence with nothing in between reads the
pre-commit base and stages an exact revert of the just-committed change.

Read the base directly through the file system provider
(workspace.fs.readFile), which always reads fresh, instead of opening a
document for it. applyLineChanges now takes the two sides as strings so
the freshly read base can be applied without a TextDocument.

Fixes microsoft#64027
Copilot AI review requested due to automatic review settings July 17, 2026 20:04
@vs-code-engineering

Copy link
Copy Markdown
Contributor

📬 CODENOTIFY

The following users are being notified based on files changed in this PR:

@lszomoru

Matched files:

  • extensions/git/src/commands.ts
  • extensions/git/src/staging.ts
  • extensions/git/src/test/smoke.test.ts
  • extensions/git/src/test/staging.test.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes stale Git index/HEAD reads during range staging, reverting, and unstaging.

Changes:

  • Reads Git base content directly from the filesystem provider.
  • Refactors line-change application to operate on strings.
  • Adds unit and end-to-end regression coverage.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
extensions/git/src/staging.ts Adds string-based line indexing and change application.
extensions/git/src/commands.ts Uses fresh base content for range operations.
extensions/git/src/test/staging.test.ts Tests line application and EOL edge cases.
extensions/git/src/test/smoke.test.ts Covers the stale-index regression.

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.

Git - "Stage selected ranges" sometimes stages a revert of changes in the previous commit

3 participants