Releases: shhac/git-hunk
Release list
v0.20.0
Added
git hunk stash popmerges a stash back into files that have other unstaged changes, whichgit stash poprefuses ("Your local changes to the following files would be overwritten by merge"). That refusal met every stash of some of a file's hunks and not others, and every file edited again after stashing. The entry's worktree changes are merged file by file, the index is left as it is, and a conflict is left as git leaves one: markers labelledUpdated upstreamandStashed changes, unmerged index entries,CONFLICT (content): Merge conflict in <path>andThe stash entry is kept in case you need it again., exit 1. Where git's pop succeeds it still does the work, so the result is git's own. An untracked file in the way now refuses the pop before anything is restored, where git's pop merged the tracked changes first; and a conflict in git's own pop is now reported, where only the exit status said so before.
Fixed
-
An untracked symlink's diff, which git-hunk writes itself, named the path bare where git would C-quote it (a TAB, a quote, a backslash or a control character, and under
core.quotePatha non-ASCII byte), and left off the TAB git ends a name containing a space with. A symlink with a TAB in its name could not be added (bad git-diff - inconsistent new filename), anddiffshowed a header git never writes. The header is now git's own, byte for byte. -
Adding a file's deletion together with the untracked file it was moved to stages a rename, but
addreported the deletion as→ ?, a hashlist --stagednever shows. Both are now reported on one line under the rename's hash:staged 7c745cd aa914db → b34f350 new-name.txt. -
A git failure while
stashwas writing the stash tree or the untracked-files commit, or whilerestore --3waywas recording a conflict, exited on the spot and left agit-hunk-*temp index behind in the temp directory. Those failures now unwind, so the temp index is removed; the message and exit status are unchanged. -
On an unborn branch (no commits yet),
commitfailed with git'sfatal: Not a valid object name HEAD, andstashwith a rawgit rev-parsefailure.commitnow makes the first commit, asgit commitdoes;commit --amendsayserror: you have nothing to amend, andstashsayserror: you do not have the initial commit yet, in git's words, changing nothing.list --staged,count --stagedandresetalready worked there, against the empty tree, asgit diff --cachedandgit resetdo. -
A typechange between a binary and a symlink (a binary replaced by a symlink, or the reverse) could not be added, reset, committed, stashed or restored:
error: <path>: already exists in index(orin working directory). The binary half is staged, unstaged or restored by path, which takes the whole path, and the symlink half was applied as a patch beside it, before the binary half had made room or after it was already done. When both halves are selected, the symlink half now goes with the binary half. -
stashof a hunk in an intent-to-add entry (git add -N), or in a rename whose new side is one, took the file out of the worktree and left the entry naming it, so it showed as deleted.stashnow refuses while the index holds any intent-to-add entry, asgit stashdoes, and nothing changes:error: cannot stash while '<path>' is intent-to-add, exit 1. That includes stashing other files: a stash made beside such an entry cannot be popped while the entry stands. -
A line spec on a new or deleted file failed in most directions. The patch header was fixed when the diff was read, so the filtered patch still claimed to create or delete the whole file while its body kept the deselected lines, and git refused it:
reset <sha>:2on a staged new file,restore --force <sha>:2on an untracked file, andadd/commitof part of a deletion all failed ("new file depends on old contents", "patch did not apply cleanly"). Headers are now rendered from what the line spec leaves: the deselected lines stay where they were, so a partial undo of a new file or a partial deletion is an edit to a file that still exists. Theindexline is kept, so--3wayworks on a partial selection too. -
A line spec on an empty file (
add <empty>:1) silently applied the whole file. It is now rejected, as are line specs on a symlink (its one line is its whole target) and on either half of a typechange:error: line selection not supported for empty file|symlink|typechange '<path>', matching the existing message for binary files. -
Every change to a binary path had the same hash (
bin.dathashed alike as a worktree edit and as a new file under--ref), so a hash listed before the file changed again still matched it andcheckcould not notice. A binary hash now includes the blob ids from itsindexline. Binary hashes change with this release, and differ between SHA-1 and SHA-256 repositories. -
An empty new or deleted file's patch carried
--- /dev/null/+++ b/<path>lines git never writes for one, with the path unquoted even where git would quote it. The header is now git's own:diffshows it that way, andgit applytakes it as is. -
An untracked binary file's hash depended on
core.abbrev: untracked files were diffed without--full-index, so the blob ids its hash is taken over came abbreviated. They are now full, as for tracked files, and an untracked file's patch (a symlink's too) carries full ids in itsindexline. -
A file named like a revision broke the commands that name one: with a file called
main,list --staged --ref mainfailed with git's "ambiguous argument 'main': both revision and filename", and a file calledHEADmade everystashfail the same way. Revisions now always end with--, so git never has to guess. -
The result hash
addandresetprint for a rename was not onelistwould show: adding a rename reported it as a new file under a hash thatlist --stagednever lists, and resetting one printed→ ?, as did resetting a staged new file. The result is now read back from a diff that covers both paths of a rename and, forreset, the untracked file it leaves: adding a rename reports the rename'slist --stagedhash, and resetting one reports the new path, now untracked, and the old path's deletion (unstaged b34f350 → 7c745cd,aa914db new-name.txt). -
A
stashentry recorded the stashed hunks on top of HEAD as both its tree and its index commit, so any staged change madegit stash pop --indexfail ("Index was not unstashed") and left the staged file unstaged; with nothing staged,pop --indexstaged the stashed hunks. An entry now has the same shape asgit stash push --keep-index -- <paths>would give it: its index commit is the index as it stands and its tree is that index plus the stashed hunks. Every git stash command treats it as it would the native entry:pop --indexandapply --indexrestore the stashed hunks unstaged and keep the index, andgit stash showlists staged changes as well as stashed ones, as it does for a native keep-index entry. A stash while the index has unmerged paths is refused (error: cannot stash while the index has unmerged paths), asgit stashrefuses it, rather than recording an index commit that was not the index. -
--ref nopefailed with git's complaint about the empty tree it had been expanded against. Every revision--refnames, including each side of a range, is now checked before anything runs:error: bad revision 'nope'. -
A path containing a space kept the TAB git ends such a name with on its
---/+++lines, solist --file "a b.txt"found nothing,list --porcelainprinted an extra empty column,add/resetprinted→ ?, and a rename to or from such a path reported a result hashlistnever shows. The TAB is now dropped as git defines it: only after a name containing a space. Hashes of hunks in paths with spaces change with this release. -
Under
diff.renames=copies, a copied file'scopy from/copy tolines were dropped, so git applied the section as a rename:resetof the copy's hunk took the source out of the index along with its own staged change, andadd --refof a copy commit staged a rename. Copies now stay copies. Taking a copy's hunk back out (reset,restore,stash) edits the copy alone, leaving it a copy of its source, sincegit apply --reversewould otherwise write the copy back over the source. A copy with no content change is named by-vlike a rename with none. -
An untracked file whose name git C-quotes (
ünï.txt, or one with a quote or a tab in it) was invisible:listdid not show it, so it could not be added, and resetting a staged new file with such a name printed→ ?. Untracked files are now listed NUL-separated, as git names them. The listing is also scoped to the paths in question, soadd/resetno longer list every untracked file in the repository twice to report their results. -
apply.whitespace=errormadeadd,stash,commitand every other command that applies a patch reject a hunk with trailing whitespace, andapply.whitespace=fixwould have stripped it on the way. The content is already in the worktree or history; git-hunk now moves it as it is, asgit adddoes. -
A
stashwhose stashed changes could not be taken back out of the worktree printed a warning and exited 0, with the changes both stored and still in the worktree. It is now an error, exit 1:error: cannot remove the stashed changes from the worktree, with hints that the changes are saved instash@{0}and still in the worktree, and thatgit stash dropkeeps working on them. A tracked binary that could not be checked out, or an untracked file that could not be deleted, counts the same. -
restore --3wayhandedgit applya--3waythat implies--index. Without--refit always failed ("does not match index", since the hunk is exactly where worktree and index differ), and a partial restore of a deleted file wrote...
v0.19.0
Added
- Git 3.0 readiness: git-hunk works in SHA-256 and reftable repositories, the defaults Git 3.0 gives every new repository, as well as in SHA-1 ones, with nothing to configure. Wherever it needs an object ID it asks git, which answers in the repository's own format. The integration suite gains a
git3-defaultshostile profile (SHA-256, reftable,main,safe.bareRepository=explicit) that CI runs on every push; it refuses a git too old to honour those settings rather than passing vacuously.
Fixed
--ref <initial-commit>failed in a SHA-256 repository with "unknown revision". It diffed against the SHA-1 empty tree, which a SHA-256 repository does not have.- An untracked symlink's diff named its blob with a SHA-1 hash even in a SHA-256 repository.
- An uppercase hunk-hash prefix passed validation and then matched nothing ("no hunk matching"). It is now rejected by name ("hunk hashes are lowercase hex"), in line with Git 3.0's lowercase-only object IDs.
commit --amenddropped the original commit's changes. The temp index was seeded fromHEAD~1while the hunks are relative toHEAD, so the amended commit held only the new hunk and the original change reappeared as staged. On a root commit--amendfailed outright. Both now build onHEAD, asgit commit --amenddoes.commit --dry-runchecked against the real index while the commit builds onHEAD, so a staged edit next to the target hunk made the preview pass and the commit fail.- A file added by a pre-commit hook with a non-ASCII name was left behind as a staged deletion, which the next plain
git commitwould record. Path listings are now NUL-separated, and every file of a multi-file commit counts as a target (previously only the first file of each patch did). - Binary renames:
a.bin → b.binwas listed and reset under the old path, leaving the rename staged, and a rename between names of different lengths was invisible tolist,countand-v. Renames now take their path fromrename to. A C-quoted path ending in a backslash was dropped by the same parser. add/resetwith a--filethat matched no hunk exited 0 having done nothing. They now report "no hunks matching file" and exit 1, like the other commands.add,resetandrestore --dry-runrejected every file↔symlink typechange ("already exists" / "wrong type") that the real operation applies cleanly. The dry run now checks the delete and create halves as one sequence.reset --ref <X>could not succeed: git receiveddiff --cached X^..Xand printed its usage text. It now takes hunks from the same diff aslist --ref Xand removes them from the index, the mirror ofadd --ref X.
Changed
- Internal restructure with no change in behaviour, checked against the previous binary across a few hundred scenarios:
- The per-command option structs share one
Commonstruct, replacing a field-copy layer driven by reflection. Whether a command accepts--3waynow comes from the command spec. - Subcommands dispatch through one typed path.
- Commands load and select hunks through one shared path.
- Patches are built in the order they apply.
- One numbered iterator walks every hunk body, so
diff -nnumbering and line-spec selection cannot drift apart. diff.zigparses one file section at a time, shared with the skipped-path report.- Colour, hash and summary rendering live in
format.zig. - Stash machinery moves into
stash.zig, and newhead_match.zigandcheck.zighold the index→HEAD matcher andcheck's resolver.
- The per-command option structs share one
- Test suites use the harness
first_shahelper throughout, and the symlink and typechange cases move totests/test_symlink.sh.
v0.18.1
Fixed
git hunk listnow names an unknown flag (error: unknown flag '--badflag') instead of printing the whole usage banner.parseListArgsreturned a bare error where every other parser routes through the sharedunknownFlaghelper, solistwas the only command that never told you which flag was wrong.zig build testran 175 of 391 unit tests. A module's tests are discovered only ifmain.zig's comptime block imports it, andargs.zig(175 tests, the whole CLI argument parser) andcommands.zig(41) were absent — so their tests were skipped silently rather than reported. Two had rotted undetected in the meantime: one referenced a struct field that had been renamed and no longer compiled, and one freed string literals and aborted. Both fixed; all 391 now run.
Changed
commands.zigis split from 2385 lines into three files. The result-group machinery — mapping applied hunks onto their post-apply result hashes, which is pure set arithmetic over two hunk snapshots — moves toresult_groups.zig, and the temp-index commit transaction moves tocommit.zig, each taking its own tests.cmdCommitstays alongside the other subcommand entry points so all nine still dispatch through one surface. No behaviour or interface change.- Test suites share
first_sha,bytes_of,blob_bytesandwant_bytesfromharness.shrather than redefining them per file.
v0.18.0
Fixed
- A configured external diff driver no longer makes a dirty tree look clean. With
diff.externalset, orGIT_EXTERNAL_DIFFexported, git hands the diff to that program and writes nothing to stdout while exiting 0 — solistshowed no hunks,countreturned 0, andcheckpassed vacuously. Not a crash and not a parse error: a wrong answer shaped exactly like a clean tree, which an agent has no way to distinguish. Everygit diffcall site now passes--no-ext-diff(which defeats both the config key and the environment variable), including the--no-indexpath used for untracked files. - A
diff.<driver>.textconvfilter no longer leaks converted text into the hunk listing. Hunks on such a path showed the driver's rendering of the blob rather than its real content, and the patch rebuilt from that rendering was rejected bygit apply("patch does not apply") —add,reset,restoreandcommitall failed on those paths. Diff calls now pass--no-textconv, so textconv paths behave like any other. - Redirecting stdout to a regular file no longer overwrites what preceded it. The stdout writer was positional and started at offset 0, ignoring the offset the shell had already put on the descriptor, so
git hunk list >> logtruncated the log and{ echo header; git hunk list; } > outoverwrote the header. Output is now streamed.
Added
list --verboseandcount --verbosenow name changed paths that produced no hunk, with the reason and thegit add <path>that stages them. A submodule pointer bump, a mode change and a rename with no content change are all deliberate skips — they have no line-level content for a hash to address — but the skip was silent, so a tree whose only change was one of them read here as having nothing to stage. A mode change is reported even when the same file also has content hunks, since the mode is unstageable either way. Derived from the diff text the parser consumed rather than a parallel copy of the skip rules, so a future skip cannot go unreported.
Changed
git hunk commitbuilds its temporary index underTMPDIRwhen set, rather than always under/tmp— the same rule the shell andmktempfollow, so a caller that has isolated its temp directory keeps that isolation.- The man page now carries the version in its
.THline, soman git-hunknames the release it documents and a packaging step that shipped a stale copy leaves a trace.zig build docsmust therefore be re-run on a version bump; CI's existing drift check enforces it. - Test coverage for classes that were previously unpinned: the
\ No newline at end of filemarker end to end (all four positions across add, reset, restore, commit, stash, and line-spec selection, asserting file and blob bytes); non-UTF-8 file content as distinct from filenames; line-ending normalisation undercore.autocrlftrue/input,core.eolwithtext=auto, and none — each pinned against git's own answer (git add,git checkout --) rather than against the pre-edit bytes; submodule and mode-change skips; an inheritedGIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE(which hooks, rebases and CI all set); and stdout redirected to a regular file. - Releases are now packaged by
tools/package-release.sh, which CI runs too, and the resulting tarball is read back bytests/check-release-artifact.sh— the binary, the man page and all four completion files must be present, the binary must run, and the shipped man page and completions must cover every command the shipped binary lists. Previously nothing verified a release's contents, so a workflow edit could drop the man page or a completion file silently. - The whole integration suite can be re-run under hostile git configuration:
tests/run-hostile.shapplies a profile throughGIT_CONFIG_GLOBAL(so it reaches every git invocation in every test, including the ones git-hunk spawns) and requires the same assertion count as a default-config run — a profile that silently emptied every diff would otherwise pass by asserting nothing. Eight profiles cover external diff drivers, textconv, path prefixes anddiff.relative,color.uiandcolor.diffseparately, diff algorithm and blank-line suppression,core.quotePath, and pagers with an order file. CI runs one job per profile, with the matrix generated from the profile list so adding a profile adds a job. Nothing previously set a hostile config anywhere, so every flag the tool passes to defend itself was unverified. - The test harness no longer sets
pipefail. The suite is built onecho "$OUT" | grep -q X, wheregrep -qexits on its first match andechothen dies of SIGPIPE with status 141; underpipefailthat 141 became the pipeline's status, so a passing assertion failed at random. Reproduced 200/200 with a payload larger than the pipe buffer, and observed on CI as onegrepin a pair failing while its neighbour passed. The status these pipelines want is the consumer's, which is what they return withoutpipefail. - The test harness now scrubs an inherited git environment and observes repos through
git --no-ext-diff --no-textconv --no-colorwith colour keys forced off. Without the first, a caller'sGIT_DIRredirected the whole suite at another repository; without the second, a test asserting that something is present fails under a hostile diff driver while its negation passes vacuously.
v0.17.0
Fixed
- Line specs now work on
resetandrestoreat any context width. Both commands reverse-apply their patch, but the filtered patch was built for forward apply only: deselected+lines were dropped instead of kept as context, so the patch's new side no longer matched the index/worktree and git rejected it with "patch does not apply".git hunk reset <sha>:2andgit hunk restore <sha>:2failed on any hunk containing more than one change. Only--unified 0worked, because zero context puts every change in its own hunk and leaves nothing deselected to mishandle. Deselected lines are now kept on whichever side the apply direction matches against, mirrored per direction. git hunk commit --dry-runno longer requires-m. The message check ran ahead of the dry-run branch, so the behaviour documented in--help("required unless--dry-run") — and already allowed by the argument parser — was unreachable.
Added
git hunk add --dry-runandgit hunk reset --dry-runreport what would be staged/unstaged and exit, touching neither the index nor the worktree. Partial staging is the hardest operation to eyeball after the fact, and the previous workaround —diff <sha>:<lines>— was a different command with different output, so it was easy to preview one spec and then stage another. Output followsrestore --dry-run(would stage <sha>[:lines] <file>,would-stagein porcelain) and reports the input hunks rather than post-apply result hashes, which cannot exist without applying.--files-from <path>reads file paths from a file, one per line, with-for stdin. Accepted anywhere--fileis, and merges with it. NUL-separated input is detected automatically (NUL cannot occur in a path), sogit ls-files -z | git hunk add --files-from -is safe for paths containing newlines without a second flag.git hunk diff -n/--numbernumbers a hunk's body lines without requiring a line spec, so the numbers a:linesspec expects can be read off the screen instead of counted by hand. The numbered gutter already existed but was reachable only by passing a spec — i.e. only once you knew the answer.-nand a spec share one renderer, so their numbering cannot drift; with both, selected lines keep their>markers. Human output only, a deliberate no-op under--porcelain; plaindiffoutput is unchanged byte-for-byte.- Regression tests for line specs at default context on
reset(test 237) andrestore(tests 525–527, covering insertions, deletions, and a mixed add+delete hunk), pluscommit --dry-runwithout-m(tests 1020–1021). The previously existing line-spec tests for these commands all passed--unified 0, which is why the bug survived; the new tests deliberately do not. diff -ntests (924–929), including one that stages a spec read straight off the gutter to pin the numbering to whataddactually selects.
Changed
- A hash that
--fileexcluded now says so instead of reporting as missing:no hunk matching 'da35d28' in the --file selection (it is in 'internal/cli/root.go'), plus a hint to run the file and hash selections as separate commands.--filescopes which hunks a hash may match (this is also how an ambiguous prefix is disambiguated), so passing hashes alongside it reads as a union but behaves as a filter — and the old message was indistinguishable from a stale hash, sending people to look for the wrong problem. A genuinely unknown hash keeps the original wording. - Docs:
--fileis described as scoping rather than addition on every command that takes hashes, with the two-command idiom for "whole files plus specific hunks" written out; and the staging workflow now warns that a build which skips test files (go build, and equivalents) cannot verify a commit produced by splitting an implementation from its tests. - Test harness: completion display capture ends on the explicit dumpbuf signal rather than a fixed quiet period, fixing an intermittent
test_completionsfailure (~1 run in 10 under load, measured 5/5 → 0/6 across the change).run-all.shand one test no longer use fixed/tmppaths that collide between concurrent runs.
v0.16.0
Changed
git hunk commitnow builds the commit in a throwaway temp index (GIT_INDEX_FILE) instead of backing up, rewriting, and restoring the real index. Your staged work is never touched: a crash at any point mid-commit (verified with kill -9 fault injection) leaves the index byte-identical with no recovery needed — at worst a stray temp file in/tmp. Hooks still fire normally and see exactly the content being committed. Stale.git/index.hunk-backupfiles from commits interrupted under older versions are still restored automatically.- Internal structure pass over the commit/git-subprocess layer: one error-returning capture core replaces six hand-rolled helper bodies, the parallel
*WithEnvhelper family is gone, temp-index plumbing moved to the git layer with self-contained ownership, and the hook-path selection logic is a pure, unit-tested function.
Fixed
- Pre-commit hooks that
git addfiles (lint fixers etc.) no longer leave a phantom staged deletion plus untracked twin after the commit — hook-created paths are synced into the real index, while user-staged work (including staged deletions) is provably untouched. - A binary-add failure mid-commit no longer strands transaction state; it aborts cleanly with user state untouched.
- Paths containing spaces are parsed correctly from patch headers; previously the hook-path cleanup could mistake a committed spaced path for hook-created, and resync diagnostics printed a truncated name.
Added
- 16 commit edge-case integration tests: overlapping staged/unstaged regions in every flavor,
--3wayconflict escalation, git fault injection at each transaction step (via a PATH shim), kill -9 crash recovery, index-modifying hooks, binary-commit transactional invariants, and spaced-path handling.
v0.15.3
Fixed
- zsh hash completion listings no longer columnize side by side in wide terminals — candidates always display one per row (
compadd -l), keeping thelist --onelinetable readable.
v0.15.2
Changed
- zsh and fish hash completion displays now mirror
git hunk list --onelineexactly — aligned file column,empty/N-Mline ranges, truncated summaries, list order (by file/line) instead of hash order. The display is sourced from the binary's own--oneline --no-coloroutput, so futurelistimprovements flow into completions automatically.
Added
- zsh completion listings colour the hunk hash yellow, matching
list --oneline(andgit log --oneline). Shipped as a soft default: ahunk-hasheslist-colorszstyle applied only when the user has no matching style of their own, and skipped whenNO_COLORis set. Menu selection keeps zsh's standardma=highlight, user-tunable as usual.
v0.15.1
Fixed
- zsh completion via git's own
git-completion.zshwrapper (Homebrew git, Apple Command Line Tools) now completes non-empty words. Previously any typed prefix —git hunk add 9<TAB>,git hunk list --sta<TAB>, even a unique hash prefix — completed nothing, because the wrapper's dispatch context breaks the compsys engines behind_describeand_argumentsoption matching. The_git_hunkbridge is now a self-contained wrapper-native completer built on rawcompadd/_wanted/_files, and hash candidates gain oneline-style descriptions (9ab03ef foo 0 0 new file). zsh's builtin_gitdispatch and directgit-hunkcompletion are unchanged.
Added
- Interactive completion regression tests (
tests/test_completions.sh): drives real TAB completion in a pty for both the direct andgit hunkwrapper dispatch paths, with hash-prefix overlap guaranteed by construction. Skips gracefully when zsh/zpty or a git-completion.zsh wrapper is unavailable.
v0.15.0
Added
- Shell completions for bash, zsh, and fish (
completions/), covering bothgit-hunkandgit hunkinvocation forms, with live hunk-hash completion fromlist --porcelain --oneline(staged hashes forreset),--refcompletion from git refs, and--filepath completion. Release tarballs now ship them and the Homebrew formula installs them. zig build fuzz— a brute-force mutating fuzzer for the diff parser (src/fuzz_driver.zig), plus a corpus-seeded fuzz test insrc/diff.zigthat replays deterministic regression inputs underzig build test. 8 million mutated inputs survived clean at time of writing. (Coverage-guidedzig build test --fuzzis blocked on a Zig 0.16.0 stdlib bug.)zig build docs— CLI documentation facts (commands, flags, arguments, examples) are now single-sourced insrc/spec.zig.--helpand top-level usage render from it at comptime; the man page's COMMANDS/GLOBAL OPTIONS sections are generated from it; and the skill reference plus all three shell completions are coverage-checked against it.zig build docs -- --checkruns in CI as a drift gate.
Changed
--helpflag display normalized to short-form-first (-h, --help,-u, --include-untracked); example columns aligned consistently across commands.countnow documents its accepted no-op flags (--porcelain,--no-color,-v); top-level help now documents-V, --version.- The version string is single-sourced from
build.zig.zon(build.zigimports it; release builds still override via-Dversionfrom the tag).
Fixed
- Help and man page no longer claim
--ref"combines with--staged" onadd,reset, andrestore— those commands reject--staged. - Skill reference (
commands.md): added previously missing--refand--3wayflag documentation across seven commands; corrected the stale claim that--filepaths must match diff output exactly (they resolve relative to the current directory since 0.10.2).