Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ summary: Chronological history of repository and skill changes.

## 2026-07-21 — Consolidated carve-changesets CLI and live contract

- feat: add stateless changeset merge and propagation
- refactor: make strict apply use one proof
(`c8ca89566562d7d154bfe1a1711140323e3ba9f8`)
- fix: bind GitHub operations to the selected remote
(`cfdddb0aeb792fabfb4021173e25738b45329083`)
- fix: close consolidated carve CLI review gaps
Expand Down
68 changes: 67 additions & 1 deletion skills/carve-changesets/scripts/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
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 propagate import merge_propagate_from_live, propagate_from_live, push_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
Expand All @@ -43,6 +43,8 @@
"validate-chain": LOCAL_MUTATING,
"pr-create": REMOTE_MUTATING,
"push-chain": REMOTE_MUTATING,
"propagate": REMOTE_MUTATING,
"merge-propagate": REMOTE_MUTATING,
"db-compare": LOCAL_MUTATING,
"hunk-preview": READ_ONLY,
"squash-ref": LOCAL_MUTATING,
Expand Down Expand Up @@ -213,6 +215,33 @@ def cmd_push_chain(args: argparse.Namespace) -> None:
)


def cmd_propagate(args: argparse.Namespace) -> None:
propagate_from_live(
source=args.source,
base=args.base,
pr_number=args.pr,
index=args.index,
strategy=args.strategy,
remote=args.remote,
dry_run=args.dry_run,
authority_acknowledged=args.ack_merge_and_propagate,
)


def cmd_merge_propagate(args: argparse.Namespace) -> None:
merge_propagate_from_live(
source=args.source,
base=args.base,
pr_number=args.pr,
index=args.index,
strategy=args.strategy,
method=args.method,
remote=args.remote,
dry_run=args.dry_run,
authority_acknowledged=args.ack_merge_and_propagate,
)


def cmd_db_compare(args: argparse.Namespace) -> None:
db_compare(
load_and_validate(Path(args.plan)),
Expand Down Expand Up @@ -321,6 +350,24 @@ def _add_remote_dry_run(parser: argparse.ArgumentParser) -> None:
parser.set_defaults(dry_run=True)


def _add_propagation_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--source", required=True, help="Source branch")
parser.add_argument("--base", default=None, help="Base branch")
target = parser.add_mutually_exclusive_group(required=True)
target.add_argument("--pr", type=int, help="Changeset pull request number")
target.add_argument("--index", type=int, help="One-based changeset index")
parser.add_argument(
"--strategy", choices=("rebase", "cherry-pick"), default="rebase"
)
parser.add_argument("--remote", default="origin")
parser.add_argument(
"--ack-merge-and-propagate",
action="store_true",
help="Acknowledge explicit merge-and-propagate authority",
)
_add_remote_dry_run(parser)


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Carve a review-ready source branch into intentional changesets.",
Expand Down Expand Up @@ -388,6 +435,25 @@ def build_parser() -> argparse.ArgumentParser:
_add_remote_dry_run(item)
item.set_defaults(func=cmd_push_chain)

item = _command(
sub,
"propagate",
"Verify a merged changeset and propagate its downstream suffix.",
)
_add_propagation_options(item)
item.set_defaults(func=cmd_propagate)

item = _command(
sub,
"merge-propagate",
"Merge one changeset PR, verify it, and propagate downstream.",
)
_add_propagation_options(item)
item.add_argument(
"--method", choices=("merge", "squash", "rebase"), default="merge"
)
item.set_defaults(func=cmd_merge_propagate)

item = _command(sub, "db-compare", "Compare source and chain database schemas.")
_add_plan(item)
item.add_argument("--source-cmd", required=True)
Expand Down
134 changes: 123 additions & 11 deletions skills/carve-changesets/scripts/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
)
from rehydrate import PullRequestRecord

_PR_JSON_FIELDS = (
"number,headRefName,headRefOid,baseRefName,state,body,title,mergeCommit,"
"isCrossRepository"
)


def _format_error(command: Sequence[str], error: subprocess.CalledProcessError) -> str:
stdout = (error.stdout or "").strip()
Expand Down Expand Up @@ -308,7 +313,7 @@ def pull_requests_for_source(
"--limit",
"100",
"--json",
"number,headRefName,headRefOid,baseRefName,state,body",
_PR_JSON_FIELDS,
)
)
if not isinstance(payload, list):
Expand All @@ -322,14 +327,121 @@ def pull_requests_for_source(
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 ""),
)
)
records.append(_pull_request_record(item, context=f"changeset PR for {head}"))
return records


def _merge_sha(item: Dict) -> str | None:
value = item.get("mergeCommit")
if not isinstance(value, dict):
return None
oid = str(value.get("oid") or "").strip()
return oid or None


def _pull_request_record(item: object, *, context: str) -> PullRequestRecord:
"""Decode one selected gh PR payload with operation-specific errors."""

if not isinstance(item, dict):
raise CommandError(f"Unexpected GitHub response for {context}.")
try:
number = int(item["number"])
except (KeyError, TypeError, ValueError) as exc:
raise CommandError(
f"GitHub response for {context} has no valid PR number."
) from exc
return PullRequestRecord(
number=number,
head_branch=str(item.get("headRefName") or ""),
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 ""),
title=str(item.get("title") or ""),
merge_sha=_merge_sha(item),
is_cross_repository=bool(item.get("isCrossRepository", False)),
)


def pull_request_by_number(number: int, *, remote: str = "origin") -> PullRequestRecord:
"""Read one exact PR using the same fields as chain discovery."""

repository = github_repo_for_remote(remote)
item = gh_json(
(
"pr",
"view",
str(number),
"-R",
repository,
"--json",
_PR_JSON_FIELDS,
)
)
record = _pull_request_record(item, context=f"PR #{number}")
actual_number = record.number
if actual_number != number:
raise CommandError(
f"GitHub returned PR #{actual_number} while verifying requested PR #{number}."
)
return record


def merge_pull_request(
number: int,
*,
expected_head: str,
method: str,
remote: str = "origin",
dry_run: bool,
) -> None:
"""Merge one explicitly numbered PR in the selected remote repository."""

method_flags = {"merge": "--merge", "squash": "--squash", "rebase": "--rebase"}
if method not in method_flags:
raise CommandError("Merge method must be 'merge', 'squash', or 'rebase'.")
repository = github_repo_for_remote(remote)
args = (
"pr",
"merge",
str(number),
"-R",
repository,
method_flags[method],
"--match-head-commit",
expected_head,
)
print(f"[STEP] Merging PR #{number} with method={method}")
if dry_run:
print("[DRY-RUN] Would run:")
_print_command(("gh", *args))
return
ensure_gh_ready(repository)
gh_capture(args)


def edit_pull_request(
number: int,
*,
remote: str = "origin",
base: str | None = None,
title: str | None = None,
dry_run: bool,
) -> None:
"""Edit one explicit PR; never infer a target from the checked-out branch."""

if base is None and title is None:
return
repository = github_repo_for_remote(remote)
args: list[str] = ["pr", "edit", str(number), "-R", repository]
if base is not None:
args.extend(("--base", base))
if title is not None:
args.extend(("--title", title))
print(f"[STEP] Updating PR #{number}")
if dry_run:
print("[DRY-RUN] Would run:")
_print_command(("gh", *args))
return
ensure_gh_ready(repository)
gh_capture(tuple(args))
Loading
Loading