hc -- Hunk Commits Skill
Hunk-based atomic commits for AI agents. One JSON plan, N commits. You assign hunks, hc handles all git mechanics (staging, committing, and -- for existing commits -- conflict-free history splitting). Works from any subdirectory of the repo; paths are always repo-root-relative.
Activation
This skill activates when:
- A unit of work (one change + its passing test) is completed -- commit BEFORE starting the next unit, not at the end of the whole task
- The agent needs to create atomic commits from uncommitted changes
- The user asks for hunk-level commit granularity
- The agent has written multiple logical changes (e.g., several tests, feature + test, refactor + fix)
- The user asks to split, break up, or re-granularize EXISTING commits (use
hc log+hc rewrite)
Workflow
# Step 1: See what changed. ONE call gives you everything:
# indices, headers, enclosing function (section), and the changed lines (content).
# Do NOT run 'git diff' separately -- hc diff --json already includes hunk content.
hc diff --json
# Step 2 (RECOMMENDED): start from the draft plan -- never from a blank page.
hc plan > draft.json
hc plan emits the finest mechanical split: one commit per file, split further by enclosing section when a file's hunks span several. Test files (by filename/directory convention) are labeled TODO test (...) -- those entries follow the test rules below: one commit per NEW test, never merged. Every message is a TODO (...) placeholder and hc run refuses TODO messages, so each entry must be reviewed. Take the draft even when hc diff --json already gave you the whole picture: what it buys is not insight but enumeration -- every file and every hunk index is already assigned, which is the part hand-written plans get wrong once a diff passes a handful of files. You are editing a draft, not accepting a proposal. Your review job, per entry:
- Write a real commit message. While writing it, sanity-check the entry is one idea.
- MERGE entries that belong together -- mechanical sweeps, inseparable changes, one idea that happens to span sections. Merging is a conscious act; splitting is the default you inherit. Never merge
TODO testentries for separate new tests. - Drop untracked entries you don't want committed.
hc run draft.json
Hand-written plans (heredoc) remain fine for small diffs -- but read each hunk's section/content before bundling, and heed the review granularity warning hc run emits when one commit bundles hunks from multiple sections of a file:
hc run - <<'PLAN'
{"commits":[{"message":"feat(auth): add login","files":[{"path":"auth.go","hunks":[0,1]}]}]}
PLAN
hc run is atomic at the plan level: the whole plan is validated (including a simulated apply of every commit) before the first real commit is created. A validation failure means nothing changed -- fix the plan and retry. --dry-run exists but is rarely needed; hc run performs the same validation anyway. If a run does stop part-way (exit 3 -- a lock, a hook, external interference), do NOT rewrite the plan: hc run --continue finishes it. See Error Recovery.
Reading the diff
Each hunk in hc diff --json carries what you need to classify it -- never guess from headers alone:
content-- the changed lines,+/-prefixed. Diffs use-U0, so this is exactly the change, no context lines. Very large hunks (>100 lines) arrive capped to a head/tail sample withcontent_truncated: trueandcontent_omitted_lines-- the counts and fingerprint still identify the hunk; you almost never need the elided middle.section-- which section this hunk touches. Git labels a hunk with the declaration BEFORE its first changed line, so a hunk that edits a signature would read as the PREVIOUS function; hc re-attributes those to the function they declare, and hunks that only add or remove imports claim no section at all (they ride with the code that needs them). Both corrections apply to the per-filesectionsarray and thereview granularitywarning too, so a signature change or an import riding along no longer looks like two ideas.- Docs and prose files (
.md,.rst,.txt, ...) take sections from HEADINGS only. A paragraph is never a section, however much it looks like code --Thrown when `definition()` is not implementedis a sentence, not a declaration. Map the repo's docs to get headings instead (see Teaching git where a section starts below); unmapped, a prose file has no sections and falls back to the gap heuristic. - Per-file
sections-- the distinct sections the file's hunks touch, in order. More than one entry = probably more than one idea: plan hunk-level splits. - Signal hierarchy for "is this one idea?": different files > different sections > distant regions. Non-adjacent changes are separate hunks and therefore split CANDIDATES -- nearby hunks in the SAME section are usually one idea, but far-apart regions in a sectionless file (configs, docs, top-level code) usually are not.
hc planencodes exactly this: sections first, then a ~8-unchanged-line gap fallback (skipped for scattered-many files like lockfiles, which are one mechanical change). index-- what you reference in the plan.- Top-level
untracked-- plain untracked paths (compact string array). They carry no hunks and never enter coverage validation; plan a path only to commit that new file. - Top-level
warnings-- non-fatal issues (e.g. pre-staged changes thathc runwill reject). Always check it.
File states and what to plan:
| Diff entry looks like | State | Plan entry |
|---|---|---|
hunks: [...] |
Modified file | {"path": ..., "hunks": [...]} or omit hunks for whole file |
Path in top-level untracked array |
New file | Only if it should be committed: full-file {"path": ...} (no hunk indices exist). Otherwise ignore -- untracked files never enter coverage validation |
is_deleted: true |
Deleted file | {"path": ...} (full-file stages the deletion) |
is_binary: true |
Binary file | Full-file only; hunks is a validation error |
hunks: [], no flags |
Mode-only change (e.g. chmod +x) | Full-file: {"path": ...} |
| Old path deleted + new path untracked | Rename/move | TWO entries: {"path": "old"} and {"path": "new"} (may share a commit); git shows it as a rename in history automatically. If you renamed with git mv, run git reset HEAD first -- git mv STAGES the rename, and hc reads unstaged changes only, so until you unstage it hc diff cannot see the rename at all and hc run rejects the plan |
is_intent_to_add: true (new file WITH hunks) |
Stale git add -N from another tool |
Nothing -- hc skips it from coverage and warns; plan its path only if you want it committed |
Hunk boundaries are git's: -U0 merges edits on adjacent lines into ONE hunk, and hc cannot split inside a hunk. If two logical changes ended up in the same hunk, either commit them together or make the edits in separate passes next time.
Teaching git where a section starts
Sections are git's, not hc's: hc reads the funcname git puts after the trailing @@. So a repo teaches git its boundaries once, in .gitattributes, and hc diff --json, hc plan, hc split --hunks and the review granularity warning all sharpen together. Three instances of one rule:
.gitattributes line |
Gives you | Without it |
|---|---|---|
*.php diff=php (likewise ruby, python, elixir, ... -- git help attributes lists the built-ins) |
Methods as sections in languages whose declarations are indented, which git's default regex does not see | The whole class reads as one section, and a new file stays one hunk |
*.md diff=markdown |
Headings as sections in docs and specs | A prose file has NO sections at all, and hc plan falls back to the contiguity-gap heuristic |
tests/**/*.php diff=phptest plus git config diff.phptest.xfuncname '^\s*(public\s+)?function\s+(test|it_)\w+' |
Your own boundary rule -- here, only test methods open a section | git's per-language default applies |
hc honors whatever git reports and has no AST or language plugin; none is planned. If a repo already carries the first line, the other two are the same move.
Plan Format
{
"commits": [
{
"message": "feat(auth): add login endpoint",
"files": [
{"path": "auth.go", "hunks": [0, 1]},
{"path": "handler.go"}
]
}
],
"allow_unplanned": ["experiments/**"]
}
| Field | Type | Description |
|---|---|---|
commits |
array | Ordered list of commits (required) |
commits[].message |
string | Non-empty commit message |
commits[].files |
array | Files in this commit (at least one) |
commits[].files[].path |
string | Relative path from repo root |
commits[].files[].hunks |
int[] | Hunk indices from hc diff. Omit to stage the whole file. |
allow_unplanned |
string[] | Globs excluded from coverage validation (* = one level, ** = recursive) |
Ticket / Prefix Conventions
- Same ticket for the whole run: pass it once --
hc run --prefix "WB-1234: " -prepends it to every commit message (idempotent: already-prefixed messages are left alone). Write plain conventional messages and let the flag do the rest. - Different tickets per commit (umbrella branch, many issues in one run): write the ticket directly into each commit message --
"message": "WB-2940: feat(auth): add login". Per-commit prefixes are the plan author's job; hc keeps messages otherwise opaque.
Anti-patterns -- do NOT do these
- Do NOT put untracked paths into
allow_unplannedor into commits "to satisfy coverage". Coverage validation only covers files with hunks in the diff. Entries in the top-leveluntrackedarray require NOTHING from you; reference one only when you actually want that new file committed. If you think hc demanded an untracked file, re-read the error -- it was about a different (tracked or intent-to-add) file. - Do NOT run
git diff,git add, orgit commitalongside hc.hc diff --jsonhas everything;hc rundoes all staging. - Do NOT re-run
hc diffbetween commits of one plan. One read, one plan, one run. - Do NOT batch the whole task and commit once at the end. Commit after every completed unit (change + green test); stacked uncommitted edits fuse into inseparable hunks.
- Do NOT defer test-writing to the end of the task. The test belongs to the unit it covers, written right after the change.
- Do NOT bundle same-kind work across files ("add tests for X, Y and Z" in one commit). Unless it is a mechanical sweep or an inseparable change, every file is its own commit.
- Do NOT bundle a change and its test into one commit, and do NOT bundle several NEW tests into one
test:commit. Every newly written test function is its own commit (classify viasection). Only MODIFICATIONS to existing tests may group, and only when one context drives them. "It would bloat the history" is never a reason to merge -- history size is not a problem. - Do NOT write
hunks: [all indices](or omithunks) for a multi-hunk file without reading each hunk'ssection/content. This is the most common under-split. Start fromhc planinstead -- it pre-splits by section -- and treathc run'sreview granularitywarning as a prompt to re-check.
Commit Cadence -- commit as you work, not at the end
Agents batch an entire task and commit once at the end -- sometimes 20+ changed files deep. Do not. Beyond review pain, deferring commits destroys splittability mechanically: edits made on top of uncommitted edits in the same region FUSE into one hunk. Two ideas that touch nearby lines become a single inseparable hunk that no tool can pull apart afterwards. Committing between ideas is the only moment separation is free.
- The unit of work is: one change + its test passing. When a unit is done, STOP and commit it -- as SEPARATE commits: one code commit (
feat/fix/...), then itstestcommit -- before starting the next unit. - Write the test immediately after the change it covers -- while the context is fresh -- never as an end-of-task batch. The unit is not done until its test is green.
- Overdue check: if
hc diff --jsonshows changes in more than ~5 files (or a file has accumulated hunks from more than one idea), you have waited too long. Commit the completed units now; keep only the in-progress one uncommitted. - Long refactors/sweeps are still ONE unit (one commit at their natural end) -- cadence does not mean fragmenting a mechanical change.
Commit Granularity -- the most important rule
Agents systematically err toward commits that are TOO BIG. Default to splitting.
The default unit is ONE FILE PER COMMIT. A commit containing two or more files must justify itself against exactly two exceptions:
- Mechanical sweep: the SAME repetitive transformation applied across many files -- a lint/format run, a rename, a comment reword, a codemod, an import reorder. One commit, message names the sweep (
style: apply linter across services). The test: the per-file diffs are interchangeable in kind; describing one describes all. - Inseparable change: files that cannot compile or pass independently -- a signature change plus its call sites, code plus the new type it requires. Keep this narrow: "related" is NOT "inseparable". Same feature, same ticket, same directory are NOT reasons to combine.
Everything else splits:
- Same KIND of change across files is not a sweep. "Fork the Store* action tests" over 9 files is 9 commits (
test: fork StoreOrderAction test,test: fork StorePaymentAction test, ...) -- each file reviews and reverts on its own. A sweep transforms existing lines mechanically; writing/forking N distinct files is N pieces of work. - Split within a file too -- the most-skipped rule. If a file's hunks carry separable ideas, give each its own commit. Check the file's
sectionsarray first: more than one section usually means more than one idea. Worked example: a state-machine file with 5 hunks acrossregion,isReadyForSubmissionand a new endpoint = 3 commits (imports ride with the code that needs them), NOT"hunks": [0,1,2,3,4]in one. - Type boundaries are commit boundaries. feat / fix / test / refactor / docs / chore never share a commit.
- Tests are ALWAYS their own commits, one commit per NEW test. A change and its test never share a commit -- the test commit follows immediately after the code commit it covers. Every newly written test function is its own commit, even when several live in the same file (classify each test hunk by its
section-- the test function name): N new tests = N commits. The one softening: MODIFICATIONS to existing tests may share a commit when a single context drives them (e.g. one behavior change forces updates across several existing tests) -- new tests never ride along with those either. - The litmus tests: (a) would the commit message still be accurate for each file alone? Then each file is its own commit. (b) Could
git revertof this commit undo exactly one decision? - New files can't be hunk-split in the WORKING TREE. If a new file will contain several logical changes, prefer creating it in separate passes and committing between them. (Once committed,
hc log/hc split --hunks/hc rewriteCAN split a new file per section -- see below -- but authoring in passes is still the cleaner path.)
History size is NEVER a problem -- internalize this. Do not fear high commit counts: 30 one-file commits are better than 6 bundles, and 10 single-test commits are better than one test: add tests blob. There is no such thing as "too many commits" from correct splitting; hc executes large plans cheaply. When torn between merging and splitting, ALWAYS split.
Commit Ordering
Order commits so the history builds cleanly:
- Infrastructure / types / helpers with no dependencies first
- Code that uses them second
- Each test commit immediately after the code commit it covers (never in the same commit; see the granularity rules)
Goal: each commit should compile and pass tests on its own. hc creates commits strictly in plan order.
Plan Writing Rules
- Run
hc diff --jsononce, immediately before planning. Classify each hunk from itscontentandsection. - Assign EVERY hunk to exactly one commit. Complete coverage is validated; unassigned hunks are a hard error.
- Use original indices everywhere. Even in later commits, reference hunks by their position in that one
hc diffoutput -- hc rebuilds staged content from those original coordinates, so line-number shifts from earlier commits never matter. - Match the plan entry to the file state (see the table above): untracked/binary/mode-only/deleted are full-file; renames need both paths.
- Conventional commit messages following the project's convention.
- Use
allow_unplannedsparingly -- only for TRACKED files with WIP changes that must stay uncommitted. Untracked and intent-to-add files never need it: they are only committed when you explicitly plan their path.*matches one path level; usedir/**for recursive.
Common Patterns
One commit per NEW test -- the rule for newly written tests (same file, classified via section):
{
"commits": [
{"message": "test(auth): add token expiry test", "files": [{"path": "auth_test.go", "hunks": [0]}]},
{"message": "test(auth): add token refresh test", "files": [{"path": "auth_test.go", "hunks": [1]}]},
{"message": "test(auth): add token revoke test", "files": [{"path": "auth_test.go", "hunks": [2]}]}
]
}
Modified existing tests may group by driving context (one behavior change forced all three updates -- new tests still commit separately):
{
"commits": [
{"message": "test(auth): update expiry fixtures for new token TTL", "files": [{"path": "auth_test.go", "hunks": [0, 1, 2]}]}
]
}
Feature + tests, one file per commit (default granularity):
{
"commits": [
{"message": "feat(auth): add refresh endpoint", "files": [{"path": "auth.go", "hunks": [0, 1]}]},
{"message": "feat(auth): route refresh endpoint", "files": [{"path": "handler.go"}]},
{"message": "test(auth): cover refresh endpoint", "files": [{"path": "auth_test.go"}]},
{"message": "test(auth): cover refresh routing", "files": [{"path": "handler_test.go"}]}
]
}
Mechanical sweep -- the one legitimate many-files commit:
{
"commits": [
{"message": "style: apply linter across services", "files": [
{"path": "svc/a.go"}, {"path": "svc/b.go"}, {"path": "svc/c.go"}
]}
]
}
Partial commit with WIP excluded:
{
"allow_unplanned": ["experiments/**"],
"commits": [
{"message": "fix(db): close connections on timeout", "files": [{"path": "db.go", "hunks": [0]}]},
{"message": "test(db): cover connection close on timeout", "files": [{"path": "db_test.go"}]}
]
}
Splitting Existing Commits (hc log + hc rewrite)
Already-made commits that are too coarse (pre-hc history, or over-grouped runs) can be split retroactively.
Fast path -- file-level splitting (the common case):
# 1. Survey the range cheaply: per-file flags + hunk_count, NO hunk content.
hc log <base>..HEAD --files-only --json
# 2. Generate the default one-file-per-commit plan (multi-file commits split,
# single-file commits and merges left as-is). Prints the plan; applies nothing.
hc split <base>..HEAD > plan.json
# 3. REVIEW the draft -- this is your semantic job:
# - DELETE rewrites that are mechanical sweeps (lint/rename/codemod): they should stay one commit.
# - Refine messages ({subject} ({basename}) is only a default; --message-template "{subject} :: {path}" etc.).
# - Add within-file hunk splits where one file carries separable ideas (see below).
# 4. Validate, then apply.
hc rewrite --dry-run --summary plan.json
hc rewrite plan.json
Hunk-level splitting within a file works exactly like hc run: read that commit's entry from hc log <range> --json (WITH content) and assign hunk indices across replacement commits:
{"rewrites": [{"commit": "a1b2c3d4e5f6", "commits": [
{"message": "feat: edit A", "files": [{"path": "f.go", "hunks": [0]}]},
{"message": "fix: edit B", "files": [{"path": "f.go", "hunks": [1, 2]}]}
]}]}
New files split per section too. A commit that ADDED a whole file (e.g. a fresh test suite committed in one go) is not stuck as one hunk: hc log --json exposes the file as per-section synthetic hunks, hc split --hunks proposes one commit per section, and hc rewrite stages any subset -- so "one commit per NEW test" is enforceable retroactively on freshly-authored branches. Semantics:
- In TEST files the split is per-TEST, not per-function: only test-like functions (name conventions
test*/it_*/should_*/..., or a#[Test]/@testattribute above) open groups; helpers,setUpand other support functions fold into the preceding group. Attribute/docblock/comment lines ride with the function they decorate. Non-test files split per function. - Intermediate commits stay syntactically valid. The file's trailing closing scaffold (a class's closing brace) is exposed as a final
closing scaffoldhunk -- assign it to the FIRST commit (ashc split --hunksdrafts do): later hunks insert before it by construction, so every intermediate file is closed. Preamble rides with the first group automatically. - Section detection uses git's own funcname machinery, so a language whose declarations git does not recognize by default (indented methods: PHP, Ruby, ...) needs its
.gitattributesline or the new file stays ONE hunk -- and a customxfuncnamedriver redefines the boundaries with no hc involvement. See Teaching git where a section starts. - Plain-text/config new files (no function-like sections) keep their single whole-file hunk.
Rules:
commitis a SHA (12-char prefix fromhc log/hc splitis fine). Commits NOT listed inrewritesare kept as they are -- their SHAs still change because ancestry changes, but message/author/date/content stay identical.- Coverage applies per commit: the replacement commits together must cover EVERY hunk of the original commit exactly once (same guarantee as
hc run; noallow_unplannedhere). Hunk indices are per-file within the commit (each file's hunks start at 0); for whole files just omithunks. - Granularity rules apply unchanged: default one file per replacement commit; split a file's hunks further when they carry separable ideas; keep mechanical sweeps together (delete them from
hc splitdrafts). - Conflict-free by construction: each split must reproduce the original commit's tree byte-for-byte (hc verifies it; the result reports
"tree_identical": true), so downstream commits re-parent cleanly -- no rebase conflicts, and the working tree is never touched (uncommitted changes are safe). - Do NOT re-run tests/builds after a rewrite. The final tree is byte-identical, so every build/test result is unchanged by construction --
tree_identical: truein the result is the only verification needed. - Merges mid-range are fine: an untouched merge is preserved (re-parented with its other parents intact). Only SPLITTING a merge (or the root commit) is refused.
- Protect other people's history:
--protect origin/develop(repeatable) refuses any rewrite of commits reachable from that ref -- use it whenever the branch builds on shared history, instead of eyeballing the range. - Safety rails: the old head is saved at
refs/hc/backup/<branch>(restore withgit reset --hard <backup-ref>); commits already on a remote are refused unless--force(thengit push --force-with-lease); requires a checked-out branch (no detached HEAD). --dry-runbuilds and validates the whole new history (including tree invariants) without moving the branch -- it works even on pushed history without--force. Add--summaryto get counts ({split, replacements, kept, total_after}) without the full replacement list.- Exit codes match
hc run: 2 = plan problem, nothing changed; the branch only ever moves in one final atomic step.
Error Recovery
Every error is JSON with error, code, and hint fields. Exit codes tell you the recovery path:
| Exit | Meaning | Recovery |
|---|---|---|
| 2 | Validation error. No git state changed. | Fix the plan per the hint, retry the same hc run. |
| 3 | Execution error mid-plan. Some commits exist. | Do NOT rewrite the plan -- follow the four steps below. |
After exit 3 the recovery is mechanical. The commits already created stay created, and hc run --continue finishes the rest of the SAME plan with the SAME hunk indices: hc re-derives the original diff from the commit the plan was written against, so index 1 still means the hunk you assigned to index 1.
- Read
commits[]-- entries with"status": "committed"are done, with their SHAs. - Clear what the
hintnames: wait out a lock, fix the pre-commit hook, free the disk. - Run
git reset HEADif anything is still staged ----continueneeds a clean index. hc leaves staging in place after agit commitfailure by design, and it can also fail to clear it after a staging failure: that reset needs the same lock that just refused the staging call. When that happens thehintsays so. hc run --continue-- no plan argument, no--prefix; both come from the record the stopped run left behind.
Never commit manually to "finish" the failed commit. That moves HEAD, --continue refuses a record whose HEAD moved, and you are back to rewriting the plan by hand -- exactly what --continue exists to avoid.
Re-plan from hc diff --json only when hc refuses with HEAD has moved since the interrupted run stopped, or with working tree content of <file> does not match the captured diff (fixing the cause edited a file the plan covers).
Common validation errors:
staging area is not clean-- something is pre-staged. Rungit reset HEAD, then retry.hunks [...] not assigned to any commit/has changes but is not in the plan-- add the listed hunks/file to a commit or useallow_unplanned.hunk index N out of range-- the diff changed since you read it. Re-runhc diff --jsonand re-plan.git commit failed(exit 3) -- usually a pre-commit hook. Staging is intact; follow the four steps above.Unable to create '<path>/index.lock': File exists(exit 3) -- another git process (an editor, another agent, a background indexer) held the repository lock for the whole 2 s hc retries. The plan was never wrong: wait for that process, then continue. Raise the budget withHC_LOCK_TIMEOUT=10sif it keeps happening.
Key Commands
| Command | Purpose |
|---|---|
hc diff --json |
Indexed hunks WITH content and section -- everything needed to plan |
hc plan > draft.json |
Draft plan: file-first + section-split, TODO messages (run refuses TODOs) |
hc diff |
Same, compact TTY view (no content) |
hc run - <<'PLAN' ... PLAN |
Execute plan from stdin (preferred) |
hc run plan.json |
Execute plan from file |
hc run --prefix "WB-1234: " - |
Prepend a uniform prefix to every commit message |
hc run --dry-run - |
Validate only (rarely needed; run validates first anyway) |
hc run --continue |
Finish a plan a stopped run left part-way -- no plan argument, same hunk indices |
hc log <base>..HEAD --files-only --json |
Cheap per-commit file survey (no hunk content) |
hc log <base>..HEAD --json |
Per-commit indexed hunks WITH content (for hunk-level splits) |
hc split <base>..HEAD |
Emit the default one-file-per-commit rewrite plan (review, then pipe) |
hc split --hunks <range> |
Same, plus within-file splits grouped by section (draft heuristic) |
hc rewrite - <<'PLAN' ... PLAN |
Split existing commits; conflict-free, backup ref kept |
hc rewrite --dry-run --summary - |
Validate a rewrite (counts only) without moving the branch |
hc --version |
Show version |
Installation
# Install the binary
brew install deligoez/tap/hc # alias for the deligoez-hc formula; installs the 'hc' binary
# Install this skill for Claude Code
npx skills add -g deligoez/hc