Skip to content

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 17 Aug 20:55
· 47 commits to master since this release

Fixed

  • The overlap check compared raw header numbers, ignoring git's convention that a side with
    no lines reports the line before its empty range. A hunk ending in a pure deletion —
    a file whose tail is removed — therefore looked like an overlap, and select '*' refused
    a diff git itself writes: exit code 70 on roughly one in ten ordinary diffs (measured over
    200 generated cases). Present since 0.7.0, when the new-side check was introduced.
  • A full binary patch (git diff --binary) lost its structure: the payload lines
    (literal <n> and the base85 body) were filed as leading headers and emitted above the
    GIT binary patch marker, so git apply rejected the result as garbage while hunkpick
    exited 0. This is the form needed to stage a binary change, and the binary marker of a
    CRLF diff went unrecognised for the same reason.
  • A sub-hunk with no old-side lines (an appended block) carried a header one line past
    where git puts it (@@ -4,0 +5 @@ instead of @@ -3,0 +4 @@), and the new-side anchor
    inherited the shift.
  • An @L slice of a whole-file deletion produced a self-contradictory patch: the header
    declares the file removed while the body keeps the unselected lines as context, which git
    rejects (deleted file f still has contents). Such a selection is now a usage error.
  • A combined diff (diff --cc, @@@ headers — what git writes for a merge) was read as a
    two-sided one: the hunk body was truncated at its first line and a --- removed in both
    line invented a file entry. It is now rejected as unsupported (exit 2) instead of losing
    data silently. See docs/ADR/0012-two-sided-diffs-only.md.
  • The listing gave a CRLF diff's @@ header a separating space and a raw CR — inside a JSON
    string field — where emit already knew the CR is the line ending, not section text.
  • A context line for an empty source line is a lone space, and transports that strip
    trailing whitespace deliver it as a zero-length line. Parsing treated that line as the
    end of the hunk, dropped the rest of the body into the file's headers and emitted a
    diff git apply rejects as garbage — with exit code 0. Such a line is now read as
    context and emitted with its marker restored.
  • Lines that follow a hunk body — a blank separator between hunks, the -- signature
    git format-patch appends, a stray Binary files ... marker — were emitted with the
    leading headers, which moved them above the first @@. They now keep their position,
    and the binary marker is no longer dropped silently.
  • split replaces one hunk with several but left those trailing lines at their old
    positions, so a signature recorded after the last hunk was emitted between the pieces.
    git apply rejected the result (patch fragment without header) while hunkpick exited 0.
    Each line now follows the hunk it followed before the split.
  • Emitting those lines rescanned the whole list for every hunk, which is quadratic in their
    number: a 7 MB diff carrying a separator after each of its 128 000 hunks took 15 s to
    re-emit, against 0,2 s to list. One pass now walks the list alongside the hunks (0,18 s on
    the same input).
  • A binary file, a pure rename and a mode-only change have no ---/+++ lines, so they
    had no path and could not be addressed in a multi-file diff. Their paths are now read
    from the diff --git line, and * takes a hunkless entry whole.
  • Paths quoted and C-escaped by git ("a/\303\251.txt", the core.quotePath default for
    non-ASCII names) are decoded, so a selector spelled with the real file name matches.
    The emitted diff keeps the original bytes.
  • A CRLF diff left the CR in the file path (breaking path: selectors) and added a stray
    space to the hunk header, so the round-trip was not byte-identical.
  • A deleted file was listed as /dev/null; it is now shown under its old name.
  • Line numbers near u32::MAX from the input header overflowed while checking hunk
    overlap: a debug build panicked with exit 101, a release build wrapped and decided the
    check on a meaningless value. The bounds are computed in u64, and sub-hunk starts
    saturate rather than wrap.
  • A defect of the input diff (a header that disagrees with its body, e.g. a truncated
    diff) was reported as a verification failure of hunkpick's own result: exit code 70 and
    a Debug dump of internal fields. It is now a usage error (exit 2) in prose, with the
    sub-hunk numbered from one as list numbers it.
  • A reader that closes the pipe first (hunkpick list | head) ended the run with exit 74
    and a Broken pipe diagnostic; it is now a normal end of work (exit 0). Both this and the
    exit-code change above are recorded in docs/ADR/0013-exit-code-contract.md.
  • In the path:set selector form a broken set was re-read together with the path, so
    f:2-1 was reported as not a number: f:2 instead of reversed range.
  • split now recomputes new-side anchors like select does, so both commands treat a
    diff carved out of a larger one the same way.
  • In a plain (non-git) diff a header-only entry absorbed the next file's marker lines.
  • The human listing escapes text a terminal would act on (escape sequences, control
    bytes, bidirectional overrides) instead of passing it through.
  • A file whose name is not valid UTF-8 (legal on Unix) could not be addressed: the
    argument was refused before hunkpick saw it. Selector paths are now taken as raw bytes,
    so such a file is reachable by name; only the set after the : must be ASCII.
  • A selection that did not include the input's last line inherited its "no final newline"
    flag, so the result ended mid-line and git apply called it a corrupt patch. The flag now
    travels only when the result does end on that line.
  • A hunk header carrying a token hunkpick cannot represent — @@ -1,3,9 +1,3 @@,
    @@ -1,3 +1,3 junk @@ — was parsed as if the extra part were absent and emitted without it,
    at exit 0. Both are now parse errors (exit 2).
  • A diff saved in UTF-16 (what git diff > patch.diff writes in Windows PowerShell 5.1) was
    reported as "binary input: NUL byte found", which points at the wrong thing. A UTF-16/UTF-32
    byte-order mark is now named, with the re-encoding command to fix it.
  • A git that could not be started for --verify-result-diff-git was reported as a failed
    verification of the result diff (exit 70). The check never ran, so it is an environment
    failure: exit 74.
  • Output written after the last newline could be lost silently: stdout is line-buffered and the
    runtime's implicit flush at exit discards its error. It is flushed explicitly, and a failure
    is exit 74.
  • list --json did not end its output with a newline.
  • A combined diff whose entries carry no ---/+++ pair (a file resolved the same way in both
    parents) was reported as "no diff markers found" rather than as the combined diff it is.

Changed

  • Auto-splitting a hunk is linear in the number of change runs; it re-counted the whole
    prefix per sub-hunk before. On a 1.9 MB one-hunk diff with 64 000 runs list went from
    3.9 s to 0.06 s.
  • split no longer clones the whole parsed diff to rewrite one hunk.
  • The git apply --check child process no longer inherits GIT_DIR, GIT_WORK_TREE and
    related variables, so -C DIR alone selects the repository.
  • --color is described in list --help; the exit-code table in the README distinguishes
    SIGINT (130) from SIGTERM (143) and documents the closed-pipe case.
  • Release archives carry THIRD-PARTY-NOTICES.md with the license texts of the crates
    linked into the binary, and the release pipeline builds and verifies every archive
    before the irreversible cargo publish rather than after it. The release procedure is
    written down in RELEASING.md.
  • README states the measured peak memory (6x–19x the input, depending on average line
    length) instead of "a few hundred MiB", so the input limit is not read as a RAM ceiling.
  • The crate is built on the Rust 2024 edition, formatted with the matching style edition.
    The minimum supported Rust version is unchanged (1.85, the release that stabilised the
    edition), so nothing is required of consumers; the dependency resolver now honours that
    minimum when picking versions. See docs/ADR/0011-rust-2024-edition.md.
  • path: selectors resolve through an index built once per invocation instead of scanning the
    file list per selector: 16 000 selectors over 16 000 files went from 1.5 s to 0.04 s.
  • --verify-result-diff-git documents what it actually checks — the working tree, which in the
    usual staging pipeline already holds the edits, so the flag reports a correct result as not
    applying. Same for the JSON listing, whose text fields are the diff's own content: not
    display-sanitised the way the human listing is, and lossy for non-UTF-8 bytes.
  • The selector index limit (2^20 per selector) is named in the error message and in the README.
  • The release pipeline checks the tag against the manifest, both lockfiles and the CHANGELOG in
    a job of its own, before anything is built, and a release can be rehearsed by running the
    workflow with no tag named, which reads the version from Cargo.toml and publishes nothing.
  • Behavioural decisions of this release are recorded as ADRs: docs/ADR/0012-two-sided-diffs-only.md
    and docs/ADR/0013-exit-code-contract.md.

Added

  • Generated tests, because the defects above were found by generating inputs rather than by
    reading code: tests/differential.rs compares hunkpick with real git over generated diffs
    (a selection applies, staging one sub-hunk at a time converges on the target, the output is
    valid input for the next invocation), tests/property.rs uses proptest for the forms git
    will not produce on demand (CRLF, a missing final newline, a mail preamble), and fuzz/
    holds libFuzzer targets for parsing, the parse . emit fixed point and selector handling,
    with committed seeds (fuzz/seeds/) and a token dictionary. CI builds every fuzz target on
    each push; a scheduled workflow searches twice a week and keeps its corpus across runs.

Changed (library API)

  • Selector::File.path is Option<Vec<u8>> instead of Option<String>, and
    select::parse_selectors accepts anything convertible to OsStr (a &[String] still
    works). This is what lets a selector name a file whose path is not valid UTF-8.
    select::resolve_file takes Option<&[u8]> accordingly.
  • select::build_view returns Vec<Vec<Hunk>>: the position in the result is the file
    index, so the redundant index in each tuple is gone.
  • The crate denies undocumented public items (#![warn(missing_docs)]); every exported
    item now carries rustdoc.
  • Line::no_newline is Option<Vec<u8>> (the marker as it arrived) rather than a flag, so a
    CRLF diff keeps the marker's line ending.
  • Patch gained preamble (the lines before the first file entry — the mail head of a
    format-patch output, which used to be dropped while its footer was kept) and
    no_trailing_newline (an input that ended without one now leaves without one). Together
    with the two above, emit now round-trips its input byte for byte, not just a
    git-canonical diff.
  • ParseError gained a Combined variant for merge diffs.