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
|
see tracked, modified files and read the worktree; only an untracked
|
||||||
NEW file needs `git add` to become visible. Path-scope every `git add`
|
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
|
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
|
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
|
and how the pieces interact, file:line-anchored) followed by the
|
||||||
kvb-check-confidence skill (fix confidence, break risk, evidence, residuals).
|
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