v0.9.0
Fixed
-
A hunk header whose ranges were separated by something other than one ASCII space — a tab, two
spaces, or a non-ASCII whitespace character such as U+00A0 — parsed, and the header was then
rebuilt on the way out with a plain space. A diff git calls a corrupt patch came back out as
one git accepts, at exit 0. Such a header is now refused as malformed (exit 2), the same as any
other header hunkpick cannot reproduce byte for byte. -
The sign of a hunk range was not checked at all:
@@ +1,3 +1,3 @@,@@ -1,3 1,3 @@,
@@ -+1,3 +1,3 @@and@@ -1,+3 +1,3 @@all parsed and were rendered back as
@@ -1,3 +1,3 @@at exit 0, while git reads every one of them as a corrupt patch. The leading
-and+are now required, and a range component has to be plain ASCII digits. -
Selecting by a content id shared by several sub-hunks scaled as the square of the number of
matches, in both time and memory. Reading every id out oflist --jsonand passing them back
— the documented batch flow — names such an id once per sub-hunk, and each naming both
appended a pick per match and re-ran the collision check over every match. A diff of 8 000
identical changes (150 KB) took 10 s and 3 GB of RSS; it is now a fraction of a second. The
same accumulator defect was reachable through a repeated*. -
The cap on how many indices a selector may materialise (
1048576) applied to one selector
while the number of selectors is bounded only by the length of the command line: 200 copies of
1-1048576— 2.6 KB of arguments against a four-sub-hunk diff — reached 1.6 GB of RSS before
any index was compared against the diff. The cap is now one allowance for the whole
invocation, and its message says so. -
splitnow applies the same trailing-newline rule asselect. A diff that arrived without
its final newline keeps that property only while the result still ends on the line the input
ended on, and a cut that drops the piece holding the tail does not:hunkpick split 1 --at 3
on such a diff ended its output mid-line at exit 0, andgit applycalled the result a
corrupt patch.selectwas taught this in 0.8.1;splitwas not. -
The defect a scheduled fuzzing run found — a hunk header declaring six old lines over a body
of three, whose selection then failed the tool's own output check — was closed by making the
fuzz target skip such input, and nothing recorded it afterwards. The precondition is now
stated onselect, the crate example screens its input the way the CLI does, and
select_carries_an_inconsistent_header_into_its_resultholds the shape, which until now
existed only in one machine'sfuzz/artifacts/. -
The
selectorsfuzz target recounts a hunk header from its body instead of skipping the
input. Skipping cost roughly a third of the parsed stream — the mutations libFuzzer favours
break the header-to-body correspondence more often than not — so most of what it generated no
longer reached the selection code the target exists for. -
Fuzz seeds are checked by
tests/fuzz_seeds.rs, which found three that had stopped reaching
the code they were written for:simple-content-idnamed a made-up id,two-files-path-index
named a path absent from its own diff, andtwo-files-starused a bare*on a multi-file
diff, which is a usage error. Each was silently skipped by the target rather than reported. A
seed for the@idcollision path was added, and the byte-identicalparse/androundtrip/
seed directories were merged into onediff/. -
The property tests reach two shapes they could not generate before: an edit whose changed
lines repeat (so several sub-hunks share a content id, and a selection by that id is checked
againstid_countfromlist --json), and agit format-patchsignature after the last
hunk (so the trailing-line shift insplit_file_hunkis covered by something other than one
example). -
Fuzzing is run from
scripts/fuzz-all.shandscripts/fuzz-repro.shinstead of a command
copied out of the guides. The documented command was missingmkdir -p fuzz/corpus/<target>,
and the corpus directory is gitignored, so on a fresh clone libFuzzer refused to start; the
scheduled CI run had already failed on exactly that and gained the step, while the guides did
not.fuzz/seeds/README.mdalso omitted the nightly override and the explicit target triple
that CONTRIBUTING.md calls mandatory, and the local command had no-timeout=10, so one hung
input could swallow a short run's whole budget. The scripts carry all four, and a test checks
that every script the documentation names exists and is executable. -
Documentation no longer contradicts the tool. Two README examples still recommended
--verify-result-diff-giton agit diff | hunkpick ... | git apply --cachedpipeline after
the README gained the paragraph explaining that such a command reports a correct result as a
failure (exit 70); both now show a patch file against a tree at the pre-patch state, and a test
reads the shell blocks of README.md and CONTRIBUTING.md so the shape cannot come back.
hunkpick --helpclaimed "every subset applies" for@Lwhileselect --helpand the README
already listed the two exceptions. The README described the selector index ceiling as per
selector, which it no longer is, and called two of the four lossy JSON text fields lossy. -
Only git's own verdict now decides exit 70 for
--verify-result-diff-git.git apply --check
answers 1, and only 1, when a patch does not apply; every other status is git failing before it
looked at the diff. A repository git refuses to read (fatal: bad config line …, exit 128) was
reported asgit apply --check rejected the result diff, and a git killed by a signal — the OOM
killer, a timeout — produced the same sentence with nothing after the colon. Both now name the
status and exit 74, so exit 70 keeps meaning what ADR 0013 reserves it for: a result hunkpick
itself produced. -
-C DIRnaming something that is not a directory is reported against that argument. A mistyped
path reachesCommand::spawnas the sameNotFounda missinggitbinary does, so both printed
failed to run git: No such file or directory (os error 2)at exit 74 and sent the caller after
their git installation instead of after their own typo. The path is now checked before git is
started and named in the message at exit 2; if git still fails to start, the message says which
directory it was to run in. -
The one non-ASCII character in hunkpick's own output is gone. The encoding diagnostic added in
this cycle carried an em dash, and it is addressed by construction to a Windows user, whose
console on cp866 or cp1251 shows it as mojibake in the middle of the sentence. The guard added
alongside it covered the help texts only, so the character came back in an error message the
same week; it now covers every string literal ofsrc/outside the test modules. -
One copy of the plumbing that runs
gitwith a diff on its stdin.gitenv::feed_and_wait
replaces three near-identical blocks — the result-diff check and two test helpers, two of them
added last cycle — that had already drifted apart in how they treat a failed write. It also
closes the failure path they shared: the child is now kept in hand and killed if waiting on it
fails, becausestd::thread::scopeparks until every thread it started has finished, and the
writing thread can be blocked in awritenobody is going to read. The failure
was a hang rather than a diagnostic, and the fallback test command documented in CONTRIBUTING
has no per-test timeout. The stub file names that keep a developer's git configuration out of
the test repositories moved togitenv::insulate_config, next to the list of variables that
was consolidated there last cycle, andtests/git_config_isolation.rsnow poisons
GIT_CONFIG_SYSTEMas well asGIT_CONFIG_GLOBAL, so dropping either one turns the suite red. -
The crates.io token no longer reaches
cargo publish --dry-runin the release workflow. A dry
run never asks the registry for credentials, but it does run a verification build, which
compiles the package along with the build scripts and proc macros of its dependencies — all of
them inheriting the step's environment. A rehearsal (workflow_dispatchwith an empty tag)
builds whatever branch the dialog names, so the step handed a credential good for every crate
of the account, and outliving any access to this repository, to code from an arbitrary branch.
The publishing step keeps its own copy of the token. -
The release pipeline checks the public API too.
cargo semver-checks check-releasewas a CI
gate only, and CI runs on branch pushes: a tag on a commit CI never saw, or one whose version
was bumped after the last run, reached the irreversiblecargo publishwith the API unchecked.
The release workflow now runs the same gate for the same reason its test, lint and MSRV jobs
are duplicated there. -
The documented local loop covers what CI gates. The
fuzzdirectory is a workspace of its own,
so the--allincargo fmt/cargo clippynever reached it, while CI lints it as a separate
step: a fuzz target edited locally passed every documented command and failed the pull request.
The two commands are in CONTRIBUTING.md now, along withcargo-semver-checksas a tool the
project expects; RELEASING.md runs the semver and MSRV checks before the release commit, and
the README says its list is the everyday loop and points at CONTRIBUTING.md for the full set
rather than claiming to be it. -
RELEASING.md describes the pipeline that exists. Its table of jobs still began with
testand
attributed the tag, manifest, lock and changelog checks topublish, while they have their own
first job (verify-metadata) that every other job waits on — the same document said so three
sections further down, contradicting its own table, and an operator reading it would have gone
looking in the wrong job's log. The intro counted four artefacts that must agree where there
are five:fuzz/Cargo.lockis checked too. The job numbers in "If something fails" follow the
table again. -
Six test gaps this cycle opened are closed. The four byte-order marks are checked one by one,
by name: the single test asserting the message says "UTF-16" also passed when a UTF-32LE input
was misdiagnosed as UTF-16LE, which is the mistake the arm order guards against. An I/O failure
on stdout is now held by a test (/dev/full, exit 74 with the diagnostic) instead of by a
comment. The seven repository-locating variables get a case each, with a count assertion that
fails on a name added to the list and not to the table. The differential timings in the source
cover all six tests that draw their case count from the same place, not three, and the
documented soak (HUNKPICK_DIFF_CASES=2000) comes with the timeout it needs — measured, the
slowest test goes from 4.5 s to 44.5 s against a 60 s kill. The linearity check takes the
fastest of three runs per size, so a scheduler pause on a shared runner no longer moves the
verdict. And a property-test counterexample found in CI is uploaded as an artifact, the way a
fuzzer crash already was: the seed lives only on the runner, and a re-run draws a new one. -
CI jobs fetch dependencies through the retry action the release workflow has always used. A
flaky crates.io download — thecurl 55class that cargo's ownnet.retrydoes not treat as
spurious — failed a push run and needed a manual re-run, while the workflow that runs a few
times a month was protected and the one that runs on every push was not. -
The fuzz search accepts a budget it can finish.
minuteshad no upper bound while the job is
killed at 90, so a larger value ended the run as a failure rather than as a completed search;
it is now capped at 80. A manual dispatch also no longer cancels a scheduled search in
progress: the corpus survives either way, but the search itself restarted from nothing. -
The line-length rule in
.editorconfigdescribes the repository again. It claimed 100 columns
for every file while 73 lines were longer, including one added in the same cycle: rustfmt
enforces the width for code but leaves comments and string literals alone, and nobody was
checking those. The Rust and workflow lines are wrapped, and a test keeps them that way.
Markdown is exempted with the reason stated — a table row is one line by definition and the
widest here is 252 columns, and the README links by full URL because it is also the crates.io
page — as is the one-linedescriptioninCargo.toml. -
HUNKPICK_DIFF_CASES, the knob for how many generated cases the differential tests run, no
longer accepts what it cannot do. A value that does not parse was replaced by the default in
silence, soHUNKPICK_DIFF_CASES=2OOO(letters for zeros) produced a run indistinguishable
from the soak it was meant to be; it now says so on stderr. A value pastu64::MAX / 40
panicked inside the test's own arithmetic with "attempt to multiply with overflow", a message
about the wrong thing entirely; the knob is bounded at a million, which no run could finish
anyway. -
The one place in the repository that built a command line as a string now quotes what it puts
there. The terminal-hint test runs hunkpick underscript -c, which hands its argument to a
shell, so the build path was parsed as shell text: a space in it — a checkout under one, or a
CARGO_TARGET_DIRpointing at one — split the command (reproduced), and a metacharacter in it
would have been executed during an ordinarycargo test. The path is not attacker-supplied,
so this is hardening rather than a hole. -
"Does this release run publish?" is decided in one place. The expression stood in four
verbatim copies — the publish step, the release notes, the Release itself and the asset upload
job — and had already been rewritten once in all four at the same time, which is the cost of
that arrangement measured. The tag-resolving action answers it as apublishoutput now, and
the unusedrehearsaloutput it advertised is gone: a value computed, documented and read by
nobody sends a reader looking for the place it is used. -
Code that clippy has no rule for.
emit_selectionhad grown to 79 lines and kept "does the
result end on the line the input ended on" in a variable the loop overwrote once per file,
correct only because the last iteration wrote last — acontinueadded later would have left
it describing the wrong file, and nothing downstream checks that. Building one file entry is a
function now, and it returns the answer with the entry it belongs to; a test holds the property
across a multi-file diff.resolve_hunkparsed its index twice and built the string for its
error message on the success path as well.GitCheckErrorandFeedErrorimplement
Error::source, so a caller walking the chain reaches the operating system's own answer. And
theselectorsfuzz target drops its lossy non-Unix fallback: the targets are built for
x86_64-unknown-linux-gnu, and a fallback that changes the bytes fuzzes something other than
what the CLI passes in. -
The banners in
tests/edge_corpus.rsname the test they introduce instead of numbering it. The
numbering broke the first time a test was added without one: from that point "test 14" stood
above the fifteenth test, and the last cycle added both an unnumbered test and a new number in
the same range, so two banners claimed the same position. The numbers are gone, and a test
compares the set of banners with the set of#[test]functions, which a comment asking for the
convention could not. -
The CI tool pins have a way to be noticed.
taiki-e/install-actionis pinned by SHA and
Dependabot bumps it weekly, but the fivetool: <crate>@<version>values it is handed name a
crates.io version inside awith:value, which no ecosystem reads — the action's version says
nothing about the version of what it installs, and the cargo ecosystem is off by design. They
had the same standing as thedtolnay/rust-toolchainSHAs, which RELEASING.md already refreshes
by hand, and now they are refreshed alongside them, with the command that names the stale ones.
cargo-aboutwas one minor behind (0.9.1 against 0.9.2) and is up to date. -
Every artifact upload states how long it is kept. A release archive keeps its seven days — it
is a hand-off between two jobs of one run, andupload-assetsattaches it to the Release
afterwards — while a fuzzer's crashing input and a property-test counterexample now say 90
rather than inheriting it: each is the only copy of an input found by a run nobody was watching. -
A published archive can be traced back to the run that built it. Each one now carries a build
provenance attestation alongside its.sha256, verifiable with
gh attestation verify <archive> --repo VitalyOstanin/hunkpick. The checksum only says the
download arrived intact, and it is served from the same Release page as the archive, so
whoever could replace one could replace the other. Only a publishing run attests: a rehearsal
builds whatever branch the dialog names. -
The
x86_64-apple-darwinarchive is built on an Intel runner (macos-15-intel) instead of
being cross-compiled on Apple Silicon.verify-archive.shruns the packaged binary and checks
the version it reports, and it can only do that for an archive built for the host — so one of
the four published archives had never been executed by anything before it reached users. -
The notices file inside an archive is checked for content, not just for presence.
THIRD-PARTY-NOTICES.mdis generated and gitignored, so a truncated or placeholder copy shows
up nowhere:verify-archive.shnow requires licence sections naming the crates they cover and
the licence texts themselves, andpackage-archive.shrefuses a notices file older than
Cargo.lock, which is the shape a hand-packed archive takes when the file was left over from
an earlier dependency set. It is also generated once for the whole release rather than once
per archive:about.tomlnames every release target, so the file does not depend on the host
that produced it, and one copy is the only way to be sure the four archives agree. -
Trailing lines are placed by their tag even when they arrive out of order.
FileDiff::trailer
pairs each line with the number of hunks before it, and emitting walks the list once, which
needs it ordered; the order was held by adebug_assert!alone. In the release build — the
one anybody runs — an out-of-order entry came out after the wrong hunk at exit 0, a silently
corrupted diff rather than a refusal. Nothing in the crate builds the list that way, but the
field is public.emitnow orders what it is given, stably, and only when the one linear scan
that decides says it has to. -
The same measurement is written the same way in both places that carry it.
src/emit.rsand
the 0.8.0 entry below disagreed on the size of the diff behind the trailer timing — 9 MB
against 7 MB — and a reader cannot tell which run the number came from when it comes in two
sizes; the code comment, written next to the measurement, keeps its figure. The three decimal
commas among them are points, like every other number in the project. -
One statement of why the fuzzing commands are spelled the way they are. The explanation of
the target triple stood inCONTRIBUTING.md, inci.ymland in both fuzz scripts; the
satellites now point at the section that explains all four unobvious parts of the command,
because four copies of a reason drift into four different reasons. -
The shell scripts are checked, not just written. Seven of them carry release-critical logic —
packaging, archive verification, the changelog and tag checks — and nothing looked at them:
CI now runsshellcheck scripts/*.sh(preinstalled on the runner, clean today), and a test
holds them to the four-space indent.editorconfigasks for, which shellcheck has no opinion
about.
Changed
- A UTF-16 stream with no byte-order mark is now named as an encoding problem instead of being
reported as binary input.iconv -t UTF-16LEandUnicodeEncoding($false, $false)write such
a stream, and "binary input: NUL byte found" sent the reader looking for a binary file in the
pipeline rather than at the encoding of their own patch. The diff is still not re-encoded
(ADR 0005) — the message says what to run. - Library API.
split::split_patch_hunkis the entry point for splitting a hunk of a whole
patch: it does whatsplit_file_hunkdoes and additionally keepsPatch::no_trailing_newline
honest, which a function taking oneFileDiffcannot.split_file_hunkno longer panics on an
address the patch does not have — a binary entry, or a hunk index past the end — but returns
the newSplitError::NotATextEntryandSplitError::OutOfBounds. The CLI resolves addresses
before it splits, so this only concerns a direct library caller; adding the two variants is a
breaking change for a caller that matchesSplitErrorexhaustively. - Library API.
cliandgitenvare#[doc(hidden)]: service modules the binary and the
test crates need (neither can see apub(crate)item), not a contract anyone should build on.
Thecargo semver-checksgate added in 0.8.1 counts a new CLI flag as a new public field of an
exhaustively constructible struct, so without this every release that adds a flag would have to
be a minor one on account of a type no library user calls.parser::is_combined_marker, made
public in 0.8.0 for a guard in the binary, is private again; the guard itself moved into the
library asparser::looks_like_a_diff, so the list of lines that open a diff lives next to the
code that reads them. - Library API.
gitenvgainedfeed_and_wait,FeedError,insulate_configand the two
file-name constants it uses. Additions only; nothing existing changed shape. - Library API.
validate::GitCheckError::Spawncarries the working directory alongside the
io::Error(Spawn { source, dir }instead ofSpawn(io::Error)), and the new variant
Failed { code, stderr }reports a git that never reached a verdict. Both are breaking changes
for a caller that constructs or exhaustively matchesGitCheckError. - The MSRV gate now builds on the minimum supported version it names.
rust-toolchain.toml
pins the repository tostable, and a toolchain file wins over an installed default, so both
the CI job and the release job thatpublishwaits on compiled on stable and reported success
without ever seeing 1.85. Both now setRUSTUP_TOOLCHAINfirst, as the fuzzing setup already
did, and runcargo check --locked --all-targetsso the dev-dependencies are compiled too —
proptestis pinned to~1.11for an MSRV reason thatcargo buildnever reached. git apply --check, run for--verify-result-diff-git, now runs in the C locale. Its stderr
is shown next to hunkpick's own ASCII-English text, so an inherited locale used to put another
language — or U+FFFD, where the bytes were not UTF-8 — in front of the user.- The fuzzing dictionary and the committed seed corpus now carry bytes above ASCII: a path in
git's quoted form with octal escapes, content that is not valid UTF-8, and the byte-order
marks the input guard names. The header defect above was reachable only through such bytes and
had survived fuzzing.