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:
1 parent
3e2f7a7ddb
commit
6865ae718a
3 files changed
+163
-1
No files matched your search
@@ -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
@@ -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())
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user