diff --git a/skills/kvb-git-commit-next/SKILL.md b/skills/kvb-git-commit-next/SKILL.md index ae7c6ff..3edda77 100644 --- a/skills/kvb-git-commit-next/SKILL.md +++ b/skills/kvb-git-commit-next/SKILL.md @@ -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 --file + --match `, 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). diff --git a/skills/kvb-git-commit-next/scripts/stage_hunks.py b/skills/kvb-git-commit-next/scripts/stage_hunks.py new file mode 100755 index 0000000..72d2060 --- /dev/null +++ b/skills/kvb-git-commit-next/scripts/stage_hunks.py @@ -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()) diff --git a/tests/test_stage_hunks.py b/tests/test_stage_hunks.py new file mode 100644 index 0000000..de579bf --- /dev/null +++ b/tests/test_stage_hunks.py @@ -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()