Software Engineering · Code Review
What a Unified Diff Hunk Actually Encodes
How the hunk header, the minus and plus blocks, and the context window are produced, and which parts of a change a line-oriented diff cannot represent at all.
A diff is a lossy projection of a change. The commit itself is a pair of file contents plus a history; the diff is an edit script over lines, produced by a specific algorithm with specific defaults, and everything a reviewer concludes about intent is inferred from that script rather than read from it. Two commits with the same semantic effect can produce very different diffs, and two commits with different effects can produce diffs that look nearly identical. The format is worth knowing precisely because review decisions are made against it.
A concrete case: a function whose parameter list is reordered, and whose body is adjusted to match. The diff shows four deleted lines and four added lines that appear to have nothing to do with each other, because the algorithm matched lines by content and the reordered signature lines matched nothing. Nothing in the output says “these two blocks are the same code in a different order”. The reviewer supplies that interpretation, and sometimes supplies it wrongly.
The hunk header is an address, not a summary
A unified diff body is a sequence of hunks, each introduced by a line of the form:
@@ -12,7 +12,9 @@ def parse(self, stream):
The four numbers are addresses in the two versions of the file. -12,7 means the hunk covers the
old file starting at line 12 and spanning 7 lines; +12,9 means the corresponding region of the new
file starts at line 12 and spans 9 lines. Both counts include the context lines, which appear once in
the hunk and count toward both sides. The old-side span minus the new-side span equals the net line
delta, so a hunk that adds two lines shows counts that differ by two.
Two conventions trip up parsers. When a span contains exactly one line, GNU diff omits the comma
and the count entirely, writing -12 rather than -12,1, so a parser that splits on a comma fails
on the most common case. And when one side of a hunk is empty — an insertion at the start of an empty
file, or a deletion that leaves nothing behind — the start number is 0, because there is no line to
point at; the address -0,0 is legal and means “before the first line”. Applying a patch without
handling that case produces an off-by-one on the first hunk of newly created files, which is exactly
where such bugs are hardest to notice.
The trailing text after the second @@ is free-form and has no effect on patch application. Git
fills it from a per-language pattern that recognises function and class headers, so the same hunk in
a Python file is annotated with def parse(self, stream): and in a C file with the enclosing
signature. That annotation is a display convenience computed from the new side of the hunk and the
nearest preceding line matching the pattern. It is not necessarily the function the changed line
belongs to, because the nearest preceding match can be a nested definition or a commented-out
signature, and reviewers who trust it as a scope label are trusting a heuristic.
Because the counts are spans and not line numbers of individual changes, computing the new-file line number of a particular added line requires walking the hunk from its start and incrementing two counters. Review tools do this to anchor inline comments; a comment stored against a line number without the surrounding hunk context becomes meaningless after the next rebase, which is the mechanical reason review comments are attached to a diff position rather than to a line.
Why the minus and plus blocks are not aligned
A line diff is an edit script derived from a longest-common-subsequence computation. The algorithm
picks a set of old lines that it declares matched to new lines; every old line outside that set is a
deletion, every new line outside it is an insertion. The output then groups consecutive deletions
and consecutive insertions, which is why a hunk body is a run of - lines followed by a run of +
lines rather than an interleaved list of paired modifications.
There is no such thing as a modified line in this model. A line whose first character changed is one deletion and one insertion that happen to be adjacent. Three consequences follow for review.
The first is scale. Changing one identifier inside a 200-character line deletes and re-adds the whole line, so a mechanical rename across a file produces a diff whose size is proportional to the file, not to the number of tokens changed. This is the noise floor that whitespace-insensitive and identifier-aware comparison attempt to lower, and it is also why a large refactor can be unreviewable as a diff while being trivial as a semantic change.
The second is that moved code is indistinguishable from rewritten code. A block relocated from one file to another appears as a deletion at the source and an insertion at the destination, and only rename and copy detection, which compares content similarity above a threshold, can relabel it. Git detects renames by default at a 50 percent similarity threshold; a file that was moved and edited past that threshold reverts to delete-plus-add, and the reviewer then reads an entire file as new code. Raising or lowering the threshold with the rename options trades false renames against false deletions, and no threshold makes the ambiguity disappear.
The third is ordering. Lines in the plus block are in new-file order and lines in the minus block are in old-file order, and the two orders are unrelated. A hunk that swaps two adjacent lines shows them as deleted in one order and added in the other, which reads as two independent rewrites. Reviewers who scan hunks for “what changed” rather than reading the resulting file will miss the swap.
A fourth marker sits outside this model entirely: \ No newline at end of file after a line means
that line has no terminating newline. It is a property of the file, not of the line, and a diff that
adds a trailing newline to a file shows both a deletion and an insertion of an otherwise identical
line. Some tools strip trailing whitespace on save; when that happens across a whole file, the diff
becomes every line at once.
The context window is a compromise between anchoring and volume
Context lines exist so that a hunk can be located in the file by a patch tool that does not trust the
line numbers, and so that a human can see what surrounds the change. Three lines is the default in
GNU diff and in Git, adjustable per invocation and by configuration. The window is a property of the
rendering, not of the review unit the change was split into,
which is why a well-shaped change can still produce a hunk that sprawls. More context makes hunks
easier to place and easier to judge, at the cost of more lines to read; fewer than three makes hunks
ambiguous when a file contains repetitive blocks, which is why patch application relies on context as
well as offsets.
Git additionally shifts hunk boundaries by a small amount to align them with blank lines and indentation, an adjustment that is on by default in current versions. The effect is that a hunk tends to start and end at a visually natural boundary rather than exactly three lines before the first change. It changes presentation only; the edit script is the same.
The dangerous knobs are the ones that change what counts as a change. Ignoring whitespace makes indentation-only reflow vanish from the diff, which is correct for languages where indentation is insignificant and wrong for Python, YAML, and Makefiles, where a whitespace change can alter meaning while the diff shows nothing. It also hides whitespace inside string literals, so a change to a message string that consists only of spacing disappears. The safe pattern is to read the whitespace-insensitive diff for the shape of the change and the plain diff for the lines that matter, and to be explicit about which one a review was performed against.
What the format leaves out
The header region carries facts the hunks cannot: file modes, similarity indices for renames,
index lines naming the blob hashes before and after, and a marker for binary content, which is
either summarised as differing or encoded as a binary patch with no reviewable line structure. A
mode change from 100644 to 100755 is a one-line header difference with real consequences, and it
produces no hunk at all.
None of this is a defect in the format. A line-oriented patch is what makes a diff portable, text-searchable, and cheap to store. The cost is that every semantic claim a reviewer makes — “this is a rename”, “this loop was hoisted”, “this branch was inverted” — is an inference layered on a choice of edit script. Which edit script gets produced depends on the algorithm, and different algorithms produce genuinely different diffs for the same pair of files, as the choice of diff algorithm makes plain.
A short parser shows how thin the information actually is: hunk headers give spans, and everything else is reconstructed by counting prefixes.
import re
HUNK = re.compile(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@')
def annotate(patch):
"""Yield (old_line, new_line, text) for each body line of a unified diff."""
old = new = None
for line in patch.splitlines():
m = HUNK.match(line)
if m:
old = int(m.group(1)) or 1 # start 0 means "before line 1"
new = int(m.group(3)) or 1
continue
if old is None or line[:1] not in (' ', '-', '+'):
continue # headers, blank separators
tag, text = line[0], line[1:]
if tag == ' ':
yield old, new, text
old += 1
new += 1
elif tag == '-':
yield old, None, text
old += 1
else:
yield None, new, text
new += 1
The counting is trivial; the interpretation is not. Every judgement about a change — whether it is one edit or two, whether code moved or was rewritten, whether a hunk is large because the change is large or because the algorithm could not match anything — depends on knowing that the diff chose one of many valid edit scripts and presented it without saying so.
References
- GNU Diffutils Manual — Unified Format — GNU Project
- git-diff(1) Manual Page — Git project
- An O(ND) Difference Algorithm and Its Variations — Eugene W. Myers, Algorithmica, 1986