Skip to content

Add the patch verbs: reading hunks, applying them, and unstaging - #119

Merged
matt-edmondson merged 13 commits into
mainfrom
feat/patch-verbs
Sep 24, 2026
Merged

matt-edmondson merged 13 commits into
mainfrom
feat/patch-verbs

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Gives the library the three git verbs a caller needs to show a diff and stage part of a file. Before this, Diff() could say which files changed and by how many lines, but there was no patch text and no hunks, so there was nothing to draw and nothing to slice.

What this adds

  • Patch() reads a diff into files, hunks and lines. Each hunk keeps the bytes git emitted alongside the parsed form, because a hunk regenerated from its lines loses the \ No newline at end of file marker and apply then rejects it.
  • Apply(patchText) puts a patch into the index, with ToIndex(), Reversed() and Checked(). Staging a hunk is Apply(text).ToIndex(), unstaging one is the same with Reversed(), and Checked() answers "would this still apply?" without changing anything.
  • Unstage(path) removes a path's staged changes, probing the installed git and falling back from restore --staged to reset HEAD below 2.23.
  • GitFilePatch.PatchFor(hunks) assembles a chosen subset into text git apply accepts, in file order, refusing an empty or foreign selection.

The design is at docs/superpowers/specs/2026-09-24-patch-verbs-design.md.

What a patch cannot express

The model reports rather than pretends. A conflicted file comes back IsConflicted with Kind = Unmerged and no hunks, since combined format is not an applyable patch. A binary file comes back IsBinary with no hunks. Renames carry headers and may carry hunks too. Untracked files never appear, which is documented on IGitPatchBuilder, because a staging view's file list and its hunks then come from sources that disagree.

Hostile configuration

Three settings would otherwise break the feature for anyone who has them on, and the vector now pins all three:

  • diff.noprefix emits diff --git f.txt f.txt and diff.mnemonicPrefix emits i/f.txt w/f.txt. Neither carries the a//b/ prefixes the parser reads, so Patch() threw on the first file of every patch. Pinned with --src-prefix=a/ --dst-prefix=b/.
  • diff.suppressBlankEmpty drops the leading space from a blank context line, which truncated the hunk's text and then threw an undocumented exception type. Pinned with -c diff.suppressBlankEmpty=false, and the parser's loop now refuses to hand a content line to its header parser.
  • A .gitattributes diff driver or color.diff = always would degrade or decorate the patch, handled by --no-ext-diff, --no-textconv and --no-color.

An integration test sets all three hostile keys together and asserts a patch still round-trips.

Known limits

Output decodes as UTF-8, so a repository holding Latin-1 or Shift-JIS source can produce U+FFFD where bytes did not survive. Apply refuses patch text containing that character rather than staging mojibake into the index, and says so. A file genuinely containing U+FFFD is refused too, which is the deliberate trade.

WithContext(0) is refused, naming --unidiff-zero, because apply cannot honor a zero-context patch and the resulting failure would point at the index instead of the setting.

GitRestoreBuilder passes its path after a bare -- rather than the library's usual --end-of-options, because that option arrived in git 2.24 while restore arrived in 2.23, so the convention would break exactly the versions the fallback serves. The reason is in a <remarks> on the method.

Also in this branch

One commit, 697d1dc, is a US-spelling sweep across 60 files of source and docs, requested separately and unrelated to the feature. It renames two public members, IGitSubmoduleUpdateBuilder.Initialise to Initialize and GitSubmoduleState.Uninitialised to Uninitialized, which is what makes this a major release.

Testing

708 passing, 0 warnings. Parser tests run against eight fixtures captured from a real git rather than hand-written, covering two hunks, a missing trailing newline, a rename that also changed, a binary file, a combined conflict hunk, CRLF content, an added file and a deleted one.

The test that matters most is the round trip against a real repository: generate a patch, take one hunk of a two-hunk file through PatchFor, apply it to the index, and assert the staged and unstaged halves split exactly. A parser that drops a byte passes every unit test and fails that one. Beside it, Checked() is pinned against both a moved working tree and a moved index, the latter being the case a hunk-staging caller's refusal actually depends on.

🤖 Generated with Claude Code

Diff answers which files changed and by how much, with no patch text and no
hunks, so a caller can neither draw a diff nor stage part of a file. There
is no apply, so no patch reaches the index, and no reset or restore, so
nothing staged can be unstaged.

Three verbs: Patch reads a unified diff into files, hunks and lines,
keeping each hunk's text verbatim because a regenerated one loses the
no-newline marker and is rejected on apply. Apply takes patch text and can
target the index, reverse itself, or check without changing anything.
Unstage covers the whole-file case, probing for restore and falling back to
reset the way Fetch already probes for porcelain.

The reader always passes --no-ext-diff and --no-textconv. A repository with
a gitattributes diff driver emits human-readable output instead of a patch,
so without them the feature would fail on exactly the repositories that
configure one.
Five tasks in the spec's order: the model and hunk assembly with no git at
all, the parser against captured fixtures, the reader, apply with the
round-trip test that proves every byte survived, and unstage with its
version fallback.

The round trip sits at task four rather than the end, because that is where
a byte lost in the model or the parser becomes visible, and a hand-written
fixture would pass every parser test and fail there.
A file patch keeps git's own hunk text beside the parsed lines, because a
hunk regenerated from its lines loses the no-newline marker and apply then
rejects it. PatchFor selects hunks into a patch, emitting them in file
order since git reads one top to bottom, and refuses an empty selection
rather than producing a header git calls corrupt.
Each hunk keeps the bytes git emitted alongside the parsed lines. Combined
format from an unmerged path is recognized and left unparsed rather than
turned into ordinary hunks, which would produce patches git rejects with
nothing explaining why, and a rename that also changed content keeps its
hunks.

The CRLF fixture mixes LF structural lines with CRLF content lines by
design, so .gitattributes excludes the patch-*.txt fixtures from this
repository's LF normalization; without that they would be rewritten to LF
on the next checkout and the test they support would stop meaning anything.
"Recognisable" slipped into a doc comment and its matching exception message.
Global constraints require US spelling throughout.
Diff answers which files changed and by how much. Patch answers what
changed, which is what a caller drawing a diff or staging a hunk needs. It
always passes --no-ext-diff and --no-textconv, because a repository with a
gitattributes diff driver otherwise emits something no caller can apply.
Staging a hunk is Apply(text).ToIndex(), and unstaging one is the same with
Reversed. Checked asks whether a patch would still apply without changing
anything, which is what lets a caller refuse cleanly when the working tree
moved underneath it.

Git reads a patch from standard input or a file, and the process request
carries no standard input, so the patch goes to a temporary file that is
deleted on every path. The round-trip test is what proves the model and the
parser kept every byte.
The repository's conventions call for US spelling in identifiers, comments
and user-facing strings, and roughly 190 British forms had accumulated
against that. This rewrites all of them, in prose, in exception messages,
in test method names and in the design documents.

Two public members are renamed with them, which is what makes this a major
release: IGitSubmoduleUpdateBuilder.Initialise becomes Initialize, and the
enum member GitSubmoduleState.Uninitialised becomes Uninitialized.

The captured patch fixtures are left alone. They hold bytes git emitted,
and nothing in this repository's conventions applies to those.
ToIndex() maps to --cached, and git's own manual for it says --cached checks
only the index entry. The working-tree case already covered here never
touches the index between reading a patch and applying it, so it cannot be
the guarantee ToIndex().Checked() depends on. This pins the case that is:
staging an unrelated edit between reading the patch and applying it, so the
index no longer matches the preimage, and confirming the index still holds
what was staged rather than the patch's content afterward.
git restore arrived in 2.23, so this probes the installed version and falls
back to reset below it, the same shape Fetch already uses for porcelain.
Unstaging a hunk is Apply reversed. This is the file-level verb, and the
only one available for a binary file, which has no hunks.
diff.noprefix and diff.mnemonicPrefix rewrite the a/ and b/ path prefixes the
header parser finds a file by, so either setting killed the verb outright for
the user who had it, and left a patch that needed -p0 to apply. The vector now
pins --src-prefix=a/ and --dst-prefix=b/, which override both.

diff.suppressBlankEmpty prints an empty context line as a bare newline, which
ended the hunk body at the first blank line and then handed a content line to
the hunk header parser, where it failed as an index exception rather than as
GitParseException. Both halves are fixed: -c diff.suppressBlankEmpty=false on
the vector, and a loop that requires "@@ " before it parses a hunk at all.

WithContext now refuses zero. git apply reads a zero-context patch only with
--unidiff-zero, which this library does not offer, so it was the one pairing of
the two new verbs that could never work, and the failure named the index rather
than the setting.

A conflicted file's Kind is Unmerged, which is what GitDiffParser already
reports for the same path, so a caller switching on GitChangeKind no longer
gets two answers from two verbs.

Two fixtures added for the new file and deleted file headers, which nothing had
ever run through this parser, captured from git 2.54.0 the way the other six
were.
PatchFor filtered this file's own hunks and counted the caller's input, so a
hunk from another file was dropped without a word. Pass only foreign hunks and
a header with no body reached git as the corrupt-patch error the empty check
exists to prevent. Pass a mix, as a multi-file selection would, and some hunks
were staged while the rest vanished. It now counts what it emitted and says
which file the rest did not belong to. The remark claiming apply would catch
this is replaced by one describing what the code does.

Apply refuses patch text carrying U+FFFD. Git's output is read as UTF-8, so a
repository holding Latin-1 or Shift-JIS text, which git diffs as text because
it decides binary on NUL bytes, loses every invalid byte to the replacement
character. With ASCII context around it the patch applied and staged mojibake
while the working tree kept the original bytes. A byte-level runner is the real
fix and is out of scope, so the constraint is documented on GitHunk.Text and
IGitPatchBuilder and the corruption is turned into a refusal.

GitRestoreBuilder separates its path with a bare -- rather than through
AppendOperands. --end-of-options arrived in git 2.24, one release after restore
itself, so on the old versions the reset fallback exists for it was read as a
pathspec and the command failed. The remarks say so, and no other builder
changes.

Apply's temporary file is written inside the try whose finally removes it, and
the removal no longer replaces git's exception with its own. Unstage validates
its path before reaching for the runner, and both new verbs document the
exceptions their siblings already did.
Comment thread GitIntegration/Builders/GitApplyBuilder.cs Fixed
Comment thread GitIntegration.Test/Integration/GitPatchRoundTripTests.cs Fixed
Comment thread GitIntegration.Test/Parsing/GitPatchParserTests.cs Fixed
Path.Combine resets to a later segment when one is rooted, which is why
TemporaryRepository already documents Path.Join as the API that means
append these segments. The three call sites this branch added now follow
that, and so do the two fixture loaders they were modeled on, which were
identical and would otherwise have been left in a second shape.
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 3d600b8 into main Sep 24, 2026
14 checks passed
@matt-edmondson
matt-edmondson deleted the feat/patch-verbs branch September 24, 2026 12:44
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