Add the patch verbs: reading hunks, applying them, and unstaging - #119
Merged
Merged
Conversation
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.
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.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 filemarker and apply then rejects it.Apply(patchText)puts a patch into the index, withToIndex(),Reversed()andChecked(). Staging a hunk isApply(text).ToIndex(), unstaging one is the same withReversed(), andChecked()answers "would this still apply?" without changing anything.Unstage(path)removes a path's staged changes, probing the installed git and falling back fromrestore --stagedtoreset HEADbelow 2.23.GitFilePatch.PatchFor(hunks)assembles a chosen subset into textgit applyaccepts, 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
IsConflictedwithKind = Unmergedand no hunks, since combined format is not an applyable patch. A binary file comes backIsBinarywith no hunks. Renames carry headers and may carry hunks too. Untracked files never appear, which is documented onIGitPatchBuilder, 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.noprefixemitsdiff --git f.txt f.txtanddiff.mnemonicPrefixemitsi/f.txt w/f.txt. Neither carries thea//b/prefixes the parser reads, soPatch()threw on the first file of every patch. Pinned with--src-prefix=a/ --dst-prefix=b/.diff.suppressBlankEmptydrops 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..gitattributesdiff driver orcolor.diff = alwayswould degrade or decorate the patch, handled by--no-ext-diff,--no-textconvand--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.
Applyrefuses 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.GitRestoreBuilderpasses its path after a bare--rather than the library's usual--end-of-options, because that option arrived in git 2.24 whilerestorearrived 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.InitialisetoInitializeandGitSubmoduleState.UninitialisedtoUninitialized, 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