SuperAPI AI Directory

hc

Agents & models

View repository

Updated 10-15 20:35

About this skill

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:

  1. Write a real commit message. While writing it, sanity-check the entry is one idea.
  2. 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 test entries for separate new tests.
  3. 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 with content_truncated: true and content_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-file sections array and the review granularity warning 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 implemented is a sentence, not a declaration. A # comment inside a fenced code block is not a heading either: git's markdown driver reports it as one, and hc re-attributes the hunk to the real heading above the block. 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 plan encodes 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 that hc run will 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_unplanned or into commits "to satisfy coverage". Coverage validation only covers files with hunks in the diff. Entries in the top-level untracked array 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, or git commit alongside hc. hc diff --json has everything; hc run does all staging.
  • Do NOT re-run hc diff between 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 via section). 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 omit hunks) for a multi-hunk file without reading each hunk's section/content. This is the most common under-split. Start from hc plan instead -- it pre-splits by section -- and treat hc run's review granularity warning 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 its test commit -- 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 --json shows 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:

  1. 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.
  2. 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 sections array first: more than one section usually means more than one idea. Worked example: a state-machine file with 5 hunks across region, isReadyForSubmission and 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 revert of 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 rewrite CAN 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:

  1. Infrastructure / types / helpers with no dependencies first
  2. Code that uses them second
  3. 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

  1. Run hc diff --json once, immediately before planning. Classify each hunk from its content and section.
  2. Assign EVERY hunk to exactly one commit. Complete coverage is validated; unassigned hunks are a hard error.
  3. Use original indices everywhere. Even in later commits, reference hunks by their position in that one hc diff output -- hc rebuilds staged content from those original coordinates, so line-number shifts from earlier commits never matter.
  4. Match the plan entry to the file state (see the table above): untracked/binary/mode-only/deleted are full-file; renames need both paths.
  5. Conventional commit messages following the project's convention.
  6. Use allow_unplanned sparingly -- 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; use dir/** 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]/@test attribute above) open groups; helpers, setUp and 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 scaffold hunk -- assign it to the FIRST commit (as hc split --hunks drafts 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 .gitattributes line or the new file stays ONE hunk -- and a custom xfuncname driver 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:

  1. commit is a SHA (12-char prefix from hc log/hc split is fine). Commits NOT listed in rewrites are kept as they are -- their SHAs still change because ancestry changes, but message/author/date/content stay identical.
  2. Coverage applies per commit: the replacement commits together must cover EVERY hunk of the original commit exactly once (same guarantee as hc run; no allow_unplanned here). Hunk indices are per-file within the commit (each file's hunks start at 0); for whole files just omit hunks.
  3. 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 split drafts).
  4. 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).
  5. 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: true in the result is the only verification needed.
  6. 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.
  7. Protect other people's history: hc rewrite --protect origin/develop plan.json (repeatable) refuses any rewrite of commits reachable from that ref -- use it whenever the branch builds on shared history, instead of eyeballing the range. The flag belongs to hc rewrite only: hc split and hc log just read history and draft, so there is nothing for them to refuse -- a draft that reaches protected commits is stopped when it is applied.
  8. Safety rails: the old head is saved at refs/hc/backup/<branch> (restore with git reset --hard <backup-ref>); commits already on a remote are refused unless --force (then git push --force-with-lease); requires a checked-out branch (no detached HEAD).
  9. --dry-run builds and validates the whole new history (including tree invariants) without moving the branch -- it works even on pushed history without --force. Add --summary to get counts ({split, replacements, kept, total_after}) without the full replacement list.
  10. 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.

  1. Read commits[] -- entries with "status": "committed" are done, with their SHAs.
  2. Clear what the hint names: wait out a lock, fix the pre-commit hook, free the disk.
  3. Run git reset HEAD if anything is still staged -- --continue needs a clean index. hc leaves staging in place after a git commit failure 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 the hint says so.
  4. 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. Run git 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 use allow_unplanned.
  • hunk index N out of range -- the diff changed since you read it. Re-run hc diff --json and 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 with HC_LOCK_TIMEOUT=10s if 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