feat(kvb-git-commit-next): stage a file by line with stage_hunks.py

stage_hunks.py takes git diff -U0 of one file, keeps the added lines of an add-only hunk that match a regex and every other hunk whole when one of its changed lines matches, and applies the result to the index with git apply --cached --unidiff-zero --recount, or prints it with --dry-run; exit 1 when nothing matches. Step 4 of kvb-git-commit-next names it for a file that carries two bundles, which replaces the hand-written partial patches needed five times on 2026-11-03. tests/test_stage_hunks.py covers matching, the unstaged rest and the exit codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-03 17:57:08 +01:00
1 parent 3e2f7a7ddb
commit 6865ae718a
3 files changed
+163 -1

No files matched your search

+3 -1
View File
@@ -29,7 +29,9 @@ description: >
see tracked, modified files and read the worktree; only an untracked
NEW file needs `git add` to become visible. Path-scope every `git add`
to the bundle — `git add -A` sweeps up the operator's own edits and
their local config.
their local config. A file that carries two bundles is staged by line:
`${CLAUDE_SKILL_DIR}/scripts/stage_hunks.py --repo <worktree> --file
<path> --match <regex>`, first with `--dry-run`.
5. For the newly staged bundle run the kvb-think-explain skill (what the changes do
and how the pieces interact, file:line-anchored) followed by the
kvb-check-confidence skill (fix confidence, break risk, evidence, residuals).
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Stage only the changed lines of one file that match a pattern.
Args:
--repo: path of the git worktree.
--file: the file, relative to the worktree root.
--match: regular expression; a hunk of only added lines keeps the added
lines it matches, any other hunk is staged whole when one of its
changed lines matches.
--dry-run: print the patch instead of applying it to the index.
Exit codes:
0 staged or printed, 1 nothing matched, 2 usage error.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
def fail(message: str) -> None:
print(f"stage_hunks: {message}", file=sys.stderr)
sys.exit(2)
def git(repo: Path, *args: str, stdin: str | None = None) -> str:
result = subprocess.run(
["git", *args], cwd=repo, input=stdin, capture_output=True, text=True, check=False
)
if result.returncode != 0:
fail(result.stderr.strip() or f"git {' '.join(args)} failed")
return result.stdout
def split(diff: str) -> tuple[list[str], list[list[str]]]:
header: list[str] = []
hunks: list[list[str]] = []
for line in diff.splitlines():
if line.startswith("@@"):
hunks.append([line])
elif hunks:
hunks[-1].append(line)
else:
header.append(line)
return header, hunks
def select(hunks: list[list[str]], pattern: re.Pattern) -> list[list[str]]:
kept = []
for hunk in hunks:
changes = [line for line in hunk[1:] if line[:1] in "+-"]
if all(line.startswith("+") for line in changes):
lines = [line for line in changes if pattern.search(line[1:])]
if lines:
kept.append([hunk[0], *lines])
elif any(pattern.search(line[1:]) for line in changes):
kept.append(hunk)
return kept
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--repo", required=True)
parser.add_argument("--file", required=True)
parser.add_argument("--match", required=True)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
repo = Path(args.repo)
if not repo.is_dir():
fail(f"{repo} is not a directory")
try:
pattern = re.compile(args.match)
except re.error as error:
fail(f"--match: {error}")
header, hunks = split(git(repo, "diff", "--no-color", "-U0", "--", args.file))
kept = select(hunks, pattern)
if not kept:
print(f"stage_hunks: no change in {args.file} matches {args.match!r}", file=sys.stderr)
return 1
patch = "\n".join([*header, *(line for hunk in kept for line in hunk)]) + "\n"
if args.dry_run:
print(patch, end="")
return 0
git(repo, "apply", "--cached", "--unidiff-zero", "--recount", "-", stdin=patch)
print(f"staged {sum(len(h) - 1 for h in kept)} lines of {args.file}")
return 0
if __name__ == "__main__":
sys.exit(main())
+65
View File
@@ -0,0 +1,65 @@
"""Exercise stage_hunks.py: matching added lines, whole changed hunks, the rest stays unstaged."""
from __future__ import annotations
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "kvb-git-commit-next" / "scripts" / "stage_hunks.py"
@unittest.skipUnless(shutil.which("git"), "git not installed")
class TestStageHunks(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.repo = Path(self.tmp.name)
self.env = {
"PATH": "/usr/bin:/bin",
"HOME": str(self.repo),
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
self.file = self.repo / "notes.txt"
self.file.write_text("one\ntwo\nthree\nfour\nfive\n", encoding="utf-8")
self._git("init", "-q")
self._git("add", "notes.txt")
self._git("commit", "-q", "-m", "base")
self.file.write_text(
"one\nalpha one\nbeta one\ntwo\nthree\nfour alpha\nfive\nbeta two\n", encoding="utf-8"
)
def _git(self, *args: str) -> str:
return subprocess.run(
["git", *args], check=True, capture_output=True, text=True, env=self.env, cwd=self.repo
).stdout
def _run(self, pattern: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), "--repo", str(self.repo), "--file", "notes.txt", "--match", pattern],
check=False, capture_output=True, text=True, env=self.env,
)
def test_only_matching_changes_are_staged(self):
result = self._run("alpha")
self.assertEqual(result.returncode, 0, result.stderr)
staged = [line for line in self._git("diff", "--cached", "-U0").splitlines() if line[:1] in "+-" and line[:3] not in ("+++", "---")]
self.assertEqual(staged, ["+alpha one", "-four", "+four alpha"])
unstaged = [line for line in self._git("diff", "-U0").splitlines() if line.startswith("+") and not line.startswith("+++")]
self.assertEqual(unstaged, ["+beta one", "+beta two"])
def test_no_match_exits_1_and_bad_pattern_exits_2(self):
self.assertEqual(self._run("gamma").returncode, 1)
self.assertEqual(self._run("(").returncode, 2)
self.assertEqual(self._git("diff", "--cached"), "")
if __name__ == "__main__":
unittest.main()