hunkpatch

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

README

hunkpatch

Go Reference CI Go Report Card

Apply the sloppy unified diffs that language models write.

A Go port of aider's fuzzy hunk applier, checked function by function against the JavaScript implementation it was ported from.

中文文档

out, err := hunkpatch.Apply(source, modelProducedDiff)

The problem

git apply and patch reject what models produce. Line numbers are invented, context is copied approximately, indentation drifts, and the patch arrives wrapped in whatever the model felt like saying. All of the following are ordinary model output, and all of them work here:

@@ -900,3 +900,3 @@          ← line numbers that match nothing
-  const t = useTranslations()
+  const t = useTranslations('about')
*** Begin Patch                ← an envelope instead of a header
*** Update File: config.ts
@@
-const port = 8080
+const port = 3000
*** End Patch
@@                             ← no header, no line numbers, wrong indentation
-    <small>今日会员消费</small>
+    <small>本日の会員利用額</small>

Hunks are located by content, never by line number.

Install

go get github.com/zbysir/hunkpatch

Requires Go 1.21+. One dependency: github.com/sergi/go-diff.

Use

source := "func greet() string {\n\treturn \"hello\"\n}\n"
diff := "@@\n-\treturn \"hello\"\n+\treturn \"hello, world\"\n"

out, err := hunkpatch.Apply(source, diff)

err tells you three different things, and the returned text is usable in all three cases:

err meaning
nil every hunk applied
ErrNoHunks the diff contained no hunk; source returned unchanged
*PartialError some hunks applied, some could not be located

*PartialError is the one to actually handle. It is what a model miscopying context looks like, and ignoring it means writing back a file that looks fine and is missing an edit:

out, err := hunkpatch.Apply(source, diff)

var partial *hunkpatch.PartialError
if errors.As(err, &partial) {
	log.Printf("only %d of %d hunks applied", partial.Applied, partial.Total)
	for _, hunk := range partial.Skipped {
		log.Printf("could not locate:\n%s", strings.Join(hunk, ""))
	}
}

ApplyWith returns the same detail as a Result value, plus options.

Wrong indentation

The single most common way a model's hunk fails is copying the code correctly and the indentation wrongly. Options{IndentTolerant: true} retries the match ignoring leading whitespace, and rewrites the inserted lines with the file's indentation rather than the model's:

res, err := hunkpatch.ApplyWith(source, diff, hunkpatch.Options{IndentTolerant: true})

It only fires after every exact strategy has failed, and it refuses rather than guesses: the block must match in exactly one place, and every line must be off by the same indentation prefix. Ignoring indentation makes context less distinctive — the same fragment at two nesting levels is common — and a wrong guess here silently corrupts a file. See indent.go for the full list of guards.

Several files in one patch

Apply targets one file. For a patch spanning several, split it first:

for _, fd := range hunkpatch.FindDiffs(patch) {
	content := files[fd.NewFileName]
	for _, hunk := range fd.Hunks {
		if next, ok := hunkpatch.ApplyHunk(content, hunk); ok {
			content = next
		}
	}
	files[fd.NewFileName] = content
}

How the matching works

There is no similarity scoring and no line-number arithmetic. A hunk is split into its "before" and "after" text, and the before text is searched for as an exact substring; each attempt is tried with and without blank-line trimming.

When that fails, two things happen in order:

  1. The context is rebuilt from the real file. makeNewLinesExplicit aligns the hunk's before-side to the actual file content with diff-match-patch, then regenerates a hunk with complete context. This is what repairs a model that dropped or invented a context line.
  2. The context is thrown away, one line at a time. The hunk is cut into (preceding context, changes, following context) triples, and each triple is retried with less and less context, trying every split between the two sides until something matches.

That second step is the whole of the "fuzziness": the match itself stays exact, and tolerance comes from asking for less. It also explains the guard rails — when the remaining context is under 10 characters and occurs more than once in the file, the hunk is refused rather than applied to an arbitrary occurrence.

Where this comes from, and why it is a port

The algorithm is aider's (Python, Apache-2.0). It was ported to TypeScript as the npm package llm-diff-patcher@0.2.1, and this is a Go port of that package's aider_port directory.

Using the algorithm from Go otherwise means running the JavaScript: an embedded JS engine, a bundled copy of the library shipped alongside your binary, and whatever that engine costs in concurrency and startup — and any change to the matching behaviour has to be made in JavaScript and rebuilt. A native implementation drops all of it, and puts the algorithm somewhere it can be read, tested and extended in Go.

Reaching for an existing Go diff library does not solve it either. Those libraries are built to produce diffs; putting back a hunk whose context is only approximately right is a different problem, and it is the problem models create. And the piece that had to be ported rather than substituted is the Myers implementation inside jsdiff: when several shortest edit scripts exist, which one is returned depends on the diagonal iteration order and on how add/remove paths break ties. Different implementations group additions and removals differently, and unidiff.formatLines turns that grouping straight into hunk text. Swapping in another library changes the output.

Porting principle: faithful first, better second

The port reproduces the original including its dead code and its unfinished parts, each marked with a comment:

  • searchAndReplace computes two normalised strings and never uses them.
  • allPreprocs has four entries, but tryStrategy implements only the first of its three flags, so entries 3 and 4 are duplicates of 1 and 2 and are tried twice for nothing.
  • The relativeIndent flag is destructured and dropped, which means aider's RelativeIndenter was never ported to JavaScript at all. That gap is exactly why wrong indentation fails, and it is why IndentTolerant exists here — as an opt-in addition, so that "we changed the behaviour" is never confused with "we ported it wrong".

How this was verified

Equivalence is not claimed, it is recorded. The port was developed against the real llm-diff-patcher bundle running in a JS engine, and every case below is checked into testdata/ with the JavaScript side's answer:

file what it pins cases
parity_apply.json the whole pipeline, end to end 409
parity_internal.json 7 internal functions, per input 29×7
parity_unidiff.json jsdiff's Myers + unidiff's hunk formatter 56
parity_finddiffs.json patch splitting, file names included 10
js_semantics.json the emulated JavaScript string primitives 187

Comparing only the final output is not enough. The fallback chain is so forgiving that an internal function can be wrong and the end result still comes out right. Measured, not assumed: with 15 deliberate mutations planted in the port, end-to-end comparison caught 8. Miscounted UTF-16 lengths, treating the empty string as success, trying fewer preproc combinations, swapping Myers' add/remove branches and one missing pop() in the tokenizer were all masked by the fallbacks. Hence the per-function goldens.

Reading the two implementations side by side is not enough either. The things that actually break a port of JavaScript are invisible in the structure:

  • String.replace with a string argument replaces only the first match.
  • .length counts UTF-16 code units. "今日会员消费" is 6 in JavaScript and 18 in bytes — and directlyApplyHunk branches on length < 10.
  • The empty string is falsy, so a strategy that returns "" counts as a failure and the search continues to the next one.
  • String.prototype.trim treats U+FEFF as whitespace and U+0085 as not; Go's strings.TrimSpace does the exact opposite on both.

Each of those has a test that a mutation proved the other cases could not distinguish. js_semantics.json is generated by running the primitives through node (node testdata/js_semantics.mjs), so those expectations come from a real JavaScript engine rather than from someone's reading of the spec — regenerating it needs nothing but node. The parity_*.json files were captured from the original bundle and are checked in as-is; reproducing them requires llm-diff-patcher@0.2.1 and a JS runtime.

Currently 549 test cases, 96% statement coverage.

Deliberate differences from the original

  1. Options{IndentTolerant} — an addition; off by default. See above.
  2. makeNewLinesExplicit on malformed input. Where JavaScript throws (two adjacent changes of the same type reaching checkAndAssignTypes), the exception escapes past applyHunks and aborts the whole call. Go has no exceptions here, so it falls back to the un-rewritten hunk and carries on. This is the one behavioural difference in the algorithm itself.
  3. Partial application is reported. The original silently skips a hunk it cannot place and returns the text as if nothing was wrong. So does the algorithm here — but Apply tells you about it through *PartialError. Relatedly, a hunk with no context lines at all "succeeds" upstream without changing anything; ApplyWith counts a hunk as applied only when the text actually changed.
  4. normalizeHunk is not ported. Nothing in the apply path reaches it, so it was never covered by the differential suite, and its output lines carry no trailing newline — the result cannot be fed back into ApplyHunk. Shipping it would have been handing callers a trap.
  5. diff-match-patch timeout. Both sides use a 5-second timeout, after which the library degrades to a coarse diff. Native Go is far faster than the same code under a JS engine, so on a large enough input the JavaScript side can time out while Go has not. The parity cases stay well away from that size.

License

Apache-2.0. See LICENSE and NOTICE — the upstream chain mixes Apache-2.0 (aider) and MIT (the npm package), and NOTICE explains why this port picked the more restrictive of the two.

Documentation

Overview

Package hunkpatch applies sloppy, LLM-generated unified diffs to text.

Language models do not produce diffs that `patch` or `git apply` will accept. They get line numbers wrong, copy the surrounding context imperfectly, shift the indentation, wrap the patch in an `*** Begin Patch` envelope, or emit a hunk that is nothing but an anchor line. A strict patcher rejects all of that. This package accepts it, using the fuzzy hunk-application algorithm from aider: match the change block exactly, and when that fails, retry with less and less surrounding context until something lines up.

Where this code comes from

The algorithm is aider's (https://github.com/Aider-AI/aider, Apache-2.0), which was ported to TypeScript as the npm package llm-diff-patcher@0.2.1 (MIT). This package is a line-by-line Go port of that npm package's `aider_port` directory. The JS files it corresponds to:

dist/aider_port/normalize_utils.js
dist/aider_port/search_replace.js
dist/aider_port/aider_udiff.js
dist/aider_port/diff_lines.js
dist/aider_port/make_new_lines_explicit.js
dist/aider_port/apply_hunk.js

plus the parts of its two third-party dependencies that are actually reached: unidiff's diffLines/formatLines (which embeds jsdiff's Myers implementation) and diff-match-patch (here: github.com/sergi/go-diff).

Porting principle: faithful first, better second

The port reproduces the original byte for byte, including its dead code and its half-finished pieces. For example search_replace.js destructures a `relativeIndent` flag and then never uses it, which means aider's RelativeIndenter was never actually ported to JS; that gap is preserved here and marked with a comment. Improvements are opt-in and live behind Options, so that "we changed the behaviour" is never confused with "we ported it wrong".

Equivalence is not a claim, it is a test: every function below was compared against the JS original running in a JS engine, over several hundred inputs, and the recorded results are checked in under testdata/. See the README for how that suite is built and why comparing only the final output is not enough.

Typical use

out, err := hunkpatch.Apply(source, modelProducedDiff)

Apply targets a single file. For a patch that spans several files, use FindDiffs to split it and ApplyHunk to apply each hunk to the right file.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrNoHunks = errors.New("hunkpatch: no hunk found in diff")

ErrNoHunks is returned when the diff contains nothing that can be read as a hunk — an empty string, prose with no `@@`/`-`/`+` lines, and so on.

Functions

func Apply

func Apply(source, diff string) (string, error)

Apply applies diff to source and returns the patched text.

diff is whatever the model produced: a unified diff, a hunk with no header, a patch wrapped in an `*** Begin Patch` envelope, a fenced ```diff block. Line numbers in `@@` headers are ignored — hunks are located by content.

Apply targets a SINGLE file: any file headers in the diff are replaced with a placeholder before matching, so every hunk is applied to source. For a patch spanning several files, use FindDiffs to split it first and ApplyHunk to apply each hunk to its own file.

The returned text is always usable. err is ErrNoHunks when the diff held no hunk at all (source is returned unchanged), or a *PartialError when some hunks could not be located (the rest have been applied).

Example
package main

import (
	"fmt"

	"github.com/zbysir/hunkpatch"
)

func main() {
	source := `func greet() string {
	return "hello"
}
`
	// What a model typically emits: no file header, and a line range that does
	// not correspond to anything.
	diff := `@@ -1,3 +1,3 @@
 func greet() string {
-	return "hello"
+	return "hello, world"
 }
`

	out, err := hunkpatch.Apply(source, diff)
	if err != nil {
		panic(err)
	}
	fmt.Print(out)
}
Output:
func greet() string {
	return "hello, world"
}
Example (Envelope)

Apply accepts the `*** Begin Patch` envelope, prose around the diff, and ```diff fences without any pre-processing by the caller.

package main

import (
	"fmt"

	"github.com/zbysir/hunkpatch"
)

func main() {
	source := "const port = 8080\n"
	diff := `Sure — here's the change:

*** Begin Patch
*** Update File: config.ts
@@
-const port = 8080
+const port = 3000
*** End Patch`

	out, err := hunkpatch.Apply(source, diff)
	if err != nil {
		panic(err)
	}
	fmt.Print(out)
}
Output:
const port = 3000
Example (Partial)

A hunk that cannot be located does not sink the whole patch: the hunks that matched are applied and the failure is reported.

package main

import (
	"errors"
	"fmt"

	"github.com/zbysir/hunkpatch"
)

func main() {
	source := "alpha\nbravo\ncharlie\n"
	diff := "@@\n alpha\n-bravo\n+BRAVO\n@@\n-delta\n+DELTA\n"

	out, err := hunkpatch.Apply(source, diff)

	var partial *hunkpatch.PartialError
	if errors.As(err, &partial) {
		fmt.Printf("applied %d of %d hunks\n", partial.Applied, partial.Total)
	}
	fmt.Print(out)
}
Output:
applied 1 of 2 hunks
alpha
BRAVO
charlie

func ApplyHunk

func ApplyHunk(content string, hunk []string) (string, bool)

ApplyHunk applies a single hunk to content and reports whether it matched.

A hunk is a slice of lines, each keeping its trailing newline and its leading operator: ' ' for context, '-' for removal, '+' for addition. A line shorter than two UTF-16 code units is treated as context.

ok == false means the hunk could not be located, and the first return value is then "" rather than content.

Beware that ok == true does NOT guarantee the text changed. A hunk with no context lines at all — only '-' and '+' — has no context/change/context triple to work with, so the matching loop never runs and the content comes back untouched with ok == true. That is upstream's behaviour and it is preserved here; ApplyWith compensates by treating an unchanged result as a skipped hunk.

Corresponds to applyHunks(content, hunk) upstream — despite the plural, that function takes a single hunk.

func ApplyHunkWith

func ApplyHunkWith(content string, hunk []string, opts Options) (string, bool)

ApplyHunkWith is ApplyHunk with the opt-in strategies in Options enabled.

Types

type FileDiff

type FileDiff struct {
	// OldFileName is the path from the `--- ` header with any a/ or b/ prefix
	// removed, or "unknown" when the diff carried no matching header.
	OldFileName string
	// NewFileName is the path from the `+++ ` header, prefix removed.
	NewFileName string
	// Hunks holds each hunk as a slice of lines, newline included, with the
	// leading ' ', '-' or '+' operator still attached.
	Hunks [][]string
}

FileDiff is one file's worth of hunks, as returned by FindDiffs.

func FindDiffs

func FindDiffs(content string) []FileDiff

FindDiffs splits a patch into per-file hunks. It corresponds to findDiffs(content) and only reads what is inside ```diff fences; a bare diff has to be fenced first (Apply does that for you).

Hunks with no `--- `/`+++ ` header pair are dropped, matching the original.

Example

For a patch touching several files, split it with FindDiffs and apply each file's hunks yourself.

package main

import (
	"fmt"

	"github.com/zbysir/hunkpatch"
)

func main() {
	patch := "```diff\n" +
		"--- a/main.go\n+++ b/main.go\n@@ @@\n-var x = 1\n+var x = 2\n" +
		"--- a/util.go\n+++ b/util.go\n@@ @@\n-var y = 1\n+var y = 2\n" +
		"```\n"

	files := map[string]string{
		"main.go": "var x = 1\n",
		"util.go": "var y = 1\n",
	}

	for _, fd := range hunkpatch.FindDiffs(patch) {
		content := files[fd.NewFileName]
		for _, hunk := range fd.Hunks {
			if next, ok := hunkpatch.ApplyHunk(content, hunk); ok {
				content = next
			}
		}
		fmt.Printf("%s: %s", fd.NewFileName, content)
	}
}
Output:
main.go: var x = 2
util.go: var y = 2

type Options

type Options struct {
	// IndentTolerant enables the indentation-tolerant strategy: after every
	// exact strategy has failed, retry the match ignoring leading whitespace.
	// The block must match in exactly one place AND every line must be off by
	// the same indentation prefix, otherwise it still fails. See indent.go.
	IndentTolerant bool
}

Options turns on behaviour that goes beyond the port. The zero value is exactly the upstream JavaScript implementation.

They are per-call rather than a package-level switch so that two policies can coexist in one process: strict equivalence with the JavaScript implementation where that is what you need, and the extra strategies where rescuing a hunk is worth more than matching upstream exactly.

type PartialError

type PartialError struct {
	// Total is how many hunks the diff contained.
	Total int
	// Applied is how many of them changed the text.
	Applied int
	// Skipped holds the hunks that left the text unchanged, in the order they
	// appeared.
	Skipped [][]string
}

PartialError reports that the patch applied, but not completely. Apply and ApplyWith still return the best-effort text alongside it: the hunks that did match have been applied, and the ones in Skipped have not.

This is worth handling rather than ignoring. A model that miscopied context produces a hunk that cannot be located, and without this you would write back a file that looks fine and is missing an edit.

A hunk that leaves the text exactly as it was counts as skipped, since nothing would be written either way. That covers both the hunk whose context could not be found and the hunk that asks for no change at all (an anchor-only hunk, or one whose '-' and '+' lines are identical).

func (*PartialError) Error

func (e *PartialError) Error() string

type Result

type Result struct {
	// Text is the patched text. When some hunks did not apply it still holds
	// the ones that did.
	Text string
	// Total is how many hunks the diff contained.
	Total int
	// Applied is how many of them changed the text.
	Applied int
	// Skipped holds the hunks that left the text unchanged; see PartialError.
	Skipped [][]string
}

Result is the full outcome of ApplyWith.

func ApplyWith

func ApplyWith(source, diff string, opts Options) (Result, error)

ApplyWith is Apply with the opt-in strategies in Options enabled, and reports per-hunk detail in Result. The error follows the same contract as Apply, so a caller that already inspects Result can ignore it.

Example (IndentTolerant)

Options.IndentTolerant rescues the common case of a model that copied the code but not its indentation. The replacement is written with the file's indentation, not the model's.

package main

import (
	"fmt"

	"github.com/zbysir/hunkpatch"
)

func main() {
	source := "if ok {\n        alpha := 1\n        bravo := 2\n}\n"
	// The model used 4 spaces where the file uses 8.
	diff := "@@\n-    alpha := 1\n-    bravo := 2\n+    alpha := 10\n+    bravo := 2\n"

	res, err := hunkpatch.ApplyWith(source, diff, hunkpatch.Options{IndentTolerant: true})
	if err != nil {
		panic(err)
	}
	fmt.Print(res.Text)
}
Output:
if ok {
        alpha := 10
        bravo := 2
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL