From 0d942c50cff2d9472b664e74d423661c9f1693cb Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Tue, 21 Jul 2026 15:13:13 -0700 Subject: [PATCH 1/4] feat: add consolidated carve-changesets CLI ## Summary - Port the frozen prepare-changesets implementation behind one classified CLI - Harden GitHub, preflight, push-chain, and file-based message boundaries - Wire materialization and live status validation to the published carve contracts - Port focused tests and evals to the new skill root ## Why - Provide the implementation surface required by issue #30 without changing the frozen source skill - Make remote mutation dry-run by default and preserve the base and source branches --- CHANGELOG.md | 4 +- skills/carve-changesets/scripts/chain.py | 427 +++++++++++++ skills/carve-changesets/scripts/cli.py | 419 ++++++++++++ skills/carve-changesets/scripts/common.py | 601 ++++++++++++++++++ skills/carve-changesets/scripts/db_compare.py | 79 +++ .../scripts/evals/__init__.py | 1 + .../carve-changesets/scripts/evals/grader.py | 186 ++++++ .../carve-changesets/scripts/evals/helpers.py | 90 +++ .../scripts/evals/prompts.csv | 3 + .../carve-changesets/scripts/evals/runner.py | 172 +++++ skills/carve-changesets/scripts/github.py | 186 ++++++ .../carve-changesets/scripts/patch_apply.py | 435 +++++++++++++ .../carve-changesets/scripts/plan_checks.py | 291 +++++++++ skills/carve-changesets/scripts/preflight.py | 166 +++++ skills/carve-changesets/scripts/propagate.py | 83 +++ .../carve-changesets/scripts/squash_check.py | 90 +++ skills/carve-changesets/scripts/squash_ref.py | 106 +++ .../scripts/tests/legacy_helpers.py | 143 +++++ .../scripts/tests/test_chain.py | 113 ++++ .../scripts/tests/test_cli_safety.py | 49 ++ .../scripts/tests/test_common.py | 39 ++ .../scripts/tests/test_dbcompare.py | 35 + .../scripts/tests/test_evals.py | 78 +++ .../scripts/tests/test_github.py | 56 ++ .../scripts/tests/test_hunks_apply.py | 395 ++++++++++++ .../scripts/tests/test_preflight.py | 147 +++++ .../scripts/tests/test_propagate.py | 61 ++ .../scripts/tests/test_scripts_integration.py | 84 +++ .../scripts/tests/test_squash.py | 55 ++ .../tests/test_test_command_discovery.py | 79 +++ 30 files changed, 4672 insertions(+), 1 deletion(-) create mode 100755 skills/carve-changesets/scripts/chain.py create mode 100755 skills/carve-changesets/scripts/cli.py create mode 100644 skills/carve-changesets/scripts/common.py create mode 100755 skills/carve-changesets/scripts/db_compare.py create mode 100644 skills/carve-changesets/scripts/evals/__init__.py create mode 100644 skills/carve-changesets/scripts/evals/grader.py create mode 100644 skills/carve-changesets/scripts/evals/helpers.py create mode 100644 skills/carve-changesets/scripts/evals/prompts.csv create mode 100755 skills/carve-changesets/scripts/evals/runner.py create mode 100644 skills/carve-changesets/scripts/github.py create mode 100755 skills/carve-changesets/scripts/patch_apply.py create mode 100644 skills/carve-changesets/scripts/plan_checks.py create mode 100755 skills/carve-changesets/scripts/preflight.py create mode 100644 skills/carve-changesets/scripts/propagate.py create mode 100755 skills/carve-changesets/scripts/squash_check.py create mode 100755 skills/carve-changesets/scripts/squash_ref.py create mode 100644 skills/carve-changesets/scripts/tests/legacy_helpers.py create mode 100644 skills/carve-changesets/scripts/tests/test_chain.py create mode 100644 skills/carve-changesets/scripts/tests/test_cli_safety.py create mode 100644 skills/carve-changesets/scripts/tests/test_common.py create mode 100644 skills/carve-changesets/scripts/tests/test_dbcompare.py create mode 100644 skills/carve-changesets/scripts/tests/test_evals.py create mode 100644 skills/carve-changesets/scripts/tests/test_github.py create mode 100644 skills/carve-changesets/scripts/tests/test_hunks_apply.py create mode 100644 skills/carve-changesets/scripts/tests/test_preflight.py create mode 100644 skills/carve-changesets/scripts/tests/test_propagate.py create mode 100644 skills/carve-changesets/scripts/tests/test_scripts_integration.py create mode 100644 skills/carve-changesets/scripts/tests/test_squash.py create mode 100644 skills/carve-changesets/scripts/tests/test_test_command_discovery.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 64dfade..7168d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ summary: Chronological history of repository and skill changes. # Changelog -## 2026-07-21 — Carve-changesets live validation, identity, and contract +## 2026-07-21 — Consolidated carve-changesets CLI and live contract +- feat: add the consolidated carve-changesets CLI - fix: bind changeset validation to current live refs + (`721a1ea07c0e8d8af1265bbf70326afaf286aa4a`) - feat: validate changeset chains from live git (`df24771983819b05110670a8d03d43e003d23d28`) - feat: add self-describing changeset identity diff --git a/skills/carve-changesets/scripts/chain.py b/skills/carve-changesets/scripts/chain.py new file mode 100755 index 0000000..a093055 --- /dev/null +++ b/skills/carve-changesets/scripts/chain.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Changeset chain creation, comparison, and validation.""" + +from __future__ import annotations + +import fnmatch +import subprocess +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from common import ( + CommandError, + branch_exists, + branch_name_for, + checkout_restore, + commit_with_message, + delete_branch, + diff_name_status, + diff_stat, + ensure_branches_exist, + ensure_clean_tree, + ensure_git_repo, + git, + unique_temp_branch, +) +from metadata import ChangesetMetadata, stamp_commit_message +from patch_apply import ( + apply_patch_file, + apply_patch_text, + build_diff, + parse_hunk_selectors, + select_hunks_for_changeset, +) + + +@dataclass +class DiffEntry: + status: str + path: str + old_path: Optional[str] = None + + +def _matches_any(path: str, patterns: Iterable[str]) -> bool: + return any(fnmatch.fnmatch(path, pat) for pat in patterns) + + +def changed_files_between(base: str, source: str) -> List[DiffEntry]: + raw = diff_name_status(base, source) + entries: List[DiffEntry] = [] + if not raw: + return entries + + for line in raw.splitlines(): + parts = line.split("\t") + if not parts: + continue + status = parts[0] + code = status[0] + if code == "R" and len(parts) >= 3: + entries.append(DiffEntry(status=code, path=parts[2], old_path=parts[1])) + elif len(parts) >= 2: + entries.append(DiffEntry(status=code, path=parts[1])) + return entries + + +def select_entries( + entries: Sequence[DiffEntry], include: Sequence[str], exclude: Sequence[str] +) -> List[DiffEntry]: + if not include: + return [] + + selected = [ + e + for e in entries + if _matches_any(e.path, include) + or (e.old_path and _matches_any(e.old_path, include)) + ] + if not exclude: + return selected + + filtered: List[DiffEntry] = [] + for e in selected: + if _matches_any(e.path, exclude): + continue + if e.old_path and _matches_any(e.old_path, exclude): + continue + filtered.append(e) + return filtered + + +@dataclass +class ApplySummary: + mode: str + message: str + + +def _commit_changeset( + *, source_branch: str, source_sha: str, index: int, changeset: Dict +) -> None: + commit_message = changeset.get("commit_message") + slug = str(changeset.get("slug", f"cs-{index}")).strip() or f"cs-{index}" + if not isinstance(commit_message, str) or not commit_message.strip(): + commit_message = f"changeset {index}: {slug}" + stamped = stamp_commit_message( + commit_message, + ChangesetMetadata( + slug=slug, + index=index, + source_branch=source_branch, + source_sha=source_sha, + ), + ) + commit_with_message(stamped) + + +def _apply_changeset_paths( + *, + base_branch: str, + source_branch: str, + source_sha: str, + index: int, + changeset: Dict, +) -> ApplySummary: + include = changeset.get("include_paths", []) + exclude = changeset.get("exclude_paths", []) + + diff_entries = changed_files_between(base_branch, source_branch) + selected = select_entries(diff_entries, include, exclude) + + if not selected: + print(f"[WARN] Changeset {index}: no files matched include/exclude rules.") + return ApplySummary(mode="paths", message="no paths matched") + + checkout_paths: List[str] = [] + delete_paths: List[str] = [] + + for entry in selected: + if entry.status == "D": + delete_paths.append(entry.path) + continue + if ( + entry.old_path + and entry.old_path != entry.path + and entry.old_path not in delete_paths + ): + delete_paths.append(entry.old_path) + checkout_paths.append(entry.path) + + for path in checkout_paths: + git("checkout", source_branch, "--", path) + + for path in delete_paths: + git("rm", "-f", "--ignore-unmatch", path) + + git("add", "-A") + git("reset", "-q", "--", ".carve-changesets") + + diff_cached = git("diff", "--cached", "--quiet", check=False) + if diff_cached.returncode == 0: + print(f"[WARN] Changeset {index}: no staged changes after apply.") + return ApplySummary( + mode="paths", + message=( + f"{len(checkout_paths)} paths checked out, {len(delete_paths)} paths removed" + ), + ) + + _commit_changeset( + source_branch=source_branch, + source_sha=source_sha, + index=index, + changeset=changeset, + ) + return ApplySummary( + mode="paths", + message=( + f"{len(checkout_paths)} paths checked out, {len(delete_paths)} paths removed" + ), + ) + + +def _apply_changeset_patch( + *, + source_branch: str, + source_sha: str, + index: int, + changeset: Dict, + label: str, +) -> ApplySummary: + patch_file = changeset.get("patch_file") + if not isinstance(patch_file, str) or not patch_file.strip(): + raise CommandError(f"{label}: patch_file must be a non-empty string.") + apply_patch_file(patch_file, label=label) + + diff_cached = git("diff", "--cached", "--quiet", check=False) + if diff_cached.returncode == 0: + print(f"[WARN] Changeset {index}: no staged changes after apply.") + return ApplySummary( + mode="patch", message="patch applied with no staged changes" + ) + + _commit_changeset( + source_branch=source_branch, + source_sha=source_sha, + index=index, + changeset=changeset, + ) + return ApplySummary(mode="patch", message="patch applied and committed") + + +def _apply_changeset_hunks( + *, + base_branch: str, + source_branch: str, + source_sha: str, + index: int, + changeset: Dict, + label: str, +) -> ApplySummary: + selectors = changeset.get("hunk_selectors", []) + include = changeset.get("include_paths", []) + exclude = changeset.get("exclude_paths", []) + allow_partial = changeset.get("allow_partial_files", True) + + parsed = parse_hunk_selectors(selectors, changeset_label=label) + diff_files = build_diff(base_branch, source_branch) + selected = select_hunks_for_changeset( + diff_files, + parsed, + include_paths=include, + exclude_paths=exclude, + allow_partial_files=bool(allow_partial), + changeset_label=label, + ) + apply_patch_text(selected.text, label=label) + + diff_cached = git("diff", "--cached", "--quiet", check=False) + if diff_cached.returncode == 0: + print(f"[WARN] Changeset {index}: no staged changes after apply.") + return ApplySummary( + mode="hunks", + message=f"{selected.hunks} hunks selected in {selected.files} files", + ) + + _commit_changeset( + source_branch=source_branch, + source_sha=source_sha, + index=index, + changeset=changeset, + ) + return ApplySummary( + mode="hunks", + message=f"{selected.hunks} hunks selected in {selected.files} files", + ) + + +def apply_changeset( + *, + base_branch: str, + source_branch: str, + source_sha: str, + index: int, + changeset: Dict, +) -> ApplySummary: + mode = str(changeset.get("mode", "paths")).strip() or "paths" + label = f"Changeset {index}" + if mode == "paths": + return _apply_changeset_paths( + base_branch=base_branch, + source_branch=source_branch, + source_sha=source_sha, + index=index, + changeset=changeset, + ) + if mode == "patch": + return _apply_changeset_patch( + source_branch=source_branch, + source_sha=source_sha, + index=index, + changeset=changeset, + label=label, + ) + if mode == "hunks": + return _apply_changeset_hunks( + base_branch=base_branch, + source_branch=source_branch, + source_sha=source_sha, + index=index, + changeset=changeset, + label=label, + ) + raise CommandError( + f"{label}: unsupported mode '{mode}'. Use 'paths', 'patch', or 'hunks'." + ) + + +def create_chain(plan: Dict) -> List[str]: + ensure_git_repo() + ensure_clean_tree() + + base = plan["base_branch"] + source = plan["source_branch"] + changesets = plan["changesets"] + total = len(changesets) + + chain = [branch_name_for(source, i) for i in range(1, total + 1)] + ensure_branches_exist([base, source]) + source_sha = git("rev-parse", source).stdout.strip() + + existing_prefix = 0 + for idx, name in enumerate(chain, start=1): + exists = branch_exists(name) + if exists and idx == existing_prefix + 1: + existing_prefix = idx + continue + if exists and idx > existing_prefix + 1: + missing = branch_name_for(source, existing_prefix + 1) + raise CommandError( + f"Found existing branch {name} but missing earlier branch {missing}." + ) + + start_index = existing_prefix + 1 + if existing_prefix > 0: + print( + f"[INFO] Reusing existing changeset branches through index {existing_prefix}." + ) + print( + "[INFO] create-chain is append-only; delete a branch explicitly if it must be recreated." + ) + + with checkout_restore() as original: + print(f"[INFO] Starting from current branch: {original}") + prev_branch = base if existing_prefix == 0 else chain[existing_prefix - 1] + for idx in range(start_index, total + 1): + cs = changesets[idx - 1] + name = chain[idx - 1] + print(f"\n[STEP] Creating {name} from {prev_branch}") + git("checkout", "-B", name, prev_branch) + + summary = apply_changeset( + base_branch=base, + source_branch=source, + source_sha=source_sha, + index=idx, + changeset=cs, + ) + print(f"[OK] Applied changeset {idx} ({summary.mode}): {summary.message}") + prev_branch = name + + print("[OK] Changeset branch chain created.") + return chain + + +def compare_chain(plan: Dict) -> Tuple[str, str]: + ensure_git_repo() + ensure_clean_tree() + + base = plan["base_branch"] + source = plan["source_branch"] + total = len(plan["changesets"]) + + chain = [branch_name_for(source, i) for i in range(1, total + 1)] + ensure_branches_exist([base, source, *chain]) + + temp_branch = unique_temp_branch("pcs-temp-compare") + print(f"[INFO] Creating temporary comparison branch: {temp_branch}") + + with checkout_restore() as original: + try: + git("checkout", "-B", temp_branch, base) + for name in chain: + print(f"[STEP] Merging {name} into {temp_branch}") + git("merge", "--no-ff", "--no-edit", name) + + diffstat = diff_stat(temp_branch, source) + namestatus = diff_name_status(temp_branch, source) + finally: + git("checkout", original) + delete_branch(temp_branch) + print(f"\n[INFO] Restored original branch: {original}") + + return diffstat, namestatus + + +def validate_chain(plan: Dict, *, test_cmd: str) -> None: + """Merge changesets in order into a temp branch and run tests after each merge.""" + effective_test_cmd = test_cmd.strip() + if not effective_test_cmd: + raise CommandError( + "validate-chain requires an explicitly approved --test-cmd or " + "plan.test_command." + ) + + ensure_git_repo() + ensure_clean_tree() + + base = plan["base_branch"] + source = plan["source_branch"] + total = len(plan["changesets"]) + chain = [branch_name_for(source, i) for i in range(1, total + 1)] + ensure_branches_exist([base, source, *chain]) + + temp_branch = unique_temp_branch("pcs-temp-validate") + print(f"[INFO] Creating temporary validation branch: {temp_branch}") + + with checkout_restore() as original: + try: + git("checkout", "-B", temp_branch, base) + for idx, name in enumerate(chain, start=1): + print(f"\n[STEP] Merging {name} ({idx} of {total})") + git("merge", "--no-ff", "--no-edit", name) + print( + f"[STEP] Running tests after changeset {idx}: {effective_test_cmd}" + ) + if git("diff", "--quiet", check=False).returncode != 0: + raise CommandError( + "Working tree became dirty during validate-chain." + ) + result = subprocess.run(effective_test_cmd, shell=True) + if result.returncode != 0: + raise CommandError(f"Test command failed after changeset {idx}.") + finally: + git("checkout", original) + delete_branch(temp_branch) + print(f"\n[INFO] Restored original branch: {original}") + + print("[OK] validate-chain completed successfully.") diff --git a/skills/carve-changesets/scripts/cli.py b/skills/carve-changesets/scripts/cli.py new file mode 100755 index 0000000..81823a8 --- /dev/null +++ b/skills/carve-changesets/scripts/cli.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""The single command-line interface for carve-changesets.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Dict, List, Optional, Sequence + +from chain import compare_chain, create_chain, validate_chain +from common import ( + DEFAULT_PLAN_PATH, + CommandError, + discover_test_command, + ensure_clean_tree, + init_plan, + load_plan, + validate_plan, +) +from db_compare import db_compare +from github import pr_create, pull_requests_for_source +from patch_apply import build_diff +from plan_checks import strict_apply_check, validate_plan_strict +from preflight import preflight +from propagate import push_chain +from rehydrate import RehydrationError, rehydrate_chain +from squash_check import squash_check +from squash_ref import _resolve_base_source, create_squashed_ref +from status import status_from_live +from validate import validate_live_chain + +READ_ONLY = "read-only" +LOCAL_MUTATING = "local-mutating" +REMOTE_MUTATING = "remote-mutating" + +COMMAND_MUTATION_CLASSES = { + "preflight": LOCAL_MUTATING, + "init-plan": LOCAL_MUTATING, + "validate": LOCAL_MUTATING, + "status": READ_ONLY, + "create-chain": LOCAL_MUTATING, + "compare": LOCAL_MUTATING, + "validate-chain": LOCAL_MUTATING, + "pr-create": REMOTE_MUTATING, + "push-chain": REMOTE_MUTATING, + "db-compare": LOCAL_MUTATING, + "hunk-preview": READ_ONLY, + "squash-ref": LOCAL_MUTATING, + "squash-check": LOCAL_MUTATING, + "run": LOCAL_MUTATING, +} + + +def load_and_validate(plan_path: Path) -> Dict: + plan = load_plan(plan_path) + valid, errors = validate_plan(plan) + if not valid: + for error in errors: + print(f"[ERROR] {error}") + raise CommandError("Plan validation failed.") + return plan + + +def _print_discovered_test_command() -> None: + discovery = discover_test_command("") + command = str(discovery.get("command") or "").strip() + if command: + print(f"[HINT] Discovered test command proposal: {command}") + else: + for suggestion in discovery.get("suggestions", []): + print(f"[HINT] Test command proposal: {suggestion}") + print("[NEXT] Pass an approved command explicitly with --test-cmd.") + + +def cmd_preflight(args: argparse.Namespace) -> None: + preflight( + base=args.base, + source=args.source, + test_cmd=args.test_cmd, + skip_tests=args.skip_tests, + skip_merge_check=args.skip_merge_check, + allow_source_behind_base=args.allow_source_behind_base, + confirm_source_behind_base=args.confirm_source_behind_base, + allow_recordkeeping_tracked=args.allow_recordkeeping_tracked, + ) + + +def cmd_init_plan(args: argparse.Namespace) -> None: + test_cmd = str(args.test_cmd or "").strip() + if not test_cmd: + _print_discovered_test_command() + init_plan( + plan_path=Path(args.plan), + base=args.base, + source=args.source, + title=args.title, + changesets=args.changesets, + test_cmd=test_cmd, + force=args.force, + ) + print(f"[OK] Wrote plan template: {args.plan}") + + +def cmd_validate(args: argparse.Namespace) -> None: + plan = load_plan(Path(args.plan)) + valid, errors = validate_plan(plan) + if not valid: + raise CommandError("Plan is invalid: " + "; ".join(errors)) + if args.strict: + strict_ok, strict_errors, strict_warnings = validate_plan_strict(plan) + for warning in strict_warnings: + print(f"[WARN] {warning}") + if not strict_ok: + raise CommandError( + "Strict plan validation failed: " + "; ".join(strict_errors) + ) + strict_apply_check(plan) + print("[OK] Strict validation passed.") + return + print("[OK] Plan validation passed.") + + +def cmd_status(args: argparse.Namespace) -> None: + plan = load_and_validate(Path(args.plan)) + pull_requests = ( + [] if args.local_only else pull_requests_for_source(plan["source_branch"]) + ) + print( + status_from_live( + source_branch=plan["source_branch"], + base_branch=plan["base_branch"], + pull_requests=pull_requests, + remote=args.remote, + ) + ) + + +def cmd_create_chain(args: argparse.Namespace) -> None: + create_chain(load_and_validate(Path(args.plan))) + + +def cmd_compare(args: argparse.Namespace) -> None: + diffstat, names = compare_chain(load_and_validate(Path(args.plan))) + print("[INFO] Diffstat vs source branch:") + print(diffstat or "[OK] No diffstat differences detected.") + print("[INFO] Name-status vs source branch:") + print(names or "[OK] No name-status differences detected.") + + +def cmd_validate_chain(args: argparse.Namespace) -> None: + plan = load_and_validate(Path(args.plan)) + test_cmd = str(args.test_cmd or plan.get("test_command", "")).strip() + validate_chain(plan, test_cmd=test_cmd) + pull_requests = ( + [] if args.local_only else pull_requests_for_source(plan["source_branch"]) + ) + chain = rehydrate_chain( + source_branch=plan["source_branch"], + base_branch=plan["base_branch"], + pull_requests=pull_requests, + remote=args.remote, + ) + result = validate_live_chain(chain, remote=args.remote) + for diagnostic in result.diagnostics: + print( + f"[{diagnostic.severity.upper()}] {diagnostic.code}: {diagnostic.message}" + ) + if not result.valid: + raise CommandError("Live chain validation failed.") + print("[OK] Live chain ancestry and source equivalence passed.") + + +def cmd_pr_create(args: argparse.Namespace) -> None: + plan = load_and_validate(Path(args.plan)) + total = len(plan["changesets"]) + indices: List[int] = ( + list(range(1, total + 1)) if args.index is None else [args.index] + ) + pr_create(plan, indices=indices, dry_run=args.dry_run) + + +def cmd_push_chain(args: argparse.Namespace) -> None: + push_chain( + load_and_validate(Path(args.plan)), + remote=args.remote, + dry_run=args.dry_run, + ) + + +def cmd_db_compare(args: argparse.Namespace) -> None: + db_compare( + load_and_validate(Path(args.plan)), + source_cmd=args.source_cmd, + chain_cmd=args.chain_cmd, + out_dir=Path(args.out_dir), + ) + + +def cmd_hunk_preview(args: argparse.Namespace) -> None: + base = args.base + source = args.source + plan_path = Path(args.plan) + if plan_path.exists() and (not base or not source): + plan = load_plan(plan_path) + base = base or plan.get("base_branch", "") + source = source or plan.get("source_branch", "") + if not base or not source: + raise CommandError("hunk-preview requires --base and --source or a plan.") + matches = [ + item + for item in build_diff(base, source) + if args.file in (item.new_path, item.old_path) + ] + if not matches: + raise CommandError(f"No diff hunks found for file: {args.file}") + for item in matches: + print(f"[FILE] {item.new_path or item.old_path}") + for index, hunk in enumerate(item.hunks, start=1): + if args.contains and not all( + value in hunk.body_text for value in args.contains + ): + continue + if args.excludes and any( + value in hunk.body_text for value in args.excludes + ): + continue + print(f"[HUNK {index}] {hunk.header}") + print("\n".join(hunk.lines[1:])) + + +def cmd_squash_ref(args: argparse.Namespace) -> None: + base, source = _resolve_base_source( + plan_path=Path(args.plan), base=args.base, source=args.source + ) + create_squashed_ref( + base=base, + source=source, + reuse_existing=args.reuse_existing, + recreate=args.recreate, + ) + + +def cmd_squash_check(args: argparse.Namespace) -> None: + diffstat, names = squash_check(load_and_validate(Path(args.plan))) + print("[INFO] Diffstat vs chain tip after squash-check rebase:") + print(diffstat or "[OK] No diffstat differences detected.") + print("[INFO] Name-status vs chain tip after squash-check rebase:") + print(names or "[OK] No name-status differences detected.") + + +def cmd_run(args: argparse.Namespace) -> None: + cmd_preflight(args) + plan_path = Path(args.plan) + if not plan_path.exists() or args.force_init: + args.force = args.force or args.force_init + cmd_init_plan(args) + if args.create_chain: + create_chain(load_and_validate(plan_path)) + else: + print("[NEXT] Review the plan, then run create-chain.") + + +def _command(subparsers, name: str, help_text: str) -> argparse.ArgumentParser: + mutation_class = COMMAND_MUTATION_CLASSES[name] + parser = subparsers.add_parser( + name, + help=f"[{mutation_class}] {help_text}", + description=f"Mutation class: {mutation_class}. {help_text}", + ) + parser.set_defaults(mutation_class=mutation_class) + return parser + + +def _add_plan(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") + + +def _add_preflight_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--base", required=True, help="Base branch") + parser.add_argument("--source", required=True, help="Source branch") + parser.add_argument( + "--test-cmd", default="", help="Explicitly approved test command" + ) + parser.add_argument("--skip-tests", action="store_true") + parser.add_argument("--skip-merge-check", action="store_true") + parser.add_argument("--allow-source-behind-base", action="store_true") + parser.add_argument("--confirm-source-behind-base", action="store_true") + parser.add_argument("--allow-recordkeeping-tracked", action="store_true") + + +def _add_remote_dry_run(parser: argparse.ArgumentParser) -> None: + group = parser.add_mutually_exclusive_group() + group.add_argument("--dry-run", dest="dry_run", action="store_true") + group.add_argument("--no-dry-run", dest="dry_run", action="store_false") + parser.set_defaults(dry_run=True) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Carve a review-ready source branch into intentional changesets.", + epilog="Mutation classes are shown beside every operation; remote mutation is dry-run by default.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + item = _command( + sub, "preflight", "Validate source/base readiness and approved tests." + ) + _add_preflight_options(item) + item.set_defaults(func=cmd_preflight) + + item = _command(sub, "init-plan", "Create an ephemeral decomposition plan.") + _add_plan(item) + item.add_argument("--base", required=True) + item.add_argument("--source", required=True) + item.add_argument("--title", required=True) + item.add_argument("--changesets", type=int, default=3) + item.add_argument("--test-cmd", default="") + item.add_argument("--force", action="store_true") + item.set_defaults(func=cmd_init_plan) + + item = _command(sub, "validate", "Validate the decomposition plan.") + _add_plan(item) + item.add_argument("--strict", action="store_true") + item.set_defaults(func=cmd_validate) + + item = _command(sub, "status", "Render chain status from live git and GitHub.") + _add_plan(item) + item.add_argument("--remote", default="origin") + item.add_argument("--local-only", action="store_true") + item.set_defaults(func=cmd_status) + + item = _command(sub, "create-chain", "Materialize append-only changeset branches.") + _add_plan(item) + item.set_defaults(func=cmd_create_chain) + + item = _command(sub, "compare", "Compare reconstructed chain output with source.") + _add_plan(item) + item.set_defaults(func=cmd_compare) + + item = _command( + sub, "validate-chain", "Run approved step tests and live chain validation." + ) + _add_plan(item) + item.add_argument("--test-cmd", default="") + item.add_argument("--remote", default="origin") + item.add_argument("--local-only", action="store_true") + item.set_defaults(func=cmd_validate_chain) + + item = _command(sub, "pr-create", "Publish correctly based changeset PRs.") + _add_plan(item) + item.add_argument("--index", type=int) + _add_remote_dry_run(item) + item.set_defaults(func=cmd_pr_create) + + item = _command(sub, "push-chain", "Push changeset branches with exact leases.") + _add_plan(item) + item.add_argument("--remote", default="origin") + _add_remote_dry_run(item) + item.set_defaults(func=cmd_push_chain) + + item = _command(sub, "db-compare", "Compare source and chain database schemas.") + _add_plan(item) + item.add_argument("--source-cmd", required=True) + item.add_argument("--chain-cmd", required=True) + item.add_argument("--out-dir", default=str(DEFAULT_PLAN_PATH.parent / "db-compare")) + item.set_defaults(func=cmd_db_compare) + + item = _command(sub, "hunk-preview", "Preview explicit textual hunk selectors.") + _add_plan(item) + item.add_argument("--base", default="") + item.add_argument("--source", default="") + item.add_argument("--file", required=True) + item.add_argument("--contains", action="append", default=[]) + item.add_argument("--excludes", action="append", default=[]) + item.set_defaults(func=cmd_hunk_preview) + + item = _command(sub, "squash-ref", "Create a local-only squashed source reference.") + _add_plan(item) + item.add_argument("--base", default="") + item.add_argument("--source", default="") + item.add_argument("--reuse-existing", action="store_true") + item.add_argument("--recreate", action="store_true") + item.set_defaults(func=cmd_squash_ref) + + item = _command( + sub, "squash-check", "Compare a squashed source against the chain tip." + ) + _add_plan(item) + item.set_defaults(func=cmd_squash_check) + + item = _command( + sub, "run", "Preflight, initialize a plan, and optionally materialize it." + ) + _add_preflight_options(item) + _add_plan(item) + item.add_argument("--title", required=True) + item.add_argument("--changesets", type=int, default=3) + item.add_argument("--force", action="store_true") + item.add_argument("--force-init", action="store_true") + item.add_argument("--create-chain", action="store_true") + item.set_defaults(func=cmd_run) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + args.func(args) + if args.mutation_class != READ_ONLY: + ensure_clean_tree() + return 0 + except (CommandError, RehydrationError) as exc: + print(f"[ERROR] {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/carve-changesets/scripts/common.py b/skills/carve-changesets/scripts/common.py new file mode 100644 index 0000000..8cb1bfe --- /dev/null +++ b/skills/carve-changesets/scripts/common.py @@ -0,0 +1,601 @@ +"""Shared helpers for carve-changesets scripts.""" + +from __future__ import annotations + +import datetime as _dt +import json +import re +import subprocess +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple + +DEFAULT_PLAN_PATH = Path(".carve-changesets/plan.json") + + +class CommandError(RuntimeError): + """Raised when a subprocess or git command fails.""" + + +TEST_COMMAND_HINTS: Tuple[str, ...] = ( + "just test", + "make test", + "pytest", + "python -m pytest", + "npm test", + "pnpm test", + "yarn test", + "bun test", + "go test", + "cargo test", + "tox", + "nox", +) + + +def run( + cmd: Sequence[str], *, capture: bool = True, check: bool = True +) -> subprocess.CompletedProcess: + """Run a command and return the completed process.""" + try: + result = subprocess.run( + list(cmd), + text=True, + capture_output=capture, + check=False, + ) + except FileNotFoundError as exc: + raise CommandError(f"Command not found: {cmd[0]}") from exc + + if check and result.returncode != 0: + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + detail = stderr or stdout or f"exit code {result.returncode}" + raise CommandError(f"Command failed: {' '.join(cmd)}\n{detail}") + return result + + +def git( + *args: str, capture: bool = True, check: bool = True +) -> subprocess.CompletedProcess: + """Run a git command.""" + return run(("git",) + args, capture=capture, check=check) + + +def ensure_git_repo() -> None: + git("rev-parse", "--is-inside-work-tree") + + +def repo_root() -> Path: + return Path(git("rev-parse", "--show-toplevel").stdout.strip()) + + +def ensure_clean_tree() -> None: + status = git( + "status", + "--porcelain", + "--", + ".", + ":(exclude).carve-changesets", + ).stdout.strip() + if status: + raise CommandError( + "Working tree is not clean. Commit, stash, or discard changes first." + ) + + +def is_path_ignored(path: Path | str) -> bool: + raw = path.as_posix() if isinstance(path, Path) else str(path) + candidates = {raw, raw.rstrip("/")} + for candidate in sorted(candidates): + if not candidate: + continue + result = git( + "check-ignore", + "-q", + "--", + candidate, + capture=False, + check=False, + ) + if result.returncode == 0: + return True + return False + + +def branch_exists(name: str) -> bool: + result = git("rev-parse", "--verify", name, capture=True, check=False) + return result.returncode == 0 + + +def current_branch() -> str: + return git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() + + +def merge_base(base: str, source: str) -> str: + return git("merge-base", base, source).stdout.strip() + + +def compute_freshness(base: str, source: str) -> Dict[str, object]: + base_head = git("rev-parse", base).stdout.strip() + source_head = git("rev-parse", source).stdout.strip() + mb = merge_base(base, source) + return { + "base_head": base_head, + "source_head": source_head, + "merge_base": mb, + "source_behind_base": mb != base_head, + } + + +def diff_name_status(base: str, source: str) -> str: + return git("diff", "--name-status", f"{base}..{source}").stdout.strip() + + +def diff_stat(base: str, source: str) -> str: + return git("diff", "--stat", f"{base}..{source}").stdout.strip() + + +def unique_temp_branch(prefix: str) -> str: + ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") + return f"{prefix}-{ts}" + + +def delete_branch(name: str) -> None: + git("branch", "-D", name, check=False) + + +@contextmanager +def message_file(message: str): + """Yield a temporary file containing a commit or GitHub message.""" + + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", prefix="carve-message-" + ) as handle: + handle.write(message.rstrip() + "\n") + handle.flush() + yield handle.name + + +def commit_with_message(message: str) -> None: + """Commit staged changes using the repository-required file interface.""" + + with message_file(message) as path: + git("commit", "-F", path) + + +def ensure_branches_exist(branches: Iterable[str]) -> None: + missing = [b for b in branches if not branch_exists(b)] + if missing: + raise CommandError("Missing branch(es):\n" + "\n".join(missing)) + + +def branch_name_for(source_branch: str, index: int) -> str: + return f"{source_branch}-{index}" + + +def base_for_changeset(base_branch: str, source_branch: str, index: int) -> str: + if index <= 1: + return base_branch + return branch_name_for(source_branch, index - 1) + + +def squashed_branch_name(source_branch: str) -> str: + return f"{source_branch}-squashed" + + +@contextmanager +def checkout_restore(target: Optional[str] = None): + """Checkout target branch (if provided) and always restore the original branch.""" + original = current_branch() + try: + if target and target != original: + git("checkout", target) + yield original + finally: + if current_branch() != original: + git("checkout", original) + + +def load_plan(path: Path) -> Dict: + try: + return json.loads(path.read_text()) + except FileNotFoundError as exc: + raise CommandError(f"Plan file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise CommandError(f"Invalid JSON in plan file {path}: {exc}") from exc + + +def validate_plan(plan: Dict) -> Tuple[bool, List[str]]: + errors: List[str] = [] + + def require_string(key: str) -> None: + if ( + key not in plan + or not isinstance(plan.get(key), str) + or not str(plan[key]).strip() + ): + errors.append(f"Missing or invalid string field: {key}") + + require_string("feature_title") + require_string("base_branch") + require_string("source_branch") + + changesets = plan.get("changesets") + if not isinstance(changesets, list) or not changesets: + errors.append("Plan must include a non-empty 'changesets' array.") + return False, errors + + for idx, cs in enumerate(changesets, start=1): + if not isinstance(cs, dict): + errors.append(f"Changeset {idx} must be an object.") + continue + + for key in ("slug", "description"): + if key not in cs: + errors.append(f"Changeset {idx} missing required field: {key}") + + if "slug" in cs and (not isinstance(cs["slug"], str) or not cs["slug"].strip()): + errors.append(f"Changeset {idx} has invalid slug.") + if "description" in cs and ( + not isinstance(cs["description"], str) or not cs["description"].strip() + ): + errors.append(f"Changeset {idx} has invalid description.") + + mode = str(cs.get("mode", "paths")).strip() or "paths" + if mode not in ("paths", "patch", "hunks"): + errors.append( + f"Changeset {idx} has invalid mode '{mode}'. Use 'paths', 'patch', or 'hunks'." + ) + + include = cs.get("include_paths", []) + if include and ( + not isinstance(include, list) + or not all(isinstance(p, str) for p in include) + ): + errors.append( + f"Changeset {idx} include_paths must be a string array when provided." + ) + if mode == "paths": + if not isinstance(include, list) or not include: + errors.append( + f"Changeset {idx} include_paths must be a non-empty string array for mode=paths." + ) + + if mode == "patch": + patch_file = cs.get("patch_file") + if not isinstance(patch_file, str) or not patch_file.strip(): + errors.append( + f"Changeset {idx} patch_file must be a non-empty string for mode=patch." + ) + + if mode == "hunks": + selectors = cs.get("hunk_selectors") + if ( + not isinstance(selectors, list) + or not selectors + or not all(isinstance(s, dict) for s in selectors) + ): + errors.append( + f"Changeset {idx} hunk_selectors must be a non-empty array for mode=hunks." + ) + + exclude = cs.get("exclude_paths", []) + if exclude and ( + not isinstance(exclude, list) + or not all(isinstance(p, str) for p in exclude) + ): + errors.append( + f"Changeset {idx} exclude_paths must be a string array when provided." + ) + + allow_partial = cs.get("allow_partial_files") + if allow_partial is not None and not isinstance(allow_partial, bool): + errors.append( + f"Changeset {idx} allow_partial_files must be a boolean when provided." + ) + + pr_notes = cs.get("pr_notes", []) + if pr_notes and ( + not isinstance(pr_notes, list) + or not all(isinstance(p, str) for p in pr_notes) + ): + errors.append( + f"Changeset {idx} pr_notes must be a string array when provided." + ) + + return (not errors), errors + + +def default_changeset(index: int) -> Dict: + return { + "mode": "paths", + "slug": f"changeset-{index}", + "description": f"Describe the intent for changeset {index}.", + "include_paths": ["src/**"], + "exclude_paths": [], + "allow_partial_files": True, + "commit_message": f"changeset: placeholder {index}", + "pr_notes": ["Replace with PR notes for this changeset."], + } + + +def init_plan( + *, + plan_path: Path, + base: str, + source: str, + title: str, + changesets: int, + test_cmd: str, + force: bool, +) -> None: + plan_path.parent.mkdir(parents=True, exist_ok=True) + + if plan_path.exists() and not force: + raise CommandError( + f"Plan already exists: {plan_path}. Use --force to overwrite." + ) + + plan = { + "feature_title": title, + "base_branch": base, + "source_branch": source, + "test_command": test_cmd or "", + "changesets": [default_changeset(i) for i in range(1, changesets + 1)], + } + + plan_path.write_text(json.dumps(plan, indent=2) + "\n") + + +def _is_test_command(cmd: str) -> bool: + lower = cmd.lower() + if any(hint in lower for hint in TEST_COMMAND_HINTS): + return True + # Catch generic patterns like "just test-foo" or "make test-all". + return bool(re.search(r"\b(test|pytest)\b", lower)) + + +def _extract_code_blocks(text: str) -> List[str]: + # Keep this permissive; we are mining for likely commands, not parsing Markdown. + pattern = re.compile(r"```[a-zA-Z0-9_-]*\n(.*?)```", re.DOTALL) + return [m.group(1) for m in pattern.finditer(text)] + + +def _commands_from_block(block: str) -> List[str]: + commands: List[str] = [] + for raw_line in block.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if re.match(r"^[A-Za-z_][A-Za-z0-9_-]*:", line): + continue + if re.match(r"^\"[A-Za-z0-9_-]+\":", line): + continue + if re.match(r"^'[A-Za-z0-9_-]+':", line): + continue + # Treat each non-empty line as a potential command. + commands.append(line) + return commands + + +def parse_agents_test_commands(path: Path) -> List[str]: + try: + text = path.read_text() + except FileNotFoundError: + return [] + + candidates: List[str] = [] + seen: Set[str] = set() + for block in _extract_code_blocks(text): + for cmd in _commands_from_block(block): + if not _is_test_command(cmd): + continue + if cmd not in seen: + seen.add(cmd) + candidates.append(cmd) + return candidates + + +def _add_suggestion(cmd: str, suggestions: List[str], seen: Set[str]) -> None: + clean = cmd.strip() + if not clean or clean in seen: + return + seen.add(clean) + suggestions.append(clean) + + +def _suggest_from_justfile(root: Path, suggestions: List[str], seen: Set[str]) -> None: + for name in ("justfile", "Justfile"): + path = root / name + if not path.exists(): + continue + text = path.read_text() + if re.search(r"(?m)^test\s*:", text): + _add_suggestion("just test", suggestions, seen) + + +def _suggest_from_makefile(root: Path, suggestions: List[str], seen: Set[str]) -> None: + for name in ("Makefile", "makefile"): + path = root / name + if not path.exists(): + continue + text = path.read_text() + if re.search(r"(?m)^test\s*:", text): + _add_suggestion("make test", suggestions, seen) + + +def _suggest_from_package_json( + root: Path, suggestions: List[str], seen: Set[str] +) -> None: + path = root / "package.json" + if not path.exists(): + return + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError: + return + + scripts = data.get("scripts") if isinstance(data, dict) else None + test_script = scripts.get("test") if isinstance(scripts, dict) else None + if not isinstance(test_script, str) or not test_script.strip(): + return + + if (root / "pnpm-lock.yaml").exists(): + tool_cmd = "pnpm test" + elif (root / "yarn.lock").exists(): + tool_cmd = "yarn test" + elif (root / "bun.lockb").exists(): + tool_cmd = "bun test" + else: + tool_cmd = "npm test" + + _add_suggestion(tool_cmd, suggestions, seen) + # The project's declared test script is authoritative even if it does not + # contain the word "test" (for example, "vitest run"). + _add_suggestion(test_script, suggestions, seen) + + +def _suggest_from_python_project( + root: Path, suggestions: List[str], seen: Set[str] +) -> None: + pyproject = root / "pyproject.toml" + setup_cfg = root / "setup.cfg" + tests_dir = root / "tests" + + pyproject_text = pyproject.read_text() if pyproject.exists() else "" + setup_cfg_text = setup_cfg.read_text() if setup_cfg.exists() else "" + + if "[tool.pytest" in pyproject_text or "pytest" in pyproject_text: + _add_suggestion("python -m pytest", suggestions, seen) + return + if "[tool:pytest]" in setup_cfg_text or "pytest" in setup_cfg_text: + _add_suggestion("python -m pytest", suggestions, seen) + return + + if tests_dir.exists() and any(p.suffix == ".py" for p in tests_dir.rglob("*.py")): + _add_suggestion("python -m pytest", suggestions, seen) + + +def _suggest_from_other_roots( + root: Path, suggestions: List[str], seen: Set[str] +) -> None: + if (root / "tox.ini").exists(): + _add_suggestion("tox", suggestions, seen) + if (root / "noxfile.py").exists(): + _add_suggestion("nox", suggestions, seen) + if (root / "go.mod").exists(): + _add_suggestion("go test ./...", suggestions, seen) + if (root / "Cargo.toml").exists(): + _add_suggestion("cargo test", suggestions, seen) + + +def _suggest_from_workflows(root: Path, suggestions: List[str], seen: Set[str]) -> None: + workflows_dir = root / ".github" / "workflows" + if not workflows_dir.exists(): + return + + workflow_paths = sorted( + [ + *workflows_dir.glob("*.yml"), + *workflows_dir.glob("*.yaml"), + ] + ) + run_line = re.compile(r"^\s*run:\s*(.*)$") + + for path in workflow_paths: + lines = path.read_text().splitlines() + i = 0 + while i < len(lines): + line = lines[i] + m = run_line.match(line) + if not m: + i += 1 + continue + + rest = m.group(1).strip() + if rest and rest not in ("|", ">"): + if _is_test_command(rest): + _add_suggestion(rest, suggestions, seen) + i += 1 + continue + + # Handle multi-line run blocks. + base_indent = len(line) - len(line.lstrip(" ")) + i += 1 + while i < len(lines): + block_line = lines[i] + indent = len(block_line) - len(block_line.lstrip(" ")) + if indent <= base_indent: + break + cmd = block_line.strip() + if cmd and _is_test_command(cmd): + _add_suggestion(cmd, suggestions, seen) + i += 1 + + +def suggest_test_commands(root: Path, *, prior: Sequence[str] = ()) -> List[str]: + suggestions: List[str] = [] + seen: Set[str] = set() + + for cmd in prior: + _add_suggestion(cmd, suggestions, seen) + + _suggest_from_justfile(root, suggestions, seen) + _suggest_from_makefile(root, suggestions, seen) + _suggest_from_package_json(root, suggestions, seen) + _suggest_from_python_project(root, suggestions, seen) + _suggest_from_other_roots(root, suggestions, seen) + _suggest_from_workflows(root, suggestions, seen) + + return suggestions + + +def discover_test_command(preferred: str = "") -> Dict[str, object]: + """Discover a repo-specific test command and return diagnostics. + + The return object always includes: + - command: Optional[str] + - source: str + - reason: str + - candidates: List[str] + - suggestions: List[str] + """ + if preferred.strip(): + return { + "command": preferred.strip(), + "source": "cli", + "reason": "provided", + "candidates": [], + "suggestions": [], + } + + root = repo_root() + agents_path = root / "AGENTS.md" + agents_candidates = parse_agents_test_commands(agents_path) + + if agents_candidates and len(agents_candidates) == 1: + return { + "command": agents_candidates[0], + "source": "agents", + "reason": "agents-single", + "candidates": agents_candidates, + "suggestions": agents_candidates, + } + + if not agents_path.exists(): + reason = "agents-missing" + elif not agents_candidates: + reason = "agents-no-test-command" + else: + reason = "agents-ambiguous" + + suggestions = suggest_test_commands(root, prior=agents_candidates) + return { + "command": None, + "source": "none", + "reason": reason, + "candidates": agents_candidates, + "suggestions": suggestions, + } diff --git a/skills/carve-changesets/scripts/db_compare.py b/skills/carve-changesets/scripts/db_compare.py new file mode 100755 index 0000000..66236e0 --- /dev/null +++ b/skills/carve-changesets/scripts/db_compare.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Database/schema equivalence comparison hooks.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Dict + +from common import ( + CommandError, + branch_name_for, + checkout_restore, + delete_branch, + ensure_branches_exist, + ensure_clean_tree, + ensure_git_repo, + git, + unique_temp_branch, +) + + +def run_capture(command: str, outfile: Path) -> None: + result = subprocess.run(command, shell=True, text=True, capture_output=True) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + raise CommandError(f"Command failed: {command}\n{detail}") + outfile.write_text(result.stdout) + + +def db_compare(plan: Dict, *, source_cmd: str, chain_cmd: str, out_dir: Path) -> None: + if not source_cmd.strip() or not chain_cmd.strip(): + raise CommandError("db-compare requires both --source-cmd and --chain-cmd.") + + ensure_git_repo() + ensure_clean_tree() + + base = plan["base_branch"] + source = plan["source_branch"] + total = len(plan["changesets"]) + chain = [branch_name_for(source, i) for i in range(1, total + 1)] + ensure_branches_exist([base, source, *chain]) + + out_dir.mkdir(parents=True, exist_ok=True) + source_out = out_dir / "source.txt" + chain_out = out_dir / "chain.txt" + + temp_branch = unique_temp_branch("carve-temp-dbcompare") + print(f"[INFO] Using output directory: {out_dir}") + print(f"[INFO] Creating temporary branch: {temp_branch}") + + with checkout_restore() as original: + try: + git("checkout", source) + print(f"[STEP] Running source command on {source}") + run_capture(source_cmd, source_out) + + git("checkout", "-B", temp_branch, base) + for name in chain: + print(f"[STEP] Merging {name} into {temp_branch}") + git("merge", "--no-ff", "--no-edit", name) + + print(f"[STEP] Running chain command on {temp_branch}") + run_capture(chain_cmd, chain_out) + + print("[STEP] Diffing outputs (git diff --no-index)") + diff = git( + "diff", "--no-index", "--", str(source_out), str(chain_out), check=False + ) + if diff.returncode == 0: + print("[OK] No differences detected.") + else: + print(diff.stdout.strip() or "[WARN] Differences detected.") + finally: + git("checkout", original) + delete_branch(temp_branch) + + ensure_clean_tree() + print("[OK] db-compare completed.") diff --git a/skills/carve-changesets/scripts/evals/__init__.py b/skills/carve-changesets/scripts/evals/__init__.py new file mode 100644 index 0000000..ec9b194 --- /dev/null +++ b/skills/carve-changesets/scripts/evals/__init__.py @@ -0,0 +1 @@ +"""Local eval helpers for carve-changesets.""" diff --git a/skills/carve-changesets/scripts/evals/grader.py b/skills/carve-changesets/scripts/evals/grader.py new file mode 100644 index 0000000..1351e6d --- /dev/null +++ b/skills/carve-changesets/scripts/evals/grader.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Deterministic grader for carve-changesets eval runs.""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Dict, List + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from chain import compare_chain, create_chain, validate_chain # noqa: E402 +from common import ( # noqa: E402 + DEFAULT_PLAN_PATH, + CommandError, + branch_name_for, + ensure_clean_tree, + git, + load_plan, + validate_plan, +) + + +@dataclass +class GradeResult: + ok: bool + checks: List[str] + failures: List[str] + + +def _require_clean_tree(checks: List[str], failures: List[str]) -> None: + try: + ensure_clean_tree() + checks.append("clean_tree") + except CommandError as exc: + failures.append(f"clean_tree: {exc}") + + +def _validate_plan(plan: Dict, checks: List[str], failures: List[str]) -> None: + valid, errors = validate_plan(plan) + if valid: + checks.append("plan_valid") + else: + failures.append("plan_valid: " + "; ".join(errors)) + + +def _check_source_hash( + source_branch: str, expected: str, checks: List[str], failures: List[str] +) -> None: + current = git("rev-parse", source_branch).stdout.strip() + if current == expected: + checks.append("source_hash_unchanged") + else: + failures.append(f"source_hash_unchanged: expected {expected}, got {current}") + + +def _check_chain_exists(plan: Dict, checks: List[str], failures: List[str]) -> None: + source = plan["source_branch"] + total = len(plan["changesets"]) + missing: List[str] = [] + for i in range(1, total + 1): + name = branch_name_for(source, i) + if git("rev-parse", "--verify", name, check=False).returncode != 0: + missing.append(name) + if missing: + failures.append("chain_exists: missing " + ", ".join(missing)) + else: + checks.append("chain_exists") + + +def _check_equivalence(plan: Dict, checks: List[str], failures: List[str]) -> None: + try: + diffstat, namestatus = compare_chain(plan) + except CommandError as exc: + failures.append(f"compare_chain: {exc}") + return + + if diffstat.strip() or namestatus.strip(): + failures.append("equivalence: differences detected") + else: + checks.append("equivalence") + + +def _check_validate_chain( + plan: Dict, test_cmd: str, checks: List[str], failures: List[str] +) -> None: + try: + validate_chain(plan, test_cmd=test_cmd) + checks.append("validate_chain") + except CommandError as exc: + failures.append(f"validate_chain: {exc}") + + +def grade_repo( + *, + plan_path: Path, + expected_source_hash: str, + test_cmd: str, + auto_create_chain: bool, +) -> GradeResult: + checks: List[str] = [] + failures: List[str] = [] + + _require_clean_tree(checks, failures) + + try: + plan = load_plan(plan_path) + except CommandError as exc: + failures.append(f"load_plan: {exc}") + return GradeResult(ok=False, checks=checks, failures=failures) + + _validate_plan(plan, checks, failures) + _check_source_hash(plan["source_branch"], expected_source_hash, checks, failures) + + if auto_create_chain: + try: + create_chain(plan) + checks.append("create_chain") + except CommandError as exc: + failures.append(f"create_chain: {exc}") + + _check_chain_exists(plan, checks, failures) + _check_equivalence(plan, checks, failures) + _check_validate_chain(plan, test_cmd=test_cmd, checks=checks, failures=failures) + + return GradeResult(ok=not failures, checks=checks, failures=failures) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Grade a repo against carve-changesets invariants." + ) + parser.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") + parser.add_argument( + "--expected-source-hash", + required=True, + help="Expected source branch hash before the run", + ) + parser.add_argument( + "--test-cmd", + default="python3 -c \"print('ok')\"", + help="Test command for validate-chain", + ) + parser.add_argument( + "--auto-create-chain", + action="store_true", + help="Create the chain before grading (useful for deterministic baselines).", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit JSON result to stdout.", + ) + return parser + + +def main(argv: List[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + result = grade_repo( + plan_path=Path(args.plan), + expected_source_hash=args.expected_source_hash, + test_cmd=args.test_cmd, + auto_create_chain=args.auto_create_chain, + ) + + if args.json: + print(json.dumps(asdict(result), indent=2)) + else: + print("OK" if result.ok else "FAIL") + if result.checks: + print("checks: " + ", ".join(result.checks)) + if result.failures: + print("failures: " + "; ".join(result.failures)) + + return 0 if result.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/carve-changesets/scripts/evals/helpers.py b/skills/carve-changesets/scripts/evals/helpers.py new file mode 100644 index 0000000..25990a4 --- /dev/null +++ b/skills/carve-changesets/scripts/evals/helpers.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Helpers for local eval setup and grading.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Dict, Tuple + +from common import DEFAULT_PLAN_PATH + + +def run(cmd: list[str], *, cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=True) + + +def commit(cwd: Path, message: str) -> None: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as message_file: + message_file.write(message + "\n") + message_file.flush() + run(["git", "commit", "-F", message_file.name], cwd=cwd) + + +def write_plan(plan_path: Path, plan: Dict) -> None: + plan_path.parent.mkdir(parents=True, exist_ok=True) + plan_path.write_text(json.dumps(plan, indent=2) + "\n") + + +def init_eval_repo() -> Tuple[Path, Dict, str]: + """Create a temporary repo with a known base/source and plan.""" + repo_dir = Path(tempfile.mkdtemp(prefix="pcs-eval-repo-")) + + run(["git", "init", "-b", "main"], cwd=repo_dir) + run(["git", "config", "user.name", "PCS Eval"], cwd=repo_dir) + run(["git", "config", "user.email", "pcs-eval@example.com"], cwd=repo_dir) + + # Keep eval artifacts out of git status checks. + (repo_dir / ".gitignore").write_text(".carve-changesets/\n") + + (repo_dir / "a.txt").write_text("base-a\n") + (repo_dir / "b.txt").write_text("base-b\n") + run(["git", "add", "-A"], cwd=repo_dir) + commit(repo_dir, "base") + + run(["git", "checkout", "-b", "feature/test"], cwd=repo_dir) + (repo_dir / "a.txt").write_text("feature-a\n") + (repo_dir / "b.txt").write_text("feature-b\n") + (repo_dir / "c.txt").write_text("feature-c\n") + run(["git", "add", "-A"], cwd=repo_dir) + commit(repo_dir, "feature") + + plan: Dict = { + "feature_title": "Eval feature", + "base_branch": "main", + "source_branch": "feature/test", + "test_command": "python3 -c \"print('ok')\"", + "changesets": [ + { + "slug": "a-only", + "description": "Apply a.txt changes.", + "include_paths": ["a.txt"], + "exclude_paths": [], + "commit_message": "cs1", + "pr_notes": ["No behavior changes."], + }, + { + "slug": "rest", + "description": "Apply remaining changes.", + "include_paths": ["b.txt", "c.txt"], + "exclude_paths": [], + "commit_message": "cs2", + "pr_notes": ["Completes the feature."], + }, + ], + } + + plan_path = repo_dir / DEFAULT_PLAN_PATH + write_plan(plan_path, plan) + + source_hash = run( + ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir + ).stdout.strip() + return repo_dir, plan, source_hash + + +def cleanup_repo(repo_dir: Path) -> None: + shutil.rmtree(repo_dir) diff --git a/skills/carve-changesets/scripts/evals/prompts.csv b/skills/carve-changesets/scripts/evals/prompts.csv new file mode 100644 index 0000000..64a3d8d --- /dev/null +++ b/skills/carve-changesets/scripts/evals/prompts.csv @@ -0,0 +1,3 @@ +id,prompt +chain-basic,"You are in a git repo with base branch main, source branch feature/test, and a validated plan at .prepare-changesets/plan.json. Using the prepare-changesets skill, run preflight, create the changeset chain, validate mergeability with tests using python3 -c \"print('ok')\", and compare the fully merged chain to the source branch. Do not modify the source branch. Use scripts/*.py directly." +chain-compare,"Follow the prepare-changesets skill strictly. The plan is already validated at .prepare-changesets/plan.json for base main and source feature/test. Create the changeset chain, run validate-chain with python3 -c \"print('ok')\", and run compare. Keep the source branch immutable." diff --git a/skills/carve-changesets/scripts/evals/runner.py b/skills/carve-changesets/scripts/evals/runner.py new file mode 100755 index 0000000..69a54bb --- /dev/null +++ b/skills/carve-changesets/scripts/evals/runner.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Local eval runner that optionally invokes codex exec, then grades deterministically.""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Dict, List + +THIS_DIR = Path(__file__).resolve().parent +SCRIPTS_DIR = THIS_DIR.parents[0] +DEFAULT_PROMPTS_PATH = THIS_DIR / "prompts.csv" +DEFAULT_OUT_DIR = THIS_DIR / "out" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from common import DEFAULT_PLAN_PATH # noqa: E402 + +from evals.grader import GradeResult, grade_repo # noqa: E402 +from evals.helpers import cleanup_repo, init_eval_repo # noqa: E402 + + +def codex_available(codex_bin: str) -> bool: + return shutil.which(codex_bin) is not None + + +def run_codex(prompt: str, *, codex_bin: str, cwd: Path) -> subprocess.CompletedProcess: + cmd = [codex_bin, "exec", "--full-auto", "--json", prompt] + return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=False) + + +def load_prompts(path: Path) -> List[Dict[str, str]]: + with path.open(newline="") as f: + reader = csv.DictReader(f) + return [row for row in reader] + + +def run_eval_case( + *, + case_id: str, + prompt: str, + codex_bin: str, + skip_codex: bool, + test_cmd: str, + out_dir: Path, +) -> Dict: + repo_dir, plan, source_hash = init_eval_repo() + plan_path = repo_dir / DEFAULT_PLAN_PATH + + codex_result: Dict[str, str] = {} + original_cwd = Path.cwd() + try: + os.chdir(repo_dir) + if not skip_codex: + if not codex_available(codex_bin): + raise RuntimeError(f"codex binary not found on PATH: {codex_bin}") + result = run_codex(prompt, codex_bin=codex_bin, cwd=repo_dir) + codex_result = { + "returncode": str(result.returncode), + "stdout": result.stdout, + "stderr": result.stderr, + } + else: + codex_result = {"skipped": "true"} + + grade: GradeResult = grade_repo( + plan_path=plan_path, + expected_source_hash=source_hash, + test_cmd=test_cmd, + auto_create_chain=skip_codex, + ) + + case_out = { + "id": case_id, + "ok": grade.ok, + "checks": grade.checks, + "failures": grade.failures, + "codex": codex_result, + } + return case_out + finally: + os.chdir(original_cwd) + cleanup_repo(repo_dir) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run local evals for carve-changesets." + ) + parser.add_argument( + "--prompts", + default=str(DEFAULT_PROMPTS_PATH), + help="Prompts CSV path", + ) + parser.add_argument( + "--out-dir", + default=str(DEFAULT_OUT_DIR), + help="Directory to write eval results", + ) + parser.add_argument("--codex-bin", default="codex", help="codex executable name") + parser.add_argument( + "--skip-codex", + action="store_true", + help="Skip invoking codex and grade a deterministic baseline instead.", + ) + parser.add_argument( + "--test-cmd", + default="python3 -c \"print('ok')\"", + help="Test command used by the grader's validate-chain step.", + ) + return parser + + +def main(argv: List[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + prompts_path = Path(args.prompts) + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + cases = load_prompts(prompts_path) + results: List[Dict] = [] + + for row in cases: + case_id = row.get("id", "case") + prompt = row.get("prompt", "").strip() + if not prompt: + results.append( + {"id": case_id, "ok": False, "failures": ["empty prompt"], "checks": []} + ) + continue + try: + result = run_eval_case( + case_id=case_id, + prompt=prompt, + codex_bin=args.codex_bin, + skip_codex=args.skip_codex, + test_cmd=args.test_cmd, + out_dir=out_dir, + ) + except Exception as exc: # defensive guard for eval runs + result = { + "id": case_id, + "ok": False, + "checks": [], + "failures": [f"exception: {exc}"], + } + results.append(result) + + (out_dir / f"{case_id}.json").write_text(json.dumps(result, indent=2) + "\n") + + summary = { + "total": len(results), + "passed": sum(1 for r in results if r.get("ok")), + "failed": sum(1 for r in results if not r.get("ok")), + "results": results, + } + (out_dir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + + print(json.dumps(summary, indent=2)) + return 0 if summary["failed"] == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/carve-changesets/scripts/github.py b/skills/carve-changesets/scripts/github.py new file mode 100644 index 0000000..0a5245e --- /dev/null +++ b/skills/carve-changesets/scripts/github.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Single structured chokepoint for every carve-changesets ``gh`` call.""" + +from __future__ import annotations + +import json +import subprocess +from typing import Dict, Iterable, List, Sequence + +from common import ( + CommandError, + base_for_changeset, + branch_name_for, + ensure_clean_tree, + ensure_git_repo, + git, + message_file, +) +from metadata import embed_pr_metadata, parse_commit_message +from rehydrate import PullRequestRecord + + +def _format_error(command: Sequence[str], error: subprocess.CalledProcessError) -> str: + stdout = (error.stdout or "").strip() + stderr = (error.stderr or "").strip() + details = [f"GitHub CLI command failed: {' '.join(command)}"] + if stdout: + details.append(f"stdout: {stdout}") + if stderr: + details.append(f"stderr: {stderr}") + return "\n".join(details) + + +def gh_capture( + args: Sequence[str], *, allowed_returncodes: Iterable[int] = () +) -> tuple[str, str]: + """Run ``gh`` with list argv and return captured stdout and stderr.""" + + command = ["gh", *args] + try: + result = subprocess.run( + command, + text=True, + capture_output=True, + check=True, + ) + except FileNotFoundError as exc: + raise CommandError("GitHub CLI ('gh') not found on PATH.") from exc + except subprocess.CalledProcessError as exc: + if exc.returncode in set(allowed_returncodes): + return exc.stdout or "", exc.stderr or "" + raise CommandError(_format_error(command, exc)) from exc + return result.stdout, result.stderr or "" + + +def gh_json(args: Sequence[str], *, allowed_returncodes: Iterable[int] = ()) -> object: + """Run ``gh`` and decode one JSON response.""" + + stdout, _ = gh_capture(args, allowed_returncodes=allowed_returncodes) + if not stdout.strip(): + return None + try: + return json.loads(stdout) + except json.JSONDecodeError as exc: + raise CommandError( + f"Failed to parse JSON from GitHub CLI command: {' '.join(args)}" + ) from exc + + +def ensure_gh_ready() -> None: + gh_capture(("auth", "status")) + + +def pr_title_for(feature_title: str, index: int, total: int) -> str: + return f"{feature_title} ({index} of {total})" + + +def pr_body_for(plan: Dict, index: int, total: int, changeset: Dict) -> str: + notes = changeset.get("pr_notes", []) or [] + lines = [ + "## Overall Feature", + plan["feature_title"].strip(), + "", + f"## This Changeset ({index} of {total})", + str(changeset.get("description", "")).strip() + or "Describe the intent of this changeset.", + "", + "## Scaffolding, Flags, And Intentional Incompleteness", + ] + if notes and all(isinstance(note, str) and note.strip() for note in notes): + lines.extend(f"- {note.strip()}" for note in notes) + else: + lines.append("- None documented.") + branch = branch_name_for(plan["source_branch"], index) + message = git("show", "-s", "--format=%B", branch).stdout + return embed_pr_metadata( + "\n".join(lines).strip() + "\n", parse_commit_message(message) + ) + + +def _print_command(command: Sequence[str]) -> None: + print(" ".join(subprocess.list2cmdline([part]) for part in command)) + + +def pr_create(plan: Dict, *, indices: List[int], dry_run: bool) -> None: + ensure_git_repo() + ensure_clean_tree() + if not dry_run: + ensure_gh_ready() + + base = plan["base_branch"] + source = plan["source_branch"] + changesets = plan["changesets"] + total = len(changesets) + for index in indices: + if index < 1 or index > total: + raise CommandError(f"--index must be between 1 and {total}.") + head = branch_name_for(source, index) + pr_base = base_for_changeset(base, source, index) + title = pr_title_for(plan["feature_title"], index, total) + body = pr_body_for(plan, index, total, changesets[index - 1]) + with message_file(body) as body_path: + args = ( + "pr", + "create", + "--base", + pr_base, + "--head", + head, + "--title", + title, + "--body-file", + body_path, + ) + print(f"[STEP] PR for changeset {index}: {head} -> {pr_base}") + if dry_run: + print("[DRY-RUN] Would run:") + _print_command(("gh", *args)) + continue + gh_capture(args) + created = gh_json(("pr", "view", head, "--json", "number,url")) + if not isinstance(created, dict): + raise CommandError(f"PR for {head} was created but could not be verified.") + print(f"[OK] PR #{created['number']} created: {created['url']}") + + if dry_run: + print("[OK] Dry-run complete. Re-run with --no-dry-run to execute.") + + +def pull_requests_for_source(source_branch: str) -> list[PullRequestRecord]: + """Return complete GitHub PR evidence needed by live rehydration.""" + + payload = gh_json( + ( + "pr", + "list", + "--state", + "all", + "--limit", + "100", + "--json", + "number,headRefName,headRefOid,baseRefName,state,body", + ) + ) + if not isinstance(payload, list): + raise CommandError("Unexpected GitHub PR list response.") + prefix = f"{source_branch}-" + records: list[PullRequestRecord] = [] + for item in payload: + if not isinstance(item, dict): + raise CommandError("Unexpected GitHub PR record.") + head = str(item.get("headRefName") or "") + suffix = head.removeprefix(prefix) + if not head.startswith(prefix) or not suffix.isdigit() or int(suffix) < 1: + continue + records.append( + PullRequestRecord( + number=int(item["number"]), + head_branch=head, + head_sha=str(item.get("headRefOid") or ""), + base_branch=str(item.get("baseRefName") or ""), + state=str(item.get("state") or ""), + body=str(item.get("body") or ""), + ) + ) + return records diff --git a/skills/carve-changesets/scripts/patch_apply.py b/skills/carve-changesets/scripts/patch_apply.py new file mode 100755 index 0000000..21d4c78 --- /dev/null +++ b/skills/carve-changesets/scripts/patch_apply.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +"""Patch and hunk-based changeset application helpers.""" + +from __future__ import annotations + +import fnmatch +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from common import CommandError, git, repo_root + + +@dataclass(frozen=True) +class HunkSelector: + file: str + range_header: Optional[str] + contains: Tuple[str, ...] + excludes: Tuple[str, ...] + occurrence: Optional[int] + all_hunks: bool + has_filters: bool + + +@dataclass +class Hunk: + header: str + lines: List[str] + + @property + def body_lines(self) -> List[str]: + return self.lines[1:] + + @property + def body_text(self) -> str: + return "\n".join(self.body_lines) + + +@dataclass +class DiffFile: + old_path: Optional[str] + new_path: Optional[str] + header_lines: List[str] + hunks: List[Hunk] = field(default_factory=list) + is_binary: bool = False + binary_lines: List[str] = field(default_factory=list) + + +@dataclass +class SelectedPatch: + text: str + files: int + hunks: int + file_labels: List[str] + + +def _strip_prefix(path: str) -> Optional[str]: + if path in ("/dev/null", "dev/null"): + return None + if path.startswith("a/") or path.startswith("b/"): + return path[2:] + return path + + +def _file_label(df: DiffFile) -> str: + return df.new_path or df.old_path or "" + + +def _matches_any(path: str, patterns: Iterable[str]) -> bool: + return any(fnmatch.fnmatch(path, pat) for pat in patterns) + + +def parse_hunk_selectors( + selectors: Sequence[Dict], *, changeset_label: str +) -> List[HunkSelector]: + parsed: List[HunkSelector] = [] + for idx, raw in enumerate(selectors, start=1): + if not isinstance(raw, dict): + raise CommandError( + f"{changeset_label}: hunk selector {idx} must be an object." + ) + file_path = raw.get("file") + if not isinstance(file_path, str) or not file_path.strip(): + raise CommandError( + f"{changeset_label}: hunk selector {idx} missing valid 'file'." + ) + range_header = raw.get("range") + if range_header is not None and not isinstance(range_header, str): + raise CommandError( + f"{changeset_label}: hunk selector {idx} 'range' must be a string." + ) + contains = raw.get("contains", []) + excludes = raw.get("excludes", []) + if contains and ( + not isinstance(contains, list) + or not all(isinstance(c, str) for c in contains) + ): + raise CommandError( + f"{changeset_label}: hunk selector {idx} 'contains' must be a string array." + ) + if excludes and ( + not isinstance(excludes, list) + or not all(isinstance(c, str) for c in excludes) + ): + raise CommandError( + f"{changeset_label}: hunk selector {idx} 'excludes' must be a string array." + ) + all_hunks = raw.get("all", False) + if all_hunks is not False and not isinstance(all_hunks, bool): + raise CommandError( + f"{changeset_label}: hunk selector {idx} 'all' must be a boolean." + ) + + occurrence = raw.get("occurrence") + if occurrence is not None and ( + not isinstance(occurrence, int) or occurrence < 1 + ): + raise CommandError( + f"{changeset_label}: hunk selector {idx} 'occurrence' must be a positive integer." + ) + + has_filters = bool( + all_hunks or range_header or contains or excludes or occurrence is not None + ) + + parsed.append( + HunkSelector( + file=file_path, + range_header=str(range_header).strip() if range_header else None, + contains=tuple(str(c) for c in contains), + excludes=tuple(str(c) for c in excludes), + occurrence=occurrence, + all_hunks=bool(all_hunks), + has_filters=has_filters, + ) + ) + return parsed + + +def parse_unified_diff(diff_text: str) -> List[DiffFile]: + lines = diff_text.splitlines() + files: List[DiffFile] = [] + current: Optional[DiffFile] = None + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith("diff --git "): + if current: + files.append(current) + parts = line.split(" ") + old_path = _strip_prefix(parts[2]) if len(parts) > 2 else None + new_path = _strip_prefix(parts[3]) if len(parts) > 3 else None + current = DiffFile( + old_path=old_path, + new_path=new_path, + header_lines=[line], + ) + i += 1 + continue + + if current is None: + i += 1 + continue + + if line.startswith("GIT binary patch"): + current.is_binary = True + while i < len(lines): + line = lines[i] + if line.startswith("diff --git "): + break + current.binary_lines.append(line) + i += 1 + continue + + if line.startswith("@@ "): + hunk_lines = [line] + i += 1 + while i < len(lines): + line = lines[i] + if line.startswith("diff --git ") or line.startswith("@@ "): + break + hunk_lines.append(line) + i += 1 + current.hunks.append(Hunk(header=hunk_lines[0], lines=hunk_lines)) + continue + + current.header_lines.append(line) + i += 1 + + if current: + files.append(current) + + return files + + +def build_diff(base: str, source: str) -> List[DiffFile]: + diff = git( + "diff", + "--binary", + "--full-index", + "--find-renames=20%", + f"{base}..{source}", + ).stdout + return parse_unified_diff(diff) + + +def _selectors_for_file( + selectors: Sequence[HunkSelector], df: DiffFile +) -> List[HunkSelector]: + label = _file_label(df) + matched: List[HunkSelector] = [] + for selector in selectors: + if selector.file == label: + matched.append(selector) + continue + if df.old_path and selector.file == df.old_path: + matched.append(selector) + continue + if df.new_path and selector.file == df.new_path: + matched.append(selector) + return matched + + +def select_hunks_for_changeset( + diff_files: Sequence[DiffFile], + selectors: Sequence[HunkSelector], + *, + include_paths: Sequence[str], + exclude_paths: Sequence[str], + allow_partial_files: bool, + changeset_label: str, +) -> SelectedPatch: + if not selectors: + raise CommandError(f"{changeset_label}: hunk_selectors must be non-empty.") + + patch_lines: List[str] = [] + selected_files: List[str] = [] + selected_hunks = 0 + seen_selectors = {id(selector): False for selector in selectors} + + for df in diff_files: + file_selectors = _selectors_for_file(selectors, df) + if not file_selectors: + continue + + label = _file_label(df) + labels = {p for p in (df.old_path, df.new_path) if p} + if df.is_binary: + raise CommandError( + f"{changeset_label}: {label} is binary; use mode=patch for binary files." + ) + if not df.hunks: + raise CommandError( + f"{changeset_label}: {label} has no hunks available to select." + ) + + select_all = any(sel.all_hunks for sel in file_selectors) or ( + not allow_partial_files + and any(not sel.has_filters for sel in file_selectors) + ) + chosen: List[Hunk] = [] + if select_all: + for selector in file_selectors: + seen_selectors[id(selector)] = True + if include_paths and not any( + _matches_any(path, include_paths) for path in labels + ): + raise CommandError( + f"{changeset_label}: selector file {selector.file} does not match include_paths." + ) + if exclude_paths and any( + _matches_any(path, exclude_paths) for path in labels + ): + raise CommandError( + f"{changeset_label}: selector file {selector.file} is excluded by exclude_paths." + ) + chosen = list(df.hunks) + else: + for selector in file_selectors: + seen_selectors[id(selector)] = True + if include_paths and not any( + _matches_any(path, include_paths) for path in labels + ): + raise CommandError( + f"{changeset_label}: selector file {selector.file} does not match include_paths." + ) + if exclude_paths and any( + _matches_any(path, exclude_paths) for path in labels + ): + raise CommandError( + f"{changeset_label}: selector file {selector.file} is excluded by exclude_paths." + ) + candidates: List[Hunk] = [] + for hunk in df.hunks: + if ( + selector.range_header + and selector.range_header.strip() != hunk.header.strip() + ): + continue + body = hunk.body_text + if selector.contains and not all( + c in body for c in selector.contains + ): + continue + if selector.excludes and any(c in body for c in selector.excludes): + continue + candidates.append(hunk) + + if not candidates: + raise CommandError( + f"{changeset_label}: selector for {label} matched no hunks." + ) + + if selector.occurrence is not None: + if selector.occurrence > len(candidates): + raise CommandError( + f"{changeset_label}: selector for {label} occurrence {selector.occurrence}" + f" exceeds {len(candidates)} matches." + ) + chosen_hunk = candidates[selector.occurrence - 1] + if chosen_hunk not in chosen: + chosen.append(chosen_hunk) + else: + if len(candidates) > 1: + raise CommandError( + f"{changeset_label}: selector for {label} matched multiple hunks; " + "add 'occurrence' to disambiguate." + ) + if candidates[0] not in chosen: + chosen.append(candidates[0]) + + if not allow_partial_files and len(chosen) != len(df.hunks): + raise CommandError( + f"{changeset_label}: {label} requires all hunks when allow_partial_files=false." + ) + + if chosen: + patch_lines.extend(df.header_lines) + for hunk in chosen: + patch_lines.extend(hunk.lines) + selected_files.append(label) + selected_hunks += len(chosen) + + missing = [s.file for s in selectors if not seen_selectors[id(s)]] + if missing: + missing_list = ", ".join(sorted(set(missing))) + raise CommandError( + f"{changeset_label}: selector file(s) not found in diff: {missing_list}." + ) + + if not selected_files: + raise CommandError(f"{changeset_label}: no hunks selected for this changeset.") + + patch_text = "\n".join(patch_lines) + "\n" + return SelectedPatch( + text=patch_text, + files=len(selected_files), + hunks=selected_hunks, + file_labels=selected_files, + ) + + +def apply_patch_text(patch_text: str, *, label: str) -> None: + if not patch_text.strip(): + raise CommandError(f"{label}: patch is empty.") + + with tempfile.NamedTemporaryFile("w", delete=False, prefix="pcs-patch-") as handle: + handle.write(patch_text) + patch_path = Path(handle.name) + + try: + result = git( + "apply", + "--index", + "--3way", + "--whitespace=nowarn", + str(patch_path), + check=False, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + raise CommandError( + f"{label}: git apply failed.\n{detail or 'Patch did not apply cleanly.'}" + ) + finally: + patch_path.unlink(missing_ok=True) + + +def check_patch_text(patch_text: str, *, label: str) -> None: + if not patch_text.strip(): + raise CommandError(f"{label}: patch is empty.") + + with tempfile.NamedTemporaryFile("w", delete=False, prefix="pcs-patch-") as handle: + handle.write(patch_text) + patch_path = Path(handle.name) + + try: + result = git( + "apply", + "--check", + "--3way", + "--whitespace=nowarn", + str(patch_path), + check=False, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + raise CommandError( + f"{label}: git apply --check failed.\n" + f"{detail or 'Patch did not apply cleanly.'}" + ) + finally: + patch_path.unlink(missing_ok=True) + + +def resolve_patch_path(patch_file: str) -> Path: + raw = Path(patch_file) + return raw if raw.is_absolute() else repo_root() / raw + + +def apply_patch_file(patch_file: str, *, label: str) -> None: + patch_path = resolve_patch_path(patch_file) + if not patch_path.exists(): + raise CommandError(f"{label}: patch file not found: {patch_path}") + patch_text = patch_path.read_text() + apply_patch_text(patch_text, label=label) + + +def check_patch_file(patch_file: str, *, label: str) -> None: + patch_path = resolve_patch_path(patch_file) + if not patch_path.exists(): + raise CommandError(f"{label}: patch file not found: {patch_path}") + patch_text = patch_path.read_text() + check_patch_text(patch_text, label=label) diff --git a/skills/carve-changesets/scripts/plan_checks.py b/skills/carve-changesets/scripts/plan_checks.py new file mode 100644 index 0000000..dc5f512 --- /dev/null +++ b/skills/carve-changesets/scripts/plan_checks.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Strict plan validation and consistency checks.""" + +from __future__ import annotations + +import fnmatch +from pathlib import Path +from typing import Dict, List, Sequence, Tuple + +from common import ( + CommandError, + checkout_restore, + compute_freshness, + delete_branch, + diff_name_status, + ensure_branches_exist, + ensure_clean_tree, + ensure_git_repo, + git, + repo_root, + unique_temp_branch, +) +from patch_apply import ( + build_diff, + check_patch_file, + check_patch_text, + parse_hunk_selectors, + select_hunks_for_changeset, +) + + +def _changed_paths(base: str, source: str) -> List[str]: + raw = diff_name_status(base, source) + paths: List[str] = [] + for line in raw.splitlines(): + parts = line.split("\t") + if not parts: + continue + code = parts[0][:1] + if code == "R" and len(parts) >= 3: + paths.append(parts[1]) + paths.append(parts[2]) + elif len(parts) >= 2: + paths.append(parts[1]) + return paths + + +def _matches_any(path: str, patterns: Sequence[str]) -> bool: + for pattern in patterns: + if fnmatch.fnmatch(path, pattern): + return True + return False + + +def _warn_placeholders(changeset: Dict, index: int, warnings: List[str]) -> None: + slug = str(changeset.get("slug", "")).strip() + if slug.startswith("changeset-") or slug.startswith("cs-"): + warnings.append(f"Changeset {index}: slug looks like a placeholder.") + + pr_notes = changeset.get("pr_notes", []) + if isinstance(pr_notes, list): + for note in pr_notes: + if isinstance(note, str) and "replace with" in note.lower(): + warnings.append( + f"Changeset {index}: pr_notes contains placeholder text." + ) + break + + commit_message = str(changeset.get("commit_message", "")).strip().lower() + if "placeholder" in commit_message: + warnings.append(f"Changeset {index}: commit_message looks like a placeholder.") + + +def _validate_paths_mode( + *, + base: str, + source: str, + changeset: Dict, + index: int, + errors: List[str], +) -> None: + include = changeset.get("include_paths", []) + exclude = changeset.get("exclude_paths", []) + if not include: + errors.append( + f"Changeset {index}: include_paths must be non-empty for mode=paths." + ) + return + changed = _changed_paths(base, source) + matched = [] + for path in changed: + if _matches_any(path, include) and not _matches_any(path, exclude): + matched.append(path) + if not matched: + errors.append( + f"Changeset {index}: include_paths did not match any changed files." + ) + + +def _validate_patch_mode(*, changeset: Dict, index: int, errors: List[str]) -> None: + patch_file = changeset.get("patch_file") + if not isinstance(patch_file, str) or not patch_file.strip(): + errors.append(f"Changeset {index}: patch_file must be a non-empty string.") + return + path = Path(patch_file) + if not path.is_absolute(): + path = repo_root() / path + if not path.exists(): + errors.append(f"Changeset {index}: patch_file not found: {path}") + return + if path.stat().st_size == 0: + errors.append(f"Changeset {index}: patch_file is empty: {path}") + + +def _warn_old_path_selectors( + *, + diff_files: Sequence, + selectors: Sequence, + index: int, + warnings: List[str], +) -> None: + renames: Dict[str, str] = {} + for df in diff_files: + if df.old_path and df.new_path and df.old_path != df.new_path: + renames[df.old_path] = df.new_path + + for selector in selectors: + new_path = renames.get(selector.file) + if new_path: + warnings.append( + f"Changeset {index}: selector references old path {selector.file}; " + f"prefer new path {new_path} for stability." + ) + + +def _validate_hunks_mode( + *, + diff_files: Sequence, + changeset: Dict, + index: int, + errors: List[str], + warnings: List[str], +) -> None: + selectors = changeset.get("hunk_selectors", []) + try: + parsed = parse_hunk_selectors(selectors, changeset_label=f"Changeset {index}") + except CommandError as exc: + errors.append(str(exc)) + return + + include = changeset.get("include_paths", []) + exclude = changeset.get("exclude_paths", []) + allow_partial = bool(changeset.get("allow_partial_files", True)) + try: + select_hunks_for_changeset( + diff_files, + parsed, + include_paths=include, + exclude_paths=exclude, + allow_partial_files=allow_partial, + changeset_label=f"Changeset {index}", + ) + except CommandError as exc: + errors.append(str(exc)) + return + + _warn_old_path_selectors( + diff_files=diff_files, + selectors=parsed, + index=index, + warnings=warnings, + ) + + +def validate_plan_strict(plan: Dict) -> Tuple[bool, List[str], List[str]]: + errors: List[str] = [] + warnings: List[str] = [] + + base = str(plan.get("base_branch", "")).strip() + source = str(plan.get("source_branch", "")).strip() + if not base or not source: + errors.append("Plan missing base_branch or source_branch.") + return False, errors, warnings + + changesets = plan.get("changesets") + if not isinstance(changesets, list): + errors.append("Plan missing changesets array.") + return False, errors, warnings + + diff_files = build_diff(base, source) + + for idx, cs in enumerate(changesets, start=1): + if not isinstance(cs, dict): + errors.append(f"Changeset {idx} must be an object.") + continue + mode = str(cs.get("mode", "paths")).strip() or "paths" + if mode == "paths": + _validate_paths_mode( + base=base, source=source, changeset=cs, index=idx, errors=errors + ) + elif mode == "patch": + _validate_patch_mode(changeset=cs, index=idx, errors=errors) + elif mode == "hunks": + _validate_hunks_mode( + diff_files=diff_files, + changeset=cs, + index=idx, + errors=errors, + warnings=warnings, + ) + else: + errors.append( + f"Changeset {idx}: unsupported mode '{mode}'. Use 'paths', 'patch', or 'hunks'." + ) + + _warn_placeholders(cs, idx, warnings) + + test_cmd = str(plan.get("test_command", "")).strip() + if not test_cmd: + warnings.append("Plan test_command is empty; set it or pass --test-cmd.") + + try: + freshness = compute_freshness(base, source) + if freshness.get("source_behind_base"): + warnings.append( + "Source branch does not include the current base HEAD. " + "Consider updating source before proceeding." + ) + except CommandError: + warnings.append("Unable to verify whether source is behind base.") + + return (not errors), errors, warnings + + +def strict_apply_check(plan: Dict) -> None: + """Apply changesets on a temporary branch to ensure patch viability.""" + ensure_git_repo() + ensure_clean_tree() + + base = str(plan.get("base_branch", "")).strip() + source = str(plan.get("source_branch", "")).strip() + changesets = plan.get("changesets") + if not base or not source or not isinstance(changesets, list): + raise CommandError( + "strict apply check requires valid base/source and changesets." + ) + + ensure_branches_exist([base, source]) + + from chain import apply_changeset # local import to avoid cycles + + temp_branch = unique_temp_branch("pcs-temp-strict-check") + print(f"[INFO] Creating temporary strict-check branch: {temp_branch}") + + diff_files = build_diff(base, source) + + with checkout_restore() as original: + try: + git("checkout", "-B", temp_branch, base) + source_sha = git("rev-parse", source).stdout.strip() + for idx, cs in enumerate(changesets, start=1): + print(f"[STEP] Strict-apply changeset {idx}") + mode = str(cs.get("mode", "paths")).strip() or "paths" + if mode == "patch": + patch_file = cs.get("patch_file", "") + check_patch_file(str(patch_file), label=f"Changeset {idx}") + elif mode == "hunks": + selectors = cs.get("hunk_selectors", []) + parsed = parse_hunk_selectors( + selectors, changeset_label=f"Changeset {idx}" + ) + selected = select_hunks_for_changeset( + diff_files, + parsed, + include_paths=cs.get("include_paths", []), + exclude_paths=cs.get("exclude_paths", []), + allow_partial_files=bool(cs.get("allow_partial_files", True)), + changeset_label=f"Changeset {idx}", + ) + check_patch_text(selected.text, label=f"Changeset {idx}") + apply_changeset( + base_branch=base, + source_branch=source, + source_sha=source_sha, + index=idx, + changeset=cs, + ) + finally: + git("checkout", original) + delete_branch(temp_branch) + print(f"\n[INFO] Restored original branch: {original}") diff --git a/skills/carve-changesets/scripts/preflight.py b/skills/carve-changesets/scripts/preflight.py new file mode 100755 index 0000000..eeaffdf --- /dev/null +++ b/skills/carve-changesets/scripts/preflight.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Preflight checks for repo cleanliness, mergeability, and tests.""" + +from __future__ import annotations + +import subprocess + +from common import ( + CommandError, + branch_exists, + checkout_restore, + compute_freshness, + delete_branch, + discover_test_command, + ensure_clean_tree, + ensure_git_repo, + git, + is_path_ignored, + unique_temp_branch, +) + + +def check_mergeability(base: str, source: str) -> None: + temp_branch = unique_temp_branch("pcs-temp-preflight") + print(f"[INFO] Checking mergeability via temporary branch: {temp_branch}") + + with checkout_restore() as original: + try: + git("checkout", "-B", temp_branch, base) + merge_result = git("merge", "--no-commit", "--no-ff", source, check=False) + if merge_result.returncode != 0: + raise CommandError( + "Mergeability check failed. Resolve conflicts or rebase the source branch." + ) + finally: + git("merge", "--abort", check=False) + git("checkout", original) + delete_branch(temp_branch) + + +def run_tests_on_branch(branch: str, test_cmd: str) -> None: + print(f"[INFO] Running test command on {branch}: {test_cmd}") + with checkout_restore(branch): + result = subprocess.run(test_cmd, shell=True) + if result.returncode != 0: + raise CommandError("Test command failed.") + ensure_clean_tree() + + +def _print_test_command_help(discovery: dict) -> None: + reason = str(discovery.get("reason", "unknown")) + candidates = list(discovery.get("candidates", [])) + suggestions = list(discovery.get("suggestions", [])) + + if reason == "agents-missing": + print("[WARN] No AGENTS.md found at repo root.") + elif reason == "agents-no-test-command": + print("[WARN] AGENTS.md found but no clear test command was detected.") + elif reason == "agents-ambiguous": + print("[WARN] Multiple test commands were detected in AGENTS.md:") + for cmd in candidates: + print(f" - {cmd}") + + if suggestions: + print("[HINT] Likely test commands to consider:") + for cmd in suggestions: + print(f" - {cmd}") + + print("[NEXT] Ask once for the desired test command, then re-run with --test-cmd.") + print( + "[NEXT] If still unknown, re-run with --skip-tests and record this in the plan." + ) + + +def preflight( + *, + base: str, + source: str, + test_cmd: str, + skip_tests: bool, + skip_merge_check: bool, + allow_source_behind_base: bool = False, + confirm_source_behind_base: bool = False, + allow_recordkeeping_tracked: bool = False, +) -> None: + ensure_git_repo() + ensure_clean_tree() + + if not branch_exists(base): + raise CommandError(f"Base branch does not exist: {base}") + if not branch_exists(source): + raise CommandError(f"Source branch does not exist: {source}") + + recordkeeping_path = ".carve-changesets/" + if not is_path_ignored(recordkeeping_path): + message = ( + "[ERROR] .carve-changesets/ is not ignored. Add it to .gitignore or " + ".git/info/exclude to keep plan/state files out of PRs.\n" + "Override (not recommended): re-run with --allow-recordkeeping-tracked." + ) + if allow_recordkeeping_tracked: + print( + "[WARN] .carve-changesets/ is not ignored; proceeding by explicit override." + ) + else: + raise CommandError(message) + + freshness = compute_freshness(base, source) + mb = str(freshness["merge_base"]) + base_head = str(freshness["base_head"]) + print(f"[OK] merge-base({base}, {source}) = {mb}") + + if freshness["source_behind_base"]: + message = ( + "[ERROR] Source branch is behind base branch.\n" + f"Base: {base} @ {base_head}\n" + f"Source: {source} (merge-base={mb})\n\n" + "This workflow assumes the source includes the current base HEAD to avoid churn\n" + "while carving changesets.\n\n" + f"Fix: merge or rebase {source} onto {base}, then re-run preflight.\n" + "Override (not recommended): re-run with --allow-source-behind-base and record why in the plan." + ) + if allow_source_behind_base: + print( + "[WARN] Source branch is behind base branch; proceeding by explicit override." + ) + elif confirm_source_behind_base: + response = input("Source is behind base. Proceed anyway? [y/N] ").strip() + if response.lower() not in ("y", "yes"): + raise CommandError(message) + print( + "[WARN] Source branch is behind base branch; proceeding by explicit confirmation." + ) + else: + raise CommandError(message) + + if not skip_merge_check: + check_mergeability(base, source) + print("[OK] Mergeability check passed.") + else: + print("[WARN] Skipping mergeability check by request.") + + effective_test_cmd = test_cmd.strip() + if not effective_test_cmd and not skip_tests: + discovery = discover_test_command("") + discovered = str(discovery.get("command") or "").strip() + if discovered: + print(f"[HINT] Discovered test command proposal: {discovered}") + print("[NEXT] Re-run with that exact command passed via --test-cmd.") + else: + _print_test_command_help(discovery) + raise CommandError( + "Preflight never executes discovered commands; pass an explicitly " + "approved --test-cmd or use --skip-tests." + ) + + if effective_test_cmd and not skip_tests: + run_tests_on_branch(source, effective_test_cmd) + print("[OK] Test command succeeded on source branch.") + elif effective_test_cmd and skip_tests: + print("[WARN] Test command provided but skipped by request.") + else: + print("[WARN] No test command provided.") + + ensure_clean_tree() + print("[OK] Preflight checks passed.") diff --git a/skills/carve-changesets/scripts/propagate.py b/skills/carve-changesets/scripts/propagate.py new file mode 100644 index 0000000..8a93f8c --- /dev/null +++ b/skills/carve-changesets/scripts/propagate.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Remote push safety for a materialized changeset chain. + +Merge and downstream propagation belong to epic child #33. This module retains +only the #30 push-chain surface. +""" + +from __future__ import annotations + +from typing import Dict, List + +from common import ( + CommandError, + branch_exists, + branch_name_for, + ensure_clean_tree, + ensure_git_repo, + git, +) + + +def _ensure_chain_exists(source: str, total: int) -> List[str]: + chain = [branch_name_for(source, index) for index in range(1, total + 1)] + missing = [branch for branch in chain if not branch_exists(branch)] + if missing: + raise CommandError("Missing changeset branches:\n" + "\n".join(missing)) + return chain + + +def remote_exists(remote: str) -> bool: + return git("remote", "get-url", remote, check=False).returncode == 0 + + +def remote_branch_head(remote: str, branch: str) -> str | None: + result = git( + "ls-remote", + "--heads", + remote, + f"refs/heads/{branch}", + check=False, + ) + if result.returncode != 0: + raise CommandError(f"Unable to resolve {remote}/{branch} before push.") + line = result.stdout.strip() + return line.split()[0] if line else None + + +def push_changeset_branch(branch: str, *, remote: str, dry_run: bool) -> None: + expected = remote_branch_head(remote, branch) + lease = f"--force-with-lease=refs/heads/{branch}:{expected or ''}" + refspec = f"refs/heads/{branch}:refs/heads/{branch}" + command = ("git", "push", remote, refspec, lease) + print(f"[STEP] Pushing changeset branch {branch} to {remote} with an exact lease") + if dry_run: + print("[DRY-RUN] Would run:") + print(" ".join(command)) + return + git("push", remote, refspec, lease) + + +def push_chain(plan: Dict, *, remote: str, dry_run: bool) -> None: + """Push changeset branches only; never force-push the base or source.""" + + ensure_git_repo() + ensure_clean_tree() + if not remote_exists(remote): + raise CommandError(f"Remote does not exist: {remote}") + + base = plan["base_branch"] + source = plan["source_branch"] + chain = _ensure_chain_exists(source, len(plan["changesets"])) + print( + f"[INFO] Base branch {base} is intentionally excluded. Push or update it " + "separately with a verified fast-forward-only workflow." + ) + print(f"[INFO] Source branch {source} is immutable and will not be pushed.") + for branch in chain: + push_changeset_branch(branch, remote=remote, dry_run=dry_run) + + if dry_run: + print("[OK] Dry-run push-chain complete. Re-run with --no-dry-run to execute.") + else: + print("[OK] push-chain completed.") diff --git a/skills/carve-changesets/scripts/squash_check.py b/skills/carve-changesets/scripts/squash_check.py new file mode 100755 index 0000000..f4134d8 --- /dev/null +++ b/skills/carve-changesets/scripts/squash_check.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Compare the changeset chain against a local squashed reference via rebase.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Tuple + +from common import ( + CommandError, + branch_exists, + branch_name_for, + checkout_restore, + delete_branch, + diff_name_status, + diff_stat, + ensure_branches_exist, + ensure_clean_tree, + ensure_git_repo, + git, + load_plan, + squashed_branch_name, + unique_temp_branch, + validate_plan, +) + + +def _load_valid_plan(plan_path: Path) -> Dict: + plan = load_plan(plan_path) + valid, errors = validate_plan(plan) + if not valid: + detail = "; ".join(errors) or "unknown validation error" + raise CommandError(f"Plan validation failed: {detail}") + return plan + + +def _chain_for_plan(plan: Dict) -> List[str]: + source = plan["source_branch"] + total = len(plan["changesets"]) + if total < 1: + raise CommandError("Plan must include at least one changeset.") + return [branch_name_for(source, i) for i in range(1, total + 1)] + + +def squash_check(plan: Dict) -> Tuple[str, str]: + ensure_git_repo() + ensure_clean_tree() + + base = plan["base_branch"] + source = plan["source_branch"] + chain = _chain_for_plan(plan) + tip = chain[-1] + squashed = squashed_branch_name(source) + + if not branch_exists(squashed): + raise CommandError( + "\n".join( + [ + f"Squashed reference branch not found: {squashed}", + "Create it with squash_ref.py before running squash_check.", + ] + ) + ) + + ensure_branches_exist([base, source, squashed, *chain]) + + temp_branch = unique_temp_branch("pcs-temp-squash-check") + print(f"[INFO] Using squashed reference: {squashed}") + print(f"[INFO] Chain tip: {tip}") + print(f"[INFO] Creating temporary squash-check branch: {temp_branch}") + + with checkout_restore() as original: + try: + git("checkout", "-B", temp_branch, squashed) + rebase_result = git("rebase", "--empty=drop", tip, check=False) + if rebase_result.returncode != 0: + git("rebase", "--abort", check=False) + raise CommandError( + "Squash-check rebase encountered conflicts. The chain may not capture the source branch cleanly." + ) + + diffstat = diff_stat(tip, temp_branch) + namestatus = diff_name_status(tip, temp_branch) + finally: + git("checkout", original) + delete_branch(temp_branch) + print(f"\n[INFO] Restored original branch: {original}") + + ensure_clean_tree() + return diffstat, namestatus diff --git a/skills/carve-changesets/scripts/squash_ref.py b/skills/carve-changesets/scripts/squash_ref.py new file mode 100755 index 0000000..573688b --- /dev/null +++ b/skills/carve-changesets/scripts/squash_ref.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Create a local squashed reference branch for comparison workflows.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional, Tuple + +from common import ( + CommandError, + branch_exists, + checkout_restore, + commit_with_message, + delete_branch, + ensure_clean_tree, + ensure_git_repo, + git, + load_plan, + merge_base, + squashed_branch_name, +) + + +def _resolve_base_source(*, plan_path: Path, base: str, source: str) -> Tuple[str, str]: + plan: Optional[dict] = None + if plan_path.exists(): + plan = load_plan(plan_path) + + resolved_base = base.strip() or ( + str(plan.get("base_branch", "")).strip() if plan else "" + ) + resolved_source = source.strip() or ( + str(plan.get("source_branch", "")).strip() if plan else "" + ) + + if not resolved_base or not resolved_source: + raise CommandError( + "Provide --base and --source, or ensure the plan exists with base_branch and source_branch." + ) + return resolved_base, resolved_source + + +def create_squashed_ref( + *, + base: str, + source: str, + reuse_existing: bool, + recreate: bool, +) -> str: + ensure_git_repo() + ensure_clean_tree() + + if not branch_exists(base): + raise CommandError(f"Base branch does not exist: {base}") + if not branch_exists(source): + raise CommandError(f"Source branch does not exist: {source}") + + squashed = squashed_branch_name(source) + exists = branch_exists(squashed) + + if exists and not (reuse_existing or recreate): + raise CommandError( + "\n".join( + [ + f"Squashed reference branch already exists: {squashed}", + "Ask whether to reuse it. If approved, re-run with --reuse-existing.", + "If it must be rebuilt, re-run with --recreate.", + ] + ) + ) + + if exists and recreate: + print(f"[STEP] Deleting existing squashed reference: {squashed}") + delete_branch(squashed) + + if branch_exists(squashed) and reuse_existing: + print(f"[OK] Reusing existing squashed reference: {squashed}") + print("[NOTE] Keep this branch local-only. Do not push it.") + return squashed + + mb = merge_base(base, source) + print(f"[INFO] merge-base({base}, {source}) = {mb}") + print(f"[STEP] Creating squashed reference branch: {squashed}") + + with checkout_restore() as original: + try: + git("checkout", "-B", squashed, mb) + merge_result = git("merge", "--squash", source, check=False) + if merge_result.returncode != 0: + git("merge", "--abort", check=False) + raise CommandError( + "Squash merge failed. Resolve source/base divergence before creating a squashed reference." + ) + + diff_cached = git("diff", "--cached", "--quiet", check=False) + if diff_cached.returncode == 0: + print("[WARN] No staged changes after squash merge.") + else: + commit_with_message(f"carve: squash reference for {source}") + print("[OK] Squashed reference commit created.") + finally: + git("checkout", original) + + ensure_clean_tree() + print("[NOTE] Keep this branch local-only. Do not push it.") + return squashed diff --git a/skills/carve-changesets/scripts/tests/legacy_helpers.py b/skills/carve-changesets/scripts/tests/legacy_helpers.py new file mode 100644 index 0000000..b8eed8c --- /dev/null +++ b/skills/carve-changesets/scripts/tests/legacy_helpers.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +from contextlib import contextmanager +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in os.sys.path: + os.sys.path.insert(0, str(SCRIPTS_DIR)) + + +def run( + cmd: list[str], *, cwd: Path, check: bool = True +) -> subprocess.CompletedProcess: + return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=check) + + +def commit(cwd: Path, message: str, *, allow_empty: bool = False) -> None: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as message_file: + message_file.write(message + "\n") + message_file.flush() + command = ["git", "commit", "-F", message_file.name] + if allow_empty: + command.append("--allow-empty") + run(command, cwd=cwd) + + +@contextmanager +def chdir(path: Path): + original = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(original) + + +def init_repo() -> tuple[Path, dict]: + repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-repo-")) + + run(["git", "init", "-b", "main"], cwd=repo_dir) + run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) + run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) + + # Keep recordkeeping artifacts out of git status checks. + (repo_dir / ".gitignore").write_text(".carve-changesets/\n") + + (repo_dir / "a.txt").write_text("base-a\n") + (repo_dir / "b.txt").write_text("base-b\n") + run(["git", "add", "-A"], cwd=repo_dir) + commit(repo_dir, "base") + + run(["git", "checkout", "-b", "feature/test"], cwd=repo_dir) + (repo_dir / "a.txt").write_text("feature-a\n") + (repo_dir / "b.txt").write_text("feature-b\n") + (repo_dir / "c.txt").write_text("feature-c\n") + run(["git", "add", "-A"], cwd=repo_dir) + commit(repo_dir, "feature") + + plan = { + "feature_title": "Test feature", + "base_branch": "main", + "source_branch": "feature/test", + "test_command": "", + "changesets": [ + { + "slug": "a-only", + "description": "Apply a.txt changes.", + "include_paths": ["a.txt"], + "exclude_paths": [], + "commit_message": "cs1", + "pr_notes": ["note"], + }, + { + "slug": "rest", + "description": "Apply remaining changes.", + "include_paths": ["b.txt", "c.txt"], + "exclude_paths": [], + "commit_message": "cs2", + "pr_notes": ["note"], + }, + ], + } + + return repo_dir, plan + + +def init_conflict_repo() -> tuple[Path, dict]: + repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-conflict-")) + + run(["git", "init", "-b", "main"], cwd=repo_dir) + run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) + run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) + + (repo_dir / ".gitignore").write_text(".carve-changesets/\n") + + (repo_dir / "conflict.txt").write_text("line\n") + run(["git", "add", "-A"], cwd=repo_dir) + commit(repo_dir, "base") + + run(["git", "checkout", "-b", "feature/conflict"], cwd=repo_dir) + (repo_dir / "conflict.txt").write_text("feature-change\n") + run(["git", "add", "conflict.txt"], cwd=repo_dir) + commit(repo_dir, "feature change") + + run(["git", "checkout", "main"], cwd=repo_dir) + (repo_dir / "conflict.txt").write_text("main-change\n") + run(["git", "add", "conflict.txt"], cwd=repo_dir) + commit(repo_dir, "main change") + + plan = { + "feature_title": "Conflict feature", + "base_branch": "main", + "source_branch": "feature/conflict", + "test_command": "", + "changesets": [ + { + "slug": "conflict", + "description": "Conflicting change.", + "include_paths": ["conflict.txt"], + "exclude_paths": [], + "commit_message": "cs1", + "pr_notes": ["note"], + } + ], + } + + return repo_dir, plan + + +def init_remote(repo_dir: Path) -> Path: + remote_dir = Path(tempfile.mkdtemp(prefix="pcs-test-remote-")) / "remote.git" + run(["git", "init", "--bare", str(remote_dir)], cwd=repo_dir) + run(["git", "remote", "add", "origin", str(remote_dir)], cwd=repo_dir) + return remote_dir + + +def write_plan(plan_path: Path, plan: dict) -> None: + plan_path.parent.mkdir(parents=True, exist_ok=True) + plan_path.write_text(json.dumps(plan, indent=2) + "\n") diff --git a/skills/carve-changesets/scripts/tests/test_chain.py b/skills/carve-changesets/scripts/tests/test_chain.py new file mode 100644 index 0000000..3d386dc --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_chain.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import shutil +import unittest + +import helpers # noqa: F401 # ensures sys.path is set +from chain import compare_chain, create_chain, validate_chain +from common import CommandError +from legacy_helpers import chdir, commit, init_repo + + +class ChainTests(unittest.TestCase): + def test_create_chain_and_compare_equivalence(self) -> None: + repo_dir, plan = init_repo() + try: + from legacy_helpers import run + + source_hash_before = run( + ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir + ).stdout.strip() + with chdir(repo_dir): + create_chain(plan) + diffstat, namestatus = compare_chain(plan) + source_hash_after = run( + ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir + ).stdout.strip() + + self.assertEqual( + source_hash_before, source_hash_after, "Source branch hash changed" + ) + self.assertEqual(diffstat.strip(), "") + self.assertEqual(namestatus.strip(), "") + finally: + shutil.rmtree(repo_dir) + + def test_validate_chain_runs_tests(self) -> None: + repo_dir, plan = init_repo() + try: + with chdir(repo_dir): + create_chain(plan) + validate_chain(plan, test_cmd="python3 -c \"print('ok')\"") + finally: + shutil.rmtree(repo_dir) + + def test_validate_chain_fails_on_bad_command(self) -> None: + repo_dir, plan = init_repo() + try: + with chdir(repo_dir): + create_chain(plan) + with self.assertRaises(CommandError): + validate_chain( + plan, test_cmd='python3 -c "import sys; sys.exit(7)"' + ) + finally: + shutil.rmtree(repo_dir) + + def test_validate_chain_requires_explicit_command(self) -> None: + repo_dir, plan = init_repo() + try: + (repo_dir / "AGENTS.md").write_text( + "```bash\npython3 -c \"print('test ok')\"\n```\n" + ) + from legacy_helpers import run + + run(["git", "add", "AGENTS.md"], cwd=repo_dir) + commit(repo_dir, "add agents") + with chdir(repo_dir): + create_chain(plan) + with self.assertRaisesRegex(CommandError, "explicitly approved"): + validate_chain(plan, test_cmd="") + finally: + shutil.rmtree(repo_dir) + + def test_create_chain_is_append_only_for_existing_prefix(self) -> None: + repo_dir, plan = init_repo() + try: + from legacy_helpers import run + + with chdir(repo_dir): + create_chain(plan) + cs1 = f"{plan['source_branch']}-1" + cs2 = f"{plan['source_branch']}-2" + cs1_before = run(["git", "rev-parse", cs1], cwd=repo_dir).stdout.strip() + cs2_before = run(["git", "rev-parse", cs2], cwd=repo_dir).stdout.strip() + + plan["changesets"].append( + { + "slug": "noop-3", + "description": "Placeholder changeset to test append-only behavior.", + "include_paths": ["does-not-exist.txt"], + "exclude_paths": [], + "commit_message": "cs3", + "pr_notes": [], + } + ) + + create_chain(plan) + cs1_after = run(["git", "rev-parse", cs1], cwd=repo_dir).stdout.strip() + cs2_after = run(["git", "rev-parse", cs2], cwd=repo_dir).stdout.strip() + cs3 = f"{plan['source_branch']}-3" + cs3_rc = run( + ["git", "rev-parse", "--verify", cs3], cwd=repo_dir, check=False + ).returncode + + self.assertEqual(cs1_before, cs1_after) + self.assertEqual(cs2_before, cs2_after) + self.assertEqual(cs3_rc, 0) + finally: + shutil.rmtree(repo_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_cli_safety.py b/skills/carve-changesets/scripts/tests/test_cli_safety.py new file mode 100644 index 0000000..cea56ba --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_cli_safety.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +import helpers # noqa: F401 +from cli import COMMAND_MUTATION_CLASSES, build_parser + + +class CliSafetyTests(unittest.TestCase): + def test_every_operation_has_one_mutation_class(self) -> None: + parser = build_parser() + help_text = parser.format_help() + self.assertEqual(14, len(COMMAND_MUTATION_CLASSES)) + for command, mutation_class in COMMAND_MUTATION_CLASSES.items(): + self.assertIn(command, help_text) + self.assertIn(f"[{mutation_class}]", help_text) + + def test_all_remote_mutations_default_to_dry_run(self) -> None: + parser = build_parser() + for argv in (("pr-create",), ("push-chain",)): + args = parser.parse_args(argv) + self.assertEqual("remote-mutating", args.mutation_class) + self.assertTrue(args.dry_run) + + def test_implementation_uses_file_messages_and_no_hard_reset(self) -> None: + scripts = Path(__file__).resolve().parents[1] + implementation = "\n".join( + path.read_text() + for path in scripts.glob("*.py") + if path.name + not in {"metadata.py", "rehydrate.py", "status.py", "validate.py"} + ) + self.assertNotIn('"commit", "-m"', implementation) + self.assertNotIn('"--body",', implementation) + self.assertNotIn('"reset", "--hard"', implementation) + + def test_only_github_module_invokes_gh(self) -> None: + scripts = Path(__file__).resolve().parents[1] + for path in scripts.glob("*.py"): + if path.name == "github.py": + continue + source = path.read_text() + self.assertNotIn('["gh",', source, path.name) + self.assertNotIn('("gh",', source, path.name) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_common.py b/skills/carve-changesets/scripts/tests/test_common.py new file mode 100644 index 0000000..f8d61aa --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_common.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +import helpers # noqa: F401 # ensures sys.path is set +from common import DEFAULT_PLAN_PATH, init_plan, load_plan, validate_plan + + +class CommonTests(unittest.TestCase): + def test_plan_validation_catches_missing_fields(self) -> None: + valid, errors = validate_plan({"name": "bad"}) + self.assertFalse(valid) + self.assertTrue(errors) + + def test_init_plan_writes_valid_plan(self) -> None: + temp_dir = Path(tempfile.mkdtemp(prefix="pcs-test-plan-")) + try: + plan_path = temp_dir / DEFAULT_PLAN_PATH + init_plan( + plan_path=plan_path, + base="main", + source="feature/x", + title="Title", + changesets=2, + test_cmd="", + force=True, + ) + plan = load_plan(plan_path) + valid, errors = validate_plan(plan) + self.assertTrue(valid, f"plan should validate: {errors}") + finally: + shutil.rmtree(temp_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_dbcompare.py b/skills/carve-changesets/scripts/tests/test_dbcompare.py new file mode 100644 index 0000000..d5f7534 --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_dbcompare.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import shutil +import unittest + +import db_compare as dbcompare_mod +from chain import create_chain +from legacy_helpers import chdir, init_repo + + +class DbCompareTests(unittest.TestCase): + def test_db_compare_creates_outputs(self) -> None: + repo_dir, plan = init_repo() + try: + out_dir = repo_dir / ".carve-changesets" / "db-compare-test" + with chdir(repo_dir): + create_chain(plan) + dbcompare_mod.db_compare( + plan, + source_cmd="cat a.txt", + chain_cmd="cat a.txt", + out_dir=out_dir, + ) + + source_out = out_dir / "source.txt" + chain_out = out_dir / "chain.txt" + self.assertTrue(source_out.exists()) + self.assertTrue(chain_out.exists()) + self.assertEqual(source_out.read_text(), chain_out.read_text()) + finally: + shutil.rmtree(repo_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_evals.py b/skills/carve-changesets/scripts/tests/test_evals.py new file mode 100644 index 0000000..1983ce9 --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_evals.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +from evals.grader import grade_repo +from evals.helpers import cleanup_repo, init_eval_repo +from evals.runner import main as runner_main +from legacy_helpers import chdir, commit, run + + +class EvalGraderTests(unittest.TestCase): + def test_grader_passes_on_deterministic_baseline(self) -> None: + repo_dir, plan, source_hash = init_eval_repo() + try: + plan_path = repo_dir / ".carve-changesets/plan.json" + with chdir(repo_dir): + result = grade_repo( + plan_path=plan_path, + expected_source_hash=source_hash, + test_cmd="python3 -c \"print('ok')\"", + auto_create_chain=True, + ) + self.assertTrue(result.ok, f"grader should pass: {result.failures}") + finally: + cleanup_repo(repo_dir) + + def test_grader_detects_source_branch_mutation(self) -> None: + repo_dir, plan, source_hash = init_eval_repo() + try: + plan_path = repo_dir / ".carve-changesets/plan.json" + with chdir(repo_dir): + # Mutate the source branch after recording its hash. + (repo_dir / "a.txt").write_text("mutated-source\n") + run(["git", "add", "a.txt"], cwd=repo_dir) + commit(repo_dir, "mutate source") + + result = grade_repo( + plan_path=plan_path, + expected_source_hash=source_hash, + test_cmd="python3 -c \"print('ok')\"", + auto_create_chain=True, + ) + + self.assertFalse(result.ok) + self.assertTrue( + any("source_hash_unchanged" in failure for failure in result.failures), + f"expected source hash failure, got: {result.failures}", + ) + finally: + cleanup_repo(repo_dir) + + +class EvalRunnerTests(unittest.TestCase): + def test_runner_skip_codex_produces_passing_summary(self) -> None: + prompts_path = Path(__file__).resolve().parents[1] / "evals" / "prompts.csv" + out_dir = Path(tempfile.mkdtemp(prefix="pcs-eval-out-")) + try: + rc = runner_main( + [ + "--prompts", + str(prompts_path), + "--out-dir", + str(out_dir), + "--skip-codex", + ] + ) + self.assertEqual(rc, 0) + summary_path = out_dir / "summary.json" + self.assertTrue(summary_path.exists()) + finally: + shutil.rmtree(out_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_github.py b/skills/carve-changesets/scripts/tests/test_github.py new file mode 100644 index 0000000..7957609 --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_github.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import shutil +import subprocess +import unittest +from unittest import mock + +import github as github_mod +from chain import create_chain +from common import CommandError +from legacy_helpers import chdir, init_repo + + +class GithubTests(unittest.TestCase): + def test_pr_create_dry_run_uses_body_file(self) -> None: + repo_dir, plan = init_repo() + try: + with chdir(repo_dir): + create_chain(plan) + captured: list[tuple[str, ...]] = [] + with mock.patch.object( + github_mod, "_print_command", side_effect=captured.append + ): + github_mod.pr_create(plan, indices=[1, 2], dry_run=True) + + self.assertEqual(2, len(captured)) + for command in captured: + self.assertEqual(("gh", "pr", "create"), command[:3]) + self.assertIn("--body-file", command) + self.assertNotIn("--body", command) + finally: + shutil.rmtree(repo_dir) + + def test_gh_capture_wraps_missing_executable(self) -> None: + with mock.patch("github.subprocess.run", side_effect=FileNotFoundError): + with self.assertRaisesRegex(CommandError, "not found"): + github_mod.gh_capture(("auth", "status")) + + def test_gh_capture_allows_only_named_return_codes(self) -> None: + error = subprocess.CalledProcessError( + 4, ["gh", "example"], output="allowed", stderr="detail" + ) + with mock.patch("github.subprocess.run", side_effect=error): + stdout, stderr = github_mod.gh_capture( + ("example",), allowed_returncodes=(4,) + ) + self.assertEqual("allowed", stdout) + self.assertEqual("detail", stderr) + + with mock.patch("github.subprocess.run", side_effect=error): + with self.assertRaisesRegex(CommandError, "GitHub CLI command failed"): + github_mod.gh_capture(("example",)) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_hunks_apply.py b/skills/carve-changesets/scripts/tests/test_hunks_apply.py new file mode 100644 index 0000000..488c8aa --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_hunks_apply.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +import legacy_helpers as helpers +from chain import compare_chain, create_chain +from common import CommandError +from plan_checks import strict_apply_check, validate_plan_strict + + +def _write_lines(path: Path, lines: list[str]) -> None: + path.write_text("".join(line + "\n" for line in lines)) + + +def _init_hunk_repo() -> tuple[Path, dict]: + repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-hunks-")) + helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) + helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) + helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) + (repo_dir / ".gitignore").write_text(".carve-changesets/\n") + + base_lines = [f"line-{i}" for i in range(1, 41)] + _write_lines(repo_dir / "notes.txt", base_lines) + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "base") + + helpers.run(["git", "checkout", "-b", "feature/hunks"], cwd=repo_dir) + source_lines = base_lines[:] + source_lines[1] = "line-2 changed" + source_lines[29] = "line-30 changed" + _write_lines(repo_dir / "notes.txt", source_lines) + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "feature") + + plan = { + "feature_title": "Hunk feature", + "base_branch": "main", + "source_branch": "feature/hunks", + "test_command": "", + "changesets": [ + { + "slug": "hunk-1", + "description": "Apply first hunk.", + "mode": "hunks", + "include_paths": ["notes.txt"], + "exclude_paths": [], + "allow_partial_files": True, + "hunk_selectors": [ + {"file": "notes.txt", "contains": ["line-2 changed"]} + ], + "commit_message": "cs1", + "pr_notes": [], + }, + { + "slug": "hunk-2", + "description": "Apply second hunk.", + "mode": "hunks", + "include_paths": ["notes.txt"], + "exclude_paths": [], + "allow_partial_files": True, + "hunk_selectors": [ + {"file": "notes.txt", "contains": ["line-30 changed"]} + ], + "commit_message": "cs2", + "pr_notes": [], + }, + ], + } + + return repo_dir, plan + + +def _init_context_shift_repo() -> tuple[Path, dict]: + repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-shift-")) + helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) + helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) + helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) + (repo_dir / ".gitignore").write_text(".carve-changesets/\n") + + base_lines = [f"row-{i}" for i in range(1, 41)] + _write_lines(repo_dir / "shift.txt", base_lines) + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "base") + + helpers.run(["git", "checkout", "-b", "feature/shift"], cwd=repo_dir) + source_lines = base_lines[:] + source_lines.insert(5, "row-5.5 inserted") + source_lines[30] = "row-31 changed" + _write_lines(repo_dir / "shift.txt", source_lines) + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "feature") + + plan = { + "feature_title": "Shift feature", + "base_branch": "main", + "source_branch": "feature/shift", + "test_command": "", + "changesets": [ + { + "slug": "insert", + "description": "Insert line.", + "mode": "hunks", + "include_paths": ["shift.txt"], + "exclude_paths": [], + "allow_partial_files": True, + "hunk_selectors": [ + {"file": "shift.txt", "contains": ["row-5.5 inserted"]} + ], + "commit_message": "cs1", + "pr_notes": [], + }, + { + "slug": "change", + "description": "Modify later line.", + "mode": "hunks", + "include_paths": ["shift.txt"], + "exclude_paths": [], + "allow_partial_files": True, + "hunk_selectors": [ + {"file": "shift.txt", "contains": ["row-31 changed"]} + ], + "commit_message": "cs2", + "pr_notes": [], + }, + ], + } + + return repo_dir, plan + + +def _init_patch_repo() -> tuple[Path, dict]: + repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-patch-")) + helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) + helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) + helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) + (repo_dir / ".gitignore").write_text(".carve-changesets/\n") + + (repo_dir / "patch.txt").write_text("base\n") + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "base") + + helpers.run(["git", "checkout", "-b", "feature/patch"], cwd=repo_dir) + (repo_dir / "patch.txt").write_text("base\npatch\n") + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "feature") + + patch_dir = repo_dir / ".carve-changesets" / "patches" + patch_dir.mkdir(parents=True, exist_ok=True) + diff = helpers.run( + ["git", "diff", "main..feature/patch", "--", "patch.txt"], + cwd=repo_dir, + ).stdout + (patch_dir / "patch.txt.patch").write_text(diff) + + plan = { + "feature_title": "Patch feature", + "base_branch": "main", + "source_branch": "feature/patch", + "test_command": "", + "changesets": [ + { + "slug": "patch", + "description": "Apply patch file.", + "mode": "patch", + "patch_file": ".carve-changesets/patches/patch.txt.patch", + "commit_message": "cs1", + "pr_notes": [], + } + ], + } + + return repo_dir, plan + + +def _init_rename_repo() -> tuple[Path, dict]: + repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-rename-")) + helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) + helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) + helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) + (repo_dir / ".gitignore").write_text(".carve-changesets/\n") + + (repo_dir / "old.txt").write_text("alpha\nbeta\ngamma\n") + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "base") + + helpers.run(["git", "checkout", "-b", "feature/rename"], cwd=repo_dir) + helpers.run(["git", "mv", "old.txt", "new.txt"], cwd=repo_dir) + (repo_dir / "new.txt").write_text("alpha\nbeta changed\ngamma\n") + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "rename") + + plan = { + "feature_title": "Rename feature", + "base_branch": "main", + "source_branch": "feature/rename", + "test_command": "", + "changesets": [ + { + "slug": "rename-hunk", + "description": "Rename plus hunk.", + "mode": "hunks", + "include_paths": ["new.txt"], + "exclude_paths": [], + "allow_partial_files": True, + "hunk_selectors": [{"file": "old.txt", "contains": ["beta changed"]}], + "commit_message": "cs1", + "pr_notes": [], + } + ], + } + + return repo_dir, plan + + +def _init_bad_patch_repo() -> tuple[Path, dict]: + repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-bad-patch-")) + helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) + helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) + helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) + (repo_dir / ".gitignore").write_text(".carve-changesets/\n") + + (repo_dir / "base.txt").write_text("base\n") + helpers.run(["git", "add", "-A"], cwd=repo_dir) + helpers.commit(repo_dir, "base") + + helpers.run(["git", "checkout", "-b", "feature/bad-patch"], cwd=repo_dir) + helpers.commit(repo_dir, "feature", allow_empty=True) + + patch_dir = repo_dir / ".carve-changesets" / "patches" + patch_dir.mkdir(parents=True, exist_ok=True) + (patch_dir / "bad.patch").write_text( + "diff --git a/base.txt b/base.txt\n" + "index 0000000..1111111 100644\n" + "--- a/base.txt\n" + "+++ b/base.txt\n" + "@@ -1 +1 @@\n" + "-missing\n" + "+bad\n" + ) + + plan = { + "feature_title": "Bad patch", + "base_branch": "main", + "source_branch": "feature/bad-patch", + "test_command": "", + "changesets": [ + { + "slug": "bad-patch", + "description": "Patch that will not apply.", + "mode": "patch", + "patch_file": ".carve-changesets/patches/bad.patch", + "commit_message": "cs1", + "pr_notes": [], + } + ], + } + + return repo_dir, plan + + +class HunkApplyTests(unittest.TestCase): + def test_hunks_apply_simple(self) -> None: + repo_dir, plan = _init_hunk_repo() + try: + with helpers.chdir(repo_dir): + create_chain(plan) + diffstat, namestatus = compare_chain(plan) + self.assertEqual(diffstat, "") + self.assertEqual(namestatus, "") + finally: + shutil.rmtree(repo_dir) + + def test_hunks_apply_with_context_shift(self) -> None: + repo_dir, plan = _init_context_shift_repo() + try: + with helpers.chdir(repo_dir): + create_chain(plan) + diffstat, namestatus = compare_chain(plan) + self.assertEqual(diffstat, "") + self.assertEqual(namestatus, "") + finally: + shutil.rmtree(repo_dir) + + def test_hunks_selector_ambiguous(self) -> None: + repo_dir, plan = _init_hunk_repo() + try: + plan["changesets"][0]["hunk_selectors"] = [ + {"file": "notes.txt", "contains": ["line-"]} + ] + with helpers.chdir(repo_dir): + ok, errors, _warnings = validate_plan_strict(plan) + self.assertFalse(ok) + self.assertTrue(errors) + finally: + shutil.rmtree(repo_dir) + + def test_patch_mode(self) -> None: + repo_dir, plan = _init_patch_repo() + try: + with helpers.chdir(repo_dir): + create_chain(plan) + diffstat, namestatus = compare_chain(plan) + self.assertEqual(diffstat, "") + self.assertEqual(namestatus, "") + finally: + shutil.rmtree(repo_dir) + + def test_hunks_all_selector(self) -> None: + repo_dir, plan = _init_hunk_repo() + try: + plan["changesets"] = [ + { + "slug": "all-hunks", + "description": "Select all hunks explicitly.", + "mode": "hunks", + "include_paths": ["notes.txt"], + "exclude_paths": [], + "allow_partial_files": True, + "hunk_selectors": [{"file": "notes.txt", "all": True}], + "commit_message": "cs1", + "pr_notes": [], + } + ] + with helpers.chdir(repo_dir): + create_chain(plan) + diffstat, namestatus = compare_chain(plan) + self.assertEqual(diffstat, "") + self.assertEqual(namestatus, "") + finally: + shutil.rmtree(repo_dir) + + def test_hunks_all_with_allow_partial_false(self) -> None: + repo_dir, plan = _init_hunk_repo() + try: + plan["changesets"] = [ + { + "slug": "all-hunks", + "description": "Select all hunks implicitly.", + "mode": "hunks", + "include_paths": ["notes.txt"], + "exclude_paths": [], + "allow_partial_files": False, + "hunk_selectors": [{"file": "notes.txt"}], + "commit_message": "cs1", + "pr_notes": [], + } + ] + with helpers.chdir(repo_dir): + create_chain(plan) + diffstat, namestatus = compare_chain(plan) + self.assertEqual(diffstat, "") + self.assertEqual(namestatus, "") + finally: + shutil.rmtree(repo_dir) + + def test_hunks_with_rename_scope_include_new_path(self) -> None: + repo_dir, plan = _init_rename_repo() + try: + with helpers.chdir(repo_dir): + ok, errors, warnings = validate_plan_strict(plan) + self.assertTrue(ok, f"expected strict validation to pass: {errors}") + self.assertTrue(warnings, "expected a warning for old path usage") + create_chain(plan) + diffstat, namestatus = compare_chain(plan) + self.assertEqual(diffstat, "") + self.assertEqual(namestatus, "") + finally: + shutil.rmtree(repo_dir) + + def test_hunks_with_rename_scope_exclude_either_path(self) -> None: + repo_dir, plan = _init_rename_repo() + try: + plan["changesets"][0]["exclude_paths"] = ["old.txt"] + with helpers.chdir(repo_dir): + ok, errors, _warnings = validate_plan_strict(plan) + self.assertFalse(ok) + self.assertTrue(errors) + finally: + shutil.rmtree(repo_dir) + + def test_strict_apply_check_fails(self) -> None: + repo_dir, plan = _init_bad_patch_repo() + try: + with helpers.chdir(repo_dir): + with self.assertRaises(CommandError): + strict_apply_check(plan) + finally: + shutil.rmtree(repo_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_preflight.py b/skills/carve-changesets/scripts/tests/test_preflight.py new file mode 100644 index 0000000..3bc0aec --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_preflight.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import shutil +import unittest + +import preflight as preflight_mod +from common import CommandError +from legacy_helpers import chdir, commit, init_conflict_repo, init_repo, run + + +class PreflightTests(unittest.TestCase): + def test_preflight_success_does_not_modify_source(self) -> None: + repo_dir, plan = init_repo() + try: + source_hash_before = run( + ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir + ).stdout.strip() + with chdir(repo_dir): + preflight_mod.preflight( + base=plan["base_branch"], + source=plan["source_branch"], + test_cmd="python3 -c \"print('ok')\"", + skip_tests=False, + skip_merge_check=False, + ) + source_hash_after = run( + ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir + ).stdout.strip() + self.assertEqual(source_hash_before, source_hash_after) + finally: + shutil.rmtree(repo_dir) + + def test_preflight_detects_conflicts(self) -> None: + repo_dir, plan = init_conflict_repo() + try: + with chdir(repo_dir): + with self.assertRaises(CommandError): + preflight_mod.preflight( + base=plan["base_branch"], + source=plan["source_branch"], + test_cmd="", + skip_tests=True, + skip_merge_check=False, + ) + finally: + shutil.rmtree(repo_dir) + + def test_preflight_fails_when_source_behind_base(self) -> None: + repo_dir, plan = init_repo() + try: + run(["git", "checkout", plan["base_branch"]], cwd=repo_dir) + (repo_dir / "base.txt").write_text("base-update\n") + run(["git", "add", "base.txt"], cwd=repo_dir) + commit(repo_dir, "base update") + with chdir(repo_dir): + with self.assertRaises(CommandError): + preflight_mod.preflight( + base=plan["base_branch"], + source=plan["source_branch"], + test_cmd="", + skip_tests=True, + skip_merge_check=True, + ) + finally: + shutil.rmtree(repo_dir) + + def test_preflight_allows_source_behind_with_override(self) -> None: + repo_dir, plan = init_repo() + try: + run(["git", "checkout", plan["base_branch"]], cwd=repo_dir) + (repo_dir / "base.txt").write_text("base-update\n") + run(["git", "add", "base.txt"], cwd=repo_dir) + commit(repo_dir, "base update") + with chdir(repo_dir): + preflight_mod.preflight( + base=plan["base_branch"], + source=plan["source_branch"], + test_cmd="", + skip_tests=True, + skip_merge_check=True, + allow_source_behind_base=True, + ) + finally: + shutil.rmtree(repo_dir) + + def test_preflight_requires_recordkeeping_ignored(self) -> None: + repo_dir, plan = init_repo() + try: + (repo_dir / ".gitignore").write_text("") + run(["git", "add", ".gitignore"], cwd=repo_dir) + commit(repo_dir, "update ignore") + with chdir(repo_dir): + with self.assertRaises(CommandError): + preflight_mod.preflight( + base=plan["base_branch"], + source=plan["source_branch"], + test_cmd="", + skip_tests=True, + skip_merge_check=True, + ) + finally: + shutil.rmtree(repo_dir) + + def test_preflight_allows_recordkeeping_tracked_with_override(self) -> None: + repo_dir, plan = init_repo() + try: + (repo_dir / ".gitignore").write_text("") + run(["git", "add", ".gitignore"], cwd=repo_dir) + commit(repo_dir, "update ignore") + with chdir(repo_dir): + preflight_mod.preflight( + base=plan["base_branch"], + source=plan["source_branch"], + test_cmd="", + skip_tests=True, + skip_merge_check=True, + allow_recordkeeping_tracked=True, + ) + finally: + shutil.rmtree(repo_dir) + + def test_preflight_never_executes_discovered_command(self) -> None: + repo_dir, plan = init_repo() + try: + marker = repo_dir / "test-command-ran" + (repo_dir / "AGENTS.md").write_text( + '```bash\npython3 -c "from pathlib import Path; ' + "Path('test-command-ran').touch()\"\n```\n" + ) + run(["git", "add", "AGENTS.md"], cwd=repo_dir) + commit(repo_dir, "add test command proposal") + with chdir(repo_dir): + with self.assertRaisesRegex(CommandError, "never executes discovered"): + preflight_mod.preflight( + base=plan["base_branch"], + source=plan["source_branch"], + test_cmd="", + skip_tests=False, + skip_merge_check=True, + ) + self.assertFalse(marker.exists()) + finally: + shutil.rmtree(repo_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_propagate.py b/skills/carve-changesets/scripts/tests/test_propagate.py new file mode 100644 index 0000000..0009050 --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_propagate.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import shutil +import unittest +from unittest import mock + +from chain import create_chain +from legacy_helpers import chdir, init_remote, init_repo, run +from propagate import push_chain + + +class PushChainTests(unittest.TestCase): + def test_push_chain_never_sends_base_or_source_to_force_push(self) -> None: + repo_dir, plan = init_repo() + remote_dir = None + try: + remote_dir = init_remote(repo_dir) + with chdir(repo_dir): + create_chain(plan) + pushed: list[str] = [] + with mock.patch( + "propagate.push_changeset_branch", + side_effect=lambda branch, **_kwargs: pushed.append(branch), + ): + push_chain(plan, remote="origin", dry_run=True) + + self.assertEqual(["feature/test-1", "feature/test-2"], pushed) + self.assertNotIn(plan["base_branch"], pushed) + self.assertNotIn(plan["source_branch"], pushed) + finally: + shutil.rmtree(repo_dir) + if remote_dir is not None: + shutil.rmtree(remote_dir.parent) + + def test_push_chain_uses_exact_refspecs_and_leases(self) -> None: + repo_dir, plan = init_repo() + remote_dir = None + try: + remote_dir = init_remote(repo_dir) + with chdir(repo_dir): + create_chain(plan) + push_chain(plan, remote="origin", dry_run=False) + + for branch in ("feature/test-1", "feature/test-2"): + remote = run( + ["git", "ls-remote", "origin", f"refs/heads/{branch}"], + cwd=repo_dir, + ).stdout.strip() + self.assertTrue(remote) + base = run( + ["git", "ls-remote", "origin", "refs/heads/main"], cwd=repo_dir + ).stdout.strip() + self.assertEqual("", base) + finally: + shutil.rmtree(repo_dir) + if remote_dir is not None: + shutil.rmtree(remote_dir.parent) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_scripts_integration.py b/skills/carve-changesets/scripts/tests/test_scripts_integration.py new file mode 100644 index 0000000..76ed96f --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_scripts_integration.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import shutil +import unittest + +from common import DEFAULT_PLAN_PATH +from legacy_helpers import SCRIPTS_DIR, init_remote, init_repo, run, write_plan + + +class ScriptIntegrationTests(unittest.TestCase): + def test_single_cli_exercises_ported_surface(self) -> None: + repo_dir, plan = init_repo() + remote_dir = None + try: + cli = str(SCRIPTS_DIR / "cli.py") + plan_path = repo_dir / DEFAULT_PLAN_PATH + remote_dir = init_remote(repo_dir) + run(["git", "checkout", "main"], cwd=repo_dir) + run(["git", "push", "-u", "origin", "main"], cwd=repo_dir) + run(["git", "checkout", plan["source_branch"]], cwd=repo_dir) + run(["git", "push", "-u", "origin", plan["source_branch"]], cwd=repo_dir) + + run( + [ + cli, + "init-plan", + "--base", + plan["base_branch"], + "--source", + plan["source_branch"], + "--title", + plan["feature_title"], + "--changesets", + "2", + "--force", + ], + cwd=repo_dir, + ) + write_plan(plan_path, plan) + run( + [ + cli, + "preflight", + "--base", + plan["base_branch"], + "--source", + plan["source_branch"], + "--skip-tests", + ], + cwd=repo_dir, + ) + run([cli, "validate"], cwd=repo_dir) + run([cli, "squash-ref"], cwd=repo_dir) + run([cli, "create-chain"], cwd=repo_dir) + run([cli, "status", "--local-only"], cwd=repo_dir) + run([cli, "squash-check"], cwd=repo_dir) + run( + [ + cli, + "validate-chain", + "--test-cmd", + "python3 -c \"print('ok')\"", + "--local-only", + ], + cwd=repo_dir, + ) + run([cli, "compare"], cwd=repo_dir) + run([cli, "pr-create"], cwd=repo_dir) + run([cli, "push-chain", "--remote", "origin"], cwd=repo_dir) + finally: + shutil.rmtree(repo_dir) + if remote_dir is not None: + shutil.rmtree(remote_dir.parent) + + def test_help_lists_mutation_class_for_every_operation(self) -> None: + result = run([str(SCRIPTS_DIR / "cli.py"), "--help"], cwd=SCRIPTS_DIR) + for mutation_class in ("read-only", "local-mutating", "remote-mutating"): + self.assertIn(f"[{mutation_class}]", result.stdout) + self.assertIn("remote mutation is dry-run", result.stdout) + self.assertIn("by default", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_squash.py b/skills/carve-changesets/scripts/tests/test_squash.py new file mode 100644 index 0000000..48eeef9 --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_squash.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import shutil +import unittest + +import helpers # noqa: F401 # ensures sys.path is set +from chain import create_chain +from legacy_helpers import chdir, init_repo, run +from squash_check import squash_check +from squash_ref import create_squashed_ref + + +class SquashReferenceTests(unittest.TestCase): + def test_squash_ref_creates_tree_equivalent_branch(self) -> None: + repo_dir, plan = init_repo() + try: + with chdir(repo_dir): + squashed = create_squashed_ref( + base=plan["base_branch"], + source=plan["source_branch"], + reuse_existing=False, + recreate=False, + ) + + source_tree = run( + ["git", "rev-parse", f"{plan['source_branch']}^{{tree}}"], cwd=repo_dir + ).stdout.strip() + squashed_tree = run( + ["git", "rev-parse", f"{squashed}^{{tree}}"], cwd=repo_dir + ).stdout.strip() + self.assertEqual(source_tree, squashed_tree) + finally: + shutil.rmtree(repo_dir) + + def test_squash_check_reports_no_diff_when_chain_matches_source(self) -> None: + repo_dir, plan = init_repo() + try: + with chdir(repo_dir): + create_squashed_ref( + base=plan["base_branch"], + source=plan["source_branch"], + reuse_existing=False, + recreate=False, + ) + create_chain(plan) + diffstat, namestatus = squash_check(plan) + + self.assertEqual(diffstat.strip(), "") + self.assertEqual(namestatus.strip(), "") + finally: + shutil.rmtree(repo_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_test_command_discovery.py b/skills/carve-changesets/scripts/tests/test_test_command_discovery.py new file mode 100644 index 0000000..3869bad --- /dev/null +++ b/skills/carve-changesets/scripts/tests/test_test_command_discovery.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +import shutil +import unittest + +import helpers # noqa: F401 # ensures sys.path is set +from common import discover_test_command +from legacy_helpers import chdir, init_repo + + +class TestCommandDiscoveryTests(unittest.TestCase): + def test_agents_single_command_is_selected(self) -> None: + repo_dir, _plan = init_repo() + try: + (repo_dir / "AGENTS.md").write_text("```bash\njust test\n```\n") + (repo_dir / "justfile").write_text("test:\n echo ok\n") + with chdir(repo_dir): + discovery = discover_test_command("") + self.assertEqual(discovery["command"], "just test") + self.assertEqual(discovery["source"], "agents") + finally: + shutil.rmtree(repo_dir) + + def test_agents_ambiguous_returns_suggestions(self) -> None: + repo_dir, _plan = init_repo() + try: + (repo_dir / "AGENTS.md").write_text("```bash\njust test\nmake test\n```\n") + (repo_dir / "justfile").write_text("test:\n echo ok\n") + with chdir(repo_dir): + discovery = discover_test_command("") + self.assertIsNone(discovery["command"]) + self.assertEqual(discovery["reason"], "agents-ambiguous") + self.assertIn("just test", discovery["suggestions"]) + finally: + shutil.rmtree(repo_dir) + + def test_agents_ignores_key_value_metadata_lines(self) -> None: + repo_dir, _plan = init_repo() + try: + (repo_dir / "AGENTS.md").write_text( + "```yaml\ncontextKey: 'test-context'\nmoon :test\n```\n" + ) + with chdir(repo_dir): + discovery = discover_test_command("") + self.assertEqual(discovery["command"], "moon :test") + self.assertEqual(discovery["source"], "agents") + finally: + shutil.rmtree(repo_dir) + + def test_missing_agents_suggests_just_test(self) -> None: + repo_dir, _plan = init_repo() + try: + (repo_dir / "justfile").write_text("test:\n echo ok\n") + with chdir(repo_dir): + discovery = discover_test_command("") + self.assertIsNone(discovery["command"]) + self.assertEqual(discovery["reason"], "agents-missing") + self.assertIn("just test", discovery["suggestions"]) + finally: + shutil.rmtree(repo_dir) + + def test_package_json_suggestions_include_tool_and_script(self) -> None: + repo_dir, _plan = init_repo() + try: + (repo_dir / "package.json").write_text( + json.dumps({"scripts": {"test": "vitest run"}}, indent=2) + "\n" + ) + with chdir(repo_dir): + discovery = discover_test_command("") + self.assertIsNone(discovery["command"]) + self.assertIn("npm test", discovery["suggestions"]) + self.assertIn("vitest run", discovery["suggestions"]) + finally: + shutil.rmtree(repo_dir) + + +if __name__ == "__main__": + unittest.main() From d4b071ff46b7b4f2bf8b256f9071d76325e4d146 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Tue, 21 Jul 2026 15:53:31 -0700 Subject: [PATCH 2/4] fix: close carve CLI review gaps ## Summary - Make status plan-free and bind strict validation to materialized live chains - Bind pull request publication to exact local and remote heads and verify the created PR - Point evals at the consolidated interface and standardize db_compare spelling ## Why - Preserve live truth after ephemeral plans are removed - Reject stale or rewritten candidates before they become remote artifacts - Keep the greenfield interface and tests internally consistent --- CHANGELOG.md | 2 + skills/carve-changesets/scripts/cli.py | 52 ++++++--- skills/carve-changesets/scripts/db_compare.py | 2 +- .../scripts/evals/prompts.csv | 4 +- skills/carve-changesets/scripts/github.py | 109 +++++++++++++++++- .../scripts/tests/test_cli_safety.py | 7 ++ .../{test_dbcompare.py => test_db_compare.py} | 4 +- .../scripts/tests/test_evals.py | 13 +++ .../scripts/tests/test_github.py | 80 ++++++++++++- .../scripts/tests/test_scripts_integration.py | 108 ++++++++++++++++- 10 files changed, 354 insertions(+), 27 deletions(-) rename skills/carve-changesets/scripts/tests/{test_dbcompare.py => test_db_compare.py} (92%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7168d3d..f307e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ summary: Chronological history of repository and skill changes. ## 2026-07-21 — Consolidated carve-changesets CLI and live contract +- fix: close consolidated carve CLI review gaps - feat: add the consolidated carve-changesets CLI + (`0d942c50cff2d9472b664e74d423661c9f1693cb`) - fix: bind changeset validation to current live refs (`721a1ea07c0e8d8af1265bbf70326afaf286aa4a`) - feat: validate changeset chains from live git diff --git a/skills/carve-changesets/scripts/cli.py b/skills/carve-changesets/scripts/cli.py index 81823a8..3717f7d 100755 --- a/skills/carve-changesets/scripts/cli.py +++ b/skills/carve-changesets/scripts/cli.py @@ -23,11 +23,11 @@ from plan_checks import strict_apply_check, validate_plan_strict from preflight import preflight from propagate import push_chain -from rehydrate import RehydrationError, rehydrate_chain +from rehydrate import RehydrationError, discover_changeset_heads, rehydrate_chain from squash_check import squash_check from squash_ref import _resolve_base_source, create_squashed_ref from status import status_from_live -from validate import validate_live_chain +from validate import ChainValidation, validate_live_chain READ_ONLY = "read-only" LOCAL_MUTATING = "local-mutating" @@ -115,26 +115,49 @@ def cmd_validate(args: argparse.Namespace) -> None: "Strict plan validation failed: " + "; ".join(strict_errors) ) strict_apply_check(plan) + live_heads = discover_changeset_heads( + Path.cwd(), plan["source_branch"], args.remote + ) + if live_heads: + pull_requests = ( + [] + if args.local_only + else pull_requests_for_source(plan["source_branch"]) + ) + chain = rehydrate_chain( + source_branch=plan["source_branch"], + base_branch=plan["base_branch"], + pull_requests=pull_requests, + remote=args.remote, + ) + result = validate_live_chain(chain, remote=args.remote) + _print_live_diagnostics(result) + if not result.valid: + raise CommandError("Strict live chain validation failed.") print("[OK] Strict validation passed.") return print("[OK] Plan validation passed.") def cmd_status(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - pull_requests = ( - [] if args.local_only else pull_requests_for_source(plan["source_branch"]) - ) + pull_requests = [] if args.local_only else pull_requests_for_source(args.source) print( status_from_live( - source_branch=plan["source_branch"], - base_branch=plan["base_branch"], + source_branch=args.source, + base_branch=args.base, pull_requests=pull_requests, remote=args.remote, ) ) +def _print_live_diagnostics(result: ChainValidation) -> None: + for diagnostic in result.diagnostics: + print( + f"[{diagnostic.severity.upper()}] {diagnostic.code}: {diagnostic.message}" + ) + + def cmd_create_chain(args: argparse.Namespace) -> None: create_chain(load_and_validate(Path(args.plan))) @@ -161,10 +184,7 @@ def cmd_validate_chain(args: argparse.Namespace) -> None: remote=args.remote, ) result = validate_live_chain(chain, remote=args.remote) - for diagnostic in result.diagnostics: - print( - f"[{diagnostic.severity.upper()}] {diagnostic.code}: {diagnostic.message}" - ) + _print_live_diagnostics(result) if not result.valid: raise CommandError("Live chain validation failed.") print("[OK] Live chain ancestry and source equivalence passed.") @@ -176,7 +196,7 @@ def cmd_pr_create(args: argparse.Namespace) -> None: indices: List[int] = ( list(range(1, total + 1)) if args.index is None else [args.index] ) - pr_create(plan, indices=indices, dry_run=args.dry_run) + pr_create(plan, indices=indices, dry_run=args.dry_run, remote=args.remote) def cmd_push_chain(args: argparse.Namespace) -> None: @@ -321,10 +341,13 @@ def build_parser() -> argparse.ArgumentParser: item = _command(sub, "validate", "Validate the decomposition plan.") _add_plan(item) item.add_argument("--strict", action="store_true") + item.add_argument("--remote", default="origin") + item.add_argument("--local-only", action="store_true") item.set_defaults(func=cmd_validate) item = _command(sub, "status", "Render chain status from live git and GitHub.") - _add_plan(item) + item.add_argument("--source", required=True, help="Source branch") + item.add_argument("--base", default=None, help="Base branch") item.add_argument("--remote", default="origin") item.add_argument("--local-only", action="store_true") item.set_defaults(func=cmd_status) @@ -349,6 +372,7 @@ def build_parser() -> argparse.ArgumentParser: item = _command(sub, "pr-create", "Publish correctly based changeset PRs.") _add_plan(item) item.add_argument("--index", type=int) + item.add_argument("--remote", default="origin") _add_remote_dry_run(item) item.set_defaults(func=cmd_pr_create) diff --git a/skills/carve-changesets/scripts/db_compare.py b/skills/carve-changesets/scripts/db_compare.py index 66236e0..c9baeb9 100755 --- a/skills/carve-changesets/scripts/db_compare.py +++ b/skills/carve-changesets/scripts/db_compare.py @@ -45,7 +45,7 @@ def db_compare(plan: Dict, *, source_cmd: str, chain_cmd: str, out_dir: Path) -> source_out = out_dir / "source.txt" chain_out = out_dir / "chain.txt" - temp_branch = unique_temp_branch("carve-temp-dbcompare") + temp_branch = unique_temp_branch("carve-temp-db_compare") print(f"[INFO] Using output directory: {out_dir}") print(f"[INFO] Creating temporary branch: {temp_branch}") diff --git a/skills/carve-changesets/scripts/evals/prompts.csv b/skills/carve-changesets/scripts/evals/prompts.csv index 64a3d8d..0dda0ca 100644 --- a/skills/carve-changesets/scripts/evals/prompts.csv +++ b/skills/carve-changesets/scripts/evals/prompts.csv @@ -1,3 +1,3 @@ id,prompt -chain-basic,"You are in a git repo with base branch main, source branch feature/test, and a validated plan at .prepare-changesets/plan.json. Using the prepare-changesets skill, run preflight, create the changeset chain, validate mergeability with tests using python3 -c \"print('ok')\", and compare the fully merged chain to the source branch. Do not modify the source branch. Use scripts/*.py directly." -chain-compare,"Follow the prepare-changesets skill strictly. The plan is already validated at .prepare-changesets/plan.json for base main and source feature/test. Create the changeset chain, run validate-chain with python3 -c \"print('ok')\", and run compare. Keep the source branch immutable." +chain-basic,"You are in a git repo with base branch main, source branch feature/test, and a validated carve-changesets plan at .carve-changesets/plan.json. Using the carve-changesets skill, invoke its scripts/cli.py subcommands to run preflight, create the changeset chain, validate mergeability with tests using python3 -c \"print('ok')\", and compare the fully merged chain to the source branch. Do not modify the source branch." +chain-compare,"Follow the carve-changesets skill strictly. The plan is already validated at .carve-changesets/plan.json for base main and source feature/test. Invoke its scripts/cli.py subcommands to create the changeset chain, run validate-chain with python3 -c \"print('ok')\", and run compare. Keep the source branch immutable." diff --git a/skills/carve-changesets/scripts/github.py b/skills/carve-changesets/scripts/github.py index 0a5245e..760a78c 100644 --- a/skills/carve-changesets/scripts/github.py +++ b/skills/carve-changesets/scripts/github.py @@ -16,7 +16,13 @@ git, message_file, ) -from metadata import embed_pr_metadata, parse_commit_message +from metadata import ( + ChangesetMetadata, + MetadataError, + embed_pr_metadata, + parse_commit_message, + parse_pr_metadata, +) from rehydrate import PullRequestRecord @@ -102,7 +108,73 @@ def _print_command(command: Sequence[str]) -> None: print(" ".join(subprocess.list2cmdline([part]) for part in command)) -def pr_create(plan: Dict, *, indices: List[int], dry_run: bool) -> None: +def _local_remote_head(branch: str, remote: str) -> str: + local_result = git( + "rev-parse", "--verify", f"refs/heads/{branch}^{{commit}}", check=False + ) + if local_result.returncode != 0: + raise CommandError(f"Local changeset branch {branch!r} does not exist.") + local_head = local_result.stdout.strip() + remote_result = git( + "ls-remote", "--heads", remote, f"refs/heads/{branch}", check=False + ) + if remote_result.returncode != 0: + detail = (remote_result.stderr or remote_result.stdout or "").strip() + raise CommandError( + f"Could not resolve {remote} changeset branch {branch!r}: {detail}" + ) + fields = remote_result.stdout.strip().split() + if len(fields) != 2 or fields[1] != f"refs/heads/{branch}": + raise CommandError( + f"Remote changeset branch {remote}/{branch} does not exist; run push-chain first." + ) + remote_head = fields[0] + if local_head != remote_head: + raise CommandError( + f"Changeset branch {branch} is not publication-ready: local head " + f"{local_head} differs from {remote} head {remote_head}." + ) + return local_head + + +def _verify_created_pr( + created: object, + *, + head: str, + expected_head: str, + expected_base: str, + expected_metadata: ChangesetMetadata, +) -> Dict: + if not isinstance(created, dict): + raise CommandError(f"PR for {head} was created but could not be verified.") + if str(created.get("headRefOid") or "") != expected_head: + raise CommandError( + f"Created PR for {head} has head {created.get('headRefOid')}; " + f"expected {expected_head}." + ) + if str(created.get("baseRefName") or "") != expected_base: + raise CommandError( + f"Created PR for {head} has base {created.get('baseRefName')!r}; " + f"expected {expected_base!r}." + ) + try: + actual_metadata = parse_pr_metadata(str(created.get("body") or "")) + except MetadataError as exc: + raise CommandError( + f"Created PR for {head} has invalid changeset metadata: {exc}" + ) from exc + if actual_metadata != expected_metadata: + raise CommandError( + f"Created PR for {head} metadata does not match its exact changeset commit." + ) + if not created.get("number") or not created.get("url"): + raise CommandError(f"Created PR for {head} is missing its number or URL.") + return created + + +def pr_create( + plan: Dict, *, indices: List[int], dry_run: bool, remote: str = "origin" +) -> None: ensure_git_repo() ensure_clean_tree() if not dry_run: @@ -115,10 +187,24 @@ def pr_create(plan: Dict, *, indices: List[int], dry_run: bool) -> None: for index in indices: if index < 1 or index > total: raise CommandError(f"--index must be between 1 and {total}.") + expected_heads = ( + { + branch_name_for(source, index): _local_remote_head( + branch_name_for(source, index), remote + ) + for index in indices + } + if not dry_run + else {} + ) + for index in indices: head = branch_name_for(source, index) pr_base = base_for_changeset(base, source, index) title = pr_title_for(plan["feature_title"], index, total) body = pr_body_for(plan, index, total, changesets[index - 1]) + expected_metadata = parse_commit_message( + git("show", "-s", "--format=%B", head).stdout + ) with message_file(body) as body_path: args = ( "pr", @@ -137,10 +223,23 @@ def pr_create(plan: Dict, *, indices: List[int], dry_run: bool) -> None: print("[DRY-RUN] Would run:") _print_command(("gh", *args)) continue + expected_head = expected_heads[head] gh_capture(args) - created = gh_json(("pr", "view", head, "--json", "number,url")) - if not isinstance(created, dict): - raise CommandError(f"PR for {head} was created but could not be verified.") + created = _verify_created_pr( + gh_json( + ( + "pr", + "view", + head, + "--json", + "number,url,headRefOid,baseRefName,body", + ) + ), + head=head, + expected_head=expected_head, + expected_base=pr_base, + expected_metadata=expected_metadata, + ) print(f"[OK] PR #{created['number']} created: {created['url']}") if dry_run: diff --git a/skills/carve-changesets/scripts/tests/test_cli_safety.py b/skills/carve-changesets/scripts/tests/test_cli_safety.py index cea56ba..cdc6e89 100644 --- a/skills/carve-changesets/scripts/tests/test_cli_safety.py +++ b/skills/carve-changesets/scripts/tests/test_cli_safety.py @@ -44,6 +44,13 @@ def test_only_github_module_invokes_gh(self) -> None: self.assertNotIn('["gh",', source, path.name) self.assertNotIn('("gh",', source, path.name) + def test_database_compare_spelling_is_standardized(self) -> None: + scripts = Path(__file__).resolve().parents[1] + disallowed = "db" + "compare" + for path in scripts.rglob("*.py"): + self.assertNotIn(disallowed, path.name, str(path)) + self.assertNotIn(disallowed, path.read_text(), str(path)) + if __name__ == "__main__": unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_dbcompare.py b/skills/carve-changesets/scripts/tests/test_db_compare.py similarity index 92% rename from skills/carve-changesets/scripts/tests/test_dbcompare.py rename to skills/carve-changesets/scripts/tests/test_db_compare.py index d5f7534..d6bc2fd 100644 --- a/skills/carve-changesets/scripts/tests/test_dbcompare.py +++ b/skills/carve-changesets/scripts/tests/test_db_compare.py @@ -3,7 +3,7 @@ import shutil import unittest -import db_compare as dbcompare_mod +import db_compare as db_compare_mod from chain import create_chain from legacy_helpers import chdir, init_repo @@ -15,7 +15,7 @@ def test_db_compare_creates_outputs(self) -> None: out_dir = repo_dir / ".carve-changesets" / "db-compare-test" with chdir(repo_dir): create_chain(plan) - dbcompare_mod.db_compare( + db_compare_mod.db_compare( plan, source_cmd="cat a.txt", chain_cmd="cat a.txt", diff --git a/skills/carve-changesets/scripts/tests/test_evals.py b/skills/carve-changesets/scripts/tests/test_evals.py index 1983ce9..d3a59a0 100644 --- a/skills/carve-changesets/scripts/tests/test_evals.py +++ b/skills/carve-changesets/scripts/tests/test_evals.py @@ -54,6 +54,19 @@ def test_grader_detects_source_branch_mutation(self) -> None: class EvalRunnerTests(unittest.TestCase): + def test_prompts_use_only_the_consolidated_carve_interface(self) -> None: + prompts_path = Path(__file__).resolve().parents[1] / "evals" / "prompts.csv" + prompts = prompts_path.read_text() + self.assertIn("carve-changesets skill", prompts) + self.assertIn("scripts/cli.py", prompts) + self.assertIn(".carve-changesets/plan.json", prompts) + for stale in ( + "prepare" + "-changesets", + ".prepare" + "-changesets", + "scripts/" + "*.py", + ): + self.assertNotIn(stale, prompts) + def test_runner_skip_codex_produces_passing_summary(self) -> None: prompts_path = Path(__file__).resolve().parents[1] / "evals" / "prompts.csv" out_dir = Path(tempfile.mkdtemp(prefix="pcs-eval-out-")) diff --git a/skills/carve-changesets/scripts/tests/test_github.py b/skills/carve-changesets/scripts/tests/test_github.py index 7957609..bd3a938 100644 --- a/skills/carve-changesets/scripts/tests/test_github.py +++ b/skills/carve-changesets/scripts/tests/test_github.py @@ -8,7 +8,7 @@ import github as github_mod from chain import create_chain from common import CommandError -from legacy_helpers import chdir, init_repo +from legacy_helpers import chdir, commit, init_remote, init_repo, run class GithubTests(unittest.TestCase): @@ -51,6 +51,84 @@ def test_gh_capture_allows_only_named_return_codes(self) -> None: with self.assertRaisesRegex(CommandError, "GitHub CLI command failed"): github_mod.gh_capture(("example",)) + def test_pr_create_binds_and_verifies_exact_remote_candidate(self) -> None: + repo_dir, plan = init_repo() + remote_dir = None + try: + remote_dir = init_remote(repo_dir) + with chdir(repo_dir): + create_chain(plan) + run(["git", "push", "origin", "feature/test-1"], cwd=repo_dir) + head = run( + ["git", "rev-parse", "feature/test-1"], cwd=repo_dir + ).stdout.strip() + body = github_mod.pr_body_for( + plan, 1, len(plan["changesets"]), plan["changesets"][0] + ) + created = { + "number": 91, + "url": "https://example.test/pr/91", + "headRefOid": head, + "baseRefName": "main", + "body": body, + } + with ( + mock.patch.object(github_mod, "ensure_gh_ready"), + mock.patch.object(github_mod, "gh_capture") as create_call, + mock.patch.object( + github_mod, "gh_json", return_value=created + ) as view, + ): + github_mod.pr_create( + plan, indices=[1], dry_run=False, remote="origin" + ) + + create_call.assert_called_once() + self.assertIn("--body-file", create_call.call_args.args[0]) + self.assertEqual( + ( + "pr", + "view", + "feature/test-1", + "--json", + "number,url,headRefOid,baseRefName,body", + ), + view.call_args.args[0], + ) + finally: + shutil.rmtree(repo_dir) + if remote_dir is not None: + shutil.rmtree(remote_dir.parent) + + def test_pr_create_rejects_local_head_not_on_remote(self) -> None: + repo_dir, plan = init_repo() + remote_dir = None + try: + remote_dir = init_remote(repo_dir) + with chdir(repo_dir): + create_chain(plan) + run(["git", "push", "origin", "feature/test-1"], cwd=repo_dir) + run(["git", "checkout", "feature/test-1"], cwd=repo_dir) + (repo_dir / "local-only.txt").write_text("not published\n") + run(["git", "add", "local-only.txt"], cwd=repo_dir) + old_message = run( + ["git", "show", "-s", "--format=%B", "HEAD"], cwd=repo_dir + ).stdout + commit(repo_dir, old_message) + with ( + mock.patch.object(github_mod, "ensure_gh_ready"), + mock.patch.object(github_mod, "gh_capture") as create_call, + ): + with self.assertRaisesRegex(CommandError, "differs from origin"): + github_mod.pr_create( + plan, indices=[1], dry_run=False, remote="origin" + ) + create_call.assert_not_called() + finally: + shutil.rmtree(repo_dir) + if remote_dir is not None: + shutil.rmtree(remote_dir.parent) + if __name__ == "__main__": unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_scripts_integration.py b/skills/carve-changesets/scripts/tests/test_scripts_integration.py index 76ed96f..0f6031d 100644 --- a/skills/carve-changesets/scripts/tests/test_scripts_integration.py +++ b/skills/carve-changesets/scripts/tests/test_scripts_integration.py @@ -4,7 +4,7 @@ import unittest from common import DEFAULT_PLAN_PATH -from legacy_helpers import SCRIPTS_DIR, init_remote, init_repo, run, write_plan +from legacy_helpers import SCRIPTS_DIR, commit, init_remote, init_repo, run, write_plan class ScriptIntegrationTests(unittest.TestCase): @@ -52,7 +52,18 @@ def test_single_cli_exercises_ported_surface(self) -> None: run([cli, "validate"], cwd=repo_dir) run([cli, "squash-ref"], cwd=repo_dir) run([cli, "create-chain"], cwd=repo_dir) - run([cli, "status", "--local-only"], cwd=repo_dir) + run( + [ + cli, + "status", + "--source", + plan["source_branch"], + "--base", + plan["base_branch"], + "--local-only", + ], + cwd=repo_dir, + ) run([cli, "squash-check"], cwd=repo_dir) run( [ @@ -79,6 +90,99 @@ def test_help_lists_mutation_class_for_every_operation(self) -> None: self.assertIn("remote mutation is dry-run", result.stdout) self.assertIn("by default", result.stdout) + def test_status_rehydrates_without_a_plan(self) -> None: + repo_dir, plan = init_repo() + try: + cli = str(SCRIPTS_DIR / "cli.py") + plan_path = repo_dir / DEFAULT_PLAN_PATH + write_plan(plan_path, plan) + run([cli, "create-chain"], cwd=repo_dir) + shutil.rmtree(plan_path.parent) + + result = run( + [ + cli, + "status", + "--source", + plan["source_branch"], + "--base", + plan["base_branch"], + "--local-only", + ], + cwd=repo_dir, + ) + + self.assertIn("feature/test-1", result.stdout) + self.assertIn("feature/test-2", result.stdout) + finally: + shutil.rmtree(repo_dir) + + def test_strict_validate_rejects_a_rewritten_middle_branch(self) -> None: + repo_dir, plan = init_repo() + try: + cli = str(SCRIPTS_DIR / "cli.py") + plan_path = repo_dir / DEFAULT_PLAN_PATH + write_plan(plan_path, plan) + run([cli, "create-chain"], cwd=repo_dir) + message = run( + ["git", "show", "-s", "--format=%B", "feature/test-2"], + cwd=repo_dir, + ).stdout + run(["git", "checkout", "-b", "replacement", "main"], cwd=repo_dir) + run( + ["git", "checkout", "feature/test", "--", "a.txt", "b.txt", "c.txt"], + cwd=repo_dir, + ) + run(["git", "add", "a.txt", "b.txt", "c.txt"], cwd=repo_dir) + commit(repo_dir, message) + replacement = run(["git", "rev-parse", "HEAD"], cwd=repo_dir).stdout.strip() + run( + ["git", "update-ref", "refs/heads/feature/test-2", replacement], + cwd=repo_dir, + ) + + result = run( + [cli, "validate", "--strict", "--local-only"], + cwd=repo_dir, + check=False, + ) + + self.assertEqual(1, result.returncode) + self.assertIn("predecessor_ancestry_broken", result.stdout) + finally: + shutil.rmtree(repo_dir) + + def test_strict_validate_rejects_different_source_history(self) -> None: + repo_dir, plan = init_repo() + try: + cli = str(SCRIPTS_DIR / "cli.py") + plan_path = repo_dir / DEFAULT_PLAN_PATH + write_plan(plan_path, plan) + run([cli, "create-chain"], cwd=repo_dir) + run(["git", "checkout", "-b", "alternate-source", "main"], cwd=repo_dir) + run( + ["git", "checkout", "feature/test", "--", "a.txt", "b.txt", "c.txt"], + cwd=repo_dir, + ) + run(["git", "add", "a.txt", "b.txt", "c.txt"], cwd=repo_dir) + commit(repo_dir, "alternate source history") + alternate = run(["git", "rev-parse", "HEAD"], cwd=repo_dir).stdout.strip() + run( + ["git", "update-ref", "refs/heads/feature/test", alternate], + cwd=repo_dir, + ) + + result = run( + [cli, "validate", "--strict", "--local-only"], + cwd=repo_dir, + check=False, + ) + + self.assertEqual(1, result.returncode) + self.assertIn("source_history_mismatch", result.stdout) + finally: + shutil.rmtree(repo_dir) + if __name__ == "__main__": unittest.main() From cfdddb0aeb792fabfb4021173e25738b45329083 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Tue, 21 Jul 2026 16:02:52 -0700 Subject: [PATCH 3/4] fix: bind GitHub operations to selected remote ## Summary - Resolve the selected git remote to an exact GitHub host, owner, and repository - Pass that repository explicitly to every pull request create, view, and list call - Cover a non-default enterprise remote while a conflicting origin and GH_REPO exist ## Why - Keep live git validation and GitHub reads and mutations on the same repository - Prevent ambient CLI configuration from redirecting candidate-bound operations --- CHANGELOG.md | 2 + skills/carve-changesets/scripts/cli.py | 12 +- skills/carve-changesets/scripts/github.py | 58 ++++++++- .../scripts/tests/test_github.py | 115 +++++++++++++++++- .../scripts/tests/test_scripts_integration.py | 12 +- 5 files changed, 189 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f307e17..5cb1da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ summary: Chronological history of repository and skill changes. ## 2026-07-21 — Consolidated carve-changesets CLI and live contract +- fix: bind GitHub operations to the selected remote - fix: close consolidated carve CLI review gaps + (`d4b071ff46b7b4f2bf8b256f9071d76325e4d146`) - feat: add the consolidated carve-changesets CLI (`0d942c50cff2d9472b664e74d423661c9f1693cb`) - fix: bind changeset validation to current live refs diff --git a/skills/carve-changesets/scripts/cli.py b/skills/carve-changesets/scripts/cli.py index 3717f7d..e951720 100755 --- a/skills/carve-changesets/scripts/cli.py +++ b/skills/carve-changesets/scripts/cli.py @@ -122,7 +122,7 @@ def cmd_validate(args: argparse.Namespace) -> None: pull_requests = ( [] if args.local_only - else pull_requests_for_source(plan["source_branch"]) + else pull_requests_for_source(plan["source_branch"], remote=args.remote) ) chain = rehydrate_chain( source_branch=plan["source_branch"], @@ -140,7 +140,11 @@ def cmd_validate(args: argparse.Namespace) -> None: def cmd_status(args: argparse.Namespace) -> None: - pull_requests = [] if args.local_only else pull_requests_for_source(args.source) + pull_requests = ( + [] + if args.local_only + else pull_requests_for_source(args.source, remote=args.remote) + ) print( status_from_live( source_branch=args.source, @@ -175,7 +179,9 @@ def cmd_validate_chain(args: argparse.Namespace) -> None: test_cmd = str(args.test_cmd or plan.get("test_command", "")).strip() validate_chain(plan, test_cmd=test_cmd) pull_requests = ( - [] if args.local_only else pull_requests_for_source(plan["source_branch"]) + [] + if args.local_only + else pull_requests_for_source(plan["source_branch"], remote=args.remote) ) chain = rehydrate_chain( source_branch=plan["source_branch"], diff --git a/skills/carve-changesets/scripts/github.py b/skills/carve-changesets/scripts/github.py index 760a78c..9434406 100644 --- a/skills/carve-changesets/scripts/github.py +++ b/skills/carve-changesets/scripts/github.py @@ -4,8 +4,10 @@ from __future__ import annotations import json +import re import subprocess from typing import Dict, Iterable, List, Sequence +from urllib.parse import urlparse from common import ( CommandError, @@ -73,8 +75,46 @@ def gh_json(args: Sequence[str], *, allowed_returncodes: Iterable[int] = ()) -> ) from exc -def ensure_gh_ready() -> None: - gh_capture(("auth", "status")) +def github_repo_for_remote(remote: str) -> str: + """Resolve one selected git remote to an exact gh HOST/OWNER/REPO target.""" + + remotes = git("remote").stdout.splitlines() + if remote not in remotes: + raise CommandError(f"Git remote {remote!r} does not exist.") + configured = git("config", "--get-all", f"remote.{remote}.url").stdout.splitlines() + urls = [url.strip() for url in configured if url.strip()] + if len(urls) != 1: + raise CommandError( + f"Git remote {remote!r} must have exactly one fetch URL; found {len(urls)}." + ) + raw_url = urls[0] + host = "" + path = "" + if "://" in raw_url: + parsed = urlparse(raw_url) + host = parsed.hostname or "" + path = parsed.path + else: + scp_match = re.fullmatch(r"(?:[^@/]+@)?([^:/]+):(.+)", raw_url) + if scp_match is not None: + host, path = scp_match.groups() + parts = [part for part in path.strip("/").split("/") if part] + if len(parts) != 2 or not host: + raise CommandError( + f"Git remote {remote!r} URL is not an exact GitHub repository: {raw_url}" + ) + owner, repo = parts + repo = repo.removesuffix(".git") + if not owner or not repo: + raise CommandError( + f"Git remote {remote!r} URL is not an exact GitHub repository: {raw_url}" + ) + return f"{host.lower()}/{owner}/{repo}" + + +def ensure_gh_ready(repository: str) -> None: + host = repository.split("/", 1)[0] + gh_capture(("auth", "status", "--hostname", host)) def pr_title_for(feature_title: str, index: int, total: int) -> str: @@ -177,8 +217,9 @@ def pr_create( ) -> None: ensure_git_repo() ensure_clean_tree() + repository = github_repo_for_remote(remote) if not dry_run: - ensure_gh_ready() + ensure_gh_ready(repository) base = plan["base_branch"] source = plan["source_branch"] @@ -209,6 +250,8 @@ def pr_create( args = ( "pr", "create", + "-R", + repository, "--base", pr_base, "--head", @@ -231,6 +274,8 @@ def pr_create( "pr", "view", head, + "-R", + repository, "--json", "number,url,headRefOid,baseRefName,body", ) @@ -246,13 +291,18 @@ def pr_create( print("[OK] Dry-run complete. Re-run with --no-dry-run to execute.") -def pull_requests_for_source(source_branch: str) -> list[PullRequestRecord]: +def pull_requests_for_source( + source_branch: str, *, remote: str = "origin" +) -> list[PullRequestRecord]: """Return complete GitHub PR evidence needed by live rehydration.""" + repository = github_repo_for_remote(remote) payload = gh_json( ( "pr", "list", + "-R", + repository, "--state", "all", "--limit", diff --git a/skills/carve-changesets/scripts/tests/test_github.py b/skills/carve-changesets/scripts/tests/test_github.py index bd3a938..632fd43 100644 --- a/skills/carve-changesets/scripts/tests/test_github.py +++ b/skills/carve-changesets/scripts/tests/test_github.py @@ -18,14 +18,22 @@ def test_pr_create_dry_run_uses_body_file(self) -> None: with chdir(repo_dir): create_chain(plan) captured: list[tuple[str, ...]] = [] - with mock.patch.object( - github_mod, "_print_command", side_effect=captured.append + with ( + mock.patch.object( + github_mod, + "github_repo_for_remote", + return_value="github.com/acme/widgets", + ), + mock.patch.object( + github_mod, "_print_command", side_effect=captured.append + ), ): github_mod.pr_create(plan, indices=[1, 2], dry_run=True) self.assertEqual(2, len(captured)) for command in captured: self.assertEqual(("gh", "pr", "create"), command[:3]) + self.assertIn("github.com/acme/widgets", command) self.assertIn("--body-file", command) self.assertNotIn("--body", command) finally: @@ -73,6 +81,11 @@ def test_pr_create_binds_and_verifies_exact_remote_candidate(self) -> None: "body": body, } with ( + mock.patch.object( + github_mod, + "github_repo_for_remote", + return_value="github.com/acme/widgets", + ), mock.patch.object(github_mod, "ensure_gh_ready"), mock.patch.object(github_mod, "gh_capture") as create_call, mock.patch.object( @@ -90,6 +103,8 @@ def test_pr_create_binds_and_verifies_exact_remote_candidate(self) -> None: "pr", "view", "feature/test-1", + "-R", + "github.com/acme/widgets", "--json", "number,url,headRefOid,baseRefName,body", ), @@ -116,6 +131,11 @@ def test_pr_create_rejects_local_head_not_on_remote(self) -> None: ).stdout commit(repo_dir, old_message) with ( + mock.patch.object( + github_mod, + "github_repo_for_remote", + return_value="github.com/acme/widgets", + ), mock.patch.object(github_mod, "ensure_gh_ready"), mock.patch.object(github_mod, "gh_capture") as create_call, ): @@ -129,6 +149,97 @@ def test_pr_create_rejects_local_head_not_on_remote(self) -> None: if remote_dir is not None: shutil.rmtree(remote_dir.parent) + def test_non_default_remote_binds_all_gh_pr_calls_to_its_repository( + self, + ) -> None: + repo_dir, plan = init_repo() + try: + with chdir(repo_dir): + create_chain(plan) + run( + ["git", "remote", "add", "origin", "git@github.com:wrong/repo.git"], + cwd=repo_dir, + ) + run( + [ + "git", + "remote", + "add", + "release", + "ssh://git@github.enterprise.test/acme/widgets.git", + ], + cwd=repo_dir, + ) + head = run( + ["git", "rev-parse", "feature/test-1"], cwd=repo_dir + ).stdout.strip() + body = github_mod.pr_body_for( + plan, 1, len(plan["changesets"]), plan["changesets"][0] + ) + created = { + "number": 92, + "url": "https://github.enterprise.test/acme/widgets/pull/92", + "headRefOid": head, + "baseRefName": "main", + "body": body, + } + with ( + mock.patch.dict("os.environ", {"GH_REPO": "wrong/other"}), + mock.patch.object(github_mod, "ensure_gh_ready") as auth, + mock.patch.object( + github_mod, "_local_remote_head", return_value=head + ), + mock.patch.object(github_mod, "gh_capture") as create_call, + mock.patch.object( + github_mod, "gh_json", return_value=created + ) as view_call, + ): + github_mod.pr_create( + plan, indices=[1], dry_run=False, remote="release" + ) + + repository = "github.enterprise.test/acme/widgets" + auth.assert_called_once_with(repository) + self.assertIn( + ("-R", repository), + list( + zip( + create_call.call_args.args[0], + create_call.call_args.args[0][1:], + ) + ), + ) + self.assertIn( + ("-R", repository), + list( + zip( + view_call.call_args.args[0], + view_call.call_args.args[0][1:], + ) + ), + ) + + with mock.patch.object( + github_mod, "gh_json", return_value=[] + ) as list_call: + self.assertEqual( + [], + github_mod.pull_requests_for_source( + plan["source_branch"], remote="release" + ), + ) + self.assertIn( + ("-R", repository), + list( + zip( + list_call.call_args.args[0], + list_call.call_args.args[0][1:], + ) + ), + ) + finally: + shutil.rmtree(repo_dir) + if __name__ == "__main__": unittest.main() diff --git a/skills/carve-changesets/scripts/tests/test_scripts_integration.py b/skills/carve-changesets/scripts/tests/test_scripts_integration.py index 0f6031d..35286f2 100644 --- a/skills/carve-changesets/scripts/tests/test_scripts_integration.py +++ b/skills/carve-changesets/scripts/tests/test_scripts_integration.py @@ -76,8 +76,18 @@ def test_single_cli_exercises_ported_surface(self) -> None: cwd=repo_dir, ) run([cli, "compare"], cwd=repo_dir) - run([cli, "pr-create"], cwd=repo_dir) run([cli, "push-chain", "--remote", "origin"], cwd=repo_dir) + run( + [ + "git", + "remote", + "set-url", + "origin", + "git@github.com:example/carve-eval.git", + ], + cwd=repo_dir, + ) + run([cli, "pr-create"], cwd=repo_dir) finally: shutil.rmtree(repo_dir) if remote_dir is not None: From c8ca89566562d7d154bfe1a1711140323e3ba9f8 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Tue, 21 Jul 2026 16:18:14 -0700 Subject: [PATCH 4/4] refactor: make strict apply use one proof ## Summary - Remove the eager patch and hunk precheck from strict validation - Let apply_changeset provide the single strict-apply viability proof - Delete the now-unused patch-check helpers ## Why - Avoid running two implementations of the same patch applicability check - Keep strict validation behavior aligned with actual chain materialization --- CHANGELOG.md | 2 ++ .../carve-changesets/scripts/patch_apply.py | 35 ------------------- .../carve-changesets/scripts/plan_checks.py | 22 ------------ 3 files changed, 2 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb1da8..5d3fda9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ summary: Chronological history of repository and skill changes. ## 2026-07-21 — Consolidated carve-changesets CLI and live contract +- refactor: make strict apply use one proof - fix: bind GitHub operations to the selected remote + (`cfdddb0aeb792fabfb4021173e25738b45329083`) - fix: close consolidated carve CLI review gaps (`d4b071ff46b7b4f2bf8b256f9071d76325e4d146`) - feat: add the consolidated carve-changesets CLI diff --git a/skills/carve-changesets/scripts/patch_apply.py b/skills/carve-changesets/scripts/patch_apply.py index 21d4c78..cb9b9f7 100755 --- a/skills/carve-changesets/scripts/patch_apply.py +++ b/skills/carve-changesets/scripts/patch_apply.py @@ -387,33 +387,6 @@ def apply_patch_text(patch_text: str, *, label: str) -> None: patch_path.unlink(missing_ok=True) -def check_patch_text(patch_text: str, *, label: str) -> None: - if not patch_text.strip(): - raise CommandError(f"{label}: patch is empty.") - - with tempfile.NamedTemporaryFile("w", delete=False, prefix="pcs-patch-") as handle: - handle.write(patch_text) - patch_path = Path(handle.name) - - try: - result = git( - "apply", - "--check", - "--3way", - "--whitespace=nowarn", - str(patch_path), - check=False, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - raise CommandError( - f"{label}: git apply --check failed.\n" - f"{detail or 'Patch did not apply cleanly.'}" - ) - finally: - patch_path.unlink(missing_ok=True) - - def resolve_patch_path(patch_file: str) -> Path: raw = Path(patch_file) return raw if raw.is_absolute() else repo_root() / raw @@ -425,11 +398,3 @@ def apply_patch_file(patch_file: str, *, label: str) -> None: raise CommandError(f"{label}: patch file not found: {patch_path}") patch_text = patch_path.read_text() apply_patch_text(patch_text, label=label) - - -def check_patch_file(patch_file: str, *, label: str) -> None: - patch_path = resolve_patch_path(patch_file) - if not patch_path.exists(): - raise CommandError(f"{label}: patch file not found: {patch_path}") - patch_text = patch_path.read_text() - check_patch_text(patch_text, label=label) diff --git a/skills/carve-changesets/scripts/plan_checks.py b/skills/carve-changesets/scripts/plan_checks.py index dc5f512..ff04e43 100644 --- a/skills/carve-changesets/scripts/plan_checks.py +++ b/skills/carve-changesets/scripts/plan_checks.py @@ -22,8 +22,6 @@ ) from patch_apply import ( build_diff, - check_patch_file, - check_patch_text, parse_hunk_selectors, select_hunks_for_changeset, ) @@ -252,32 +250,12 @@ def strict_apply_check(plan: Dict) -> None: temp_branch = unique_temp_branch("pcs-temp-strict-check") print(f"[INFO] Creating temporary strict-check branch: {temp_branch}") - diff_files = build_diff(base, source) - with checkout_restore() as original: try: git("checkout", "-B", temp_branch, base) source_sha = git("rev-parse", source).stdout.strip() for idx, cs in enumerate(changesets, start=1): print(f"[STEP] Strict-apply changeset {idx}") - mode = str(cs.get("mode", "paths")).strip() or "paths" - if mode == "patch": - patch_file = cs.get("patch_file", "") - check_patch_file(str(patch_file), label=f"Changeset {idx}") - elif mode == "hunks": - selectors = cs.get("hunk_selectors", []) - parsed = parse_hunk_selectors( - selectors, changeset_label=f"Changeset {idx}" - ) - selected = select_hunks_for_changeset( - diff_files, - parsed, - include_paths=cs.get("include_paths", []), - exclude_paths=cs.get("exclude_paths", []), - allow_partial_files=bool(cs.get("allow_partial_files", True)), - changeset_label=f"Changeset {idx}", - ) - check_patch_text(selected.text, label=f"Changeset {idx}") apply_changeset( base_branch=base, source_branch=source,