Pulse/scripts/release_control/internal/worktree_finish.py

190 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Land an isolated worktree's finished slice onto the base branch worktree."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import subprocess
import sys
from typing import Any
from repo_file_io import REPO_ROOT
from worktree_base import canonical_base_worktree_path
from worktree_claim import list_worktrees
def git(*args: str, cwd: Path, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=cwd,
check=check,
capture_output=True,
text=True,
)
def current_branch(*, repo_root: Path) -> str:
return git("branch", "--show-current", cwd=repo_root).stdout.strip()
def current_head(*, repo_root: Path) -> str:
return git("rev-parse", "HEAD", cwd=repo_root).stdout.strip()
def is_clean_worktree(*, repo_root: Path) -> bool:
return not git("status", "--porcelain", cwd=repo_root).stdout.strip()
def branch_worktree_path(*, repo_root: Path, branch_name: str, preferred_path: Path | None = None) -> Path | None:
preferred = preferred_path.resolve() if preferred_path is not None else None
fallback: Path | None = None
for entry in list_worktrees(repo_root=repo_root):
if entry.get("branch") != f"refs/heads/{branch_name}":
continue
candidate = Path(entry["worktree"]).resolve()
if preferred is not None and candidate == preferred:
return candidate
if fallback is None:
fallback = candidate
return fallback
def commits_ahead_of_base(*, repo_root: Path, base_branch: str) -> list[str]:
output = git("rev-list", "--reverse", f"{base_branch}..HEAD", cwd=repo_root).stdout.strip()
return [line for line in output.splitlines() if line.strip()]
def cherry_pick_commits(*, base_worktree: Path, commits: list[str]) -> None:
for commit in commits:
subprocess.run(["git", "cherry-pick", commit], cwd=base_worktree, check=True)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Land an isolated worktree's finished slice onto the base branch worktree."
)
parser.add_argument(
"--base-branch",
default="pulse/v6",
help="Base branch that receives the finished slice. Defaults to pulse/v6.",
)
parser.add_argument(
"--write",
action="store_true",
help="Cherry-pick the finished commit(s) onto the base branch worktree when validation passes.",
)
parser.add_argument(
"--pretty",
action="store_true",
help="Render a concise human summary instead of JSON.",
)
return parser.parse_args(argv)
def render_pretty(
*,
current_worktree: Path,
current_branch_name: str,
base_branch: str,
base_worktree: Path | None,
commits: list[str],
errors: list[str],
wrote: bool,
) -> str:
lines = [
"worktree_finish:",
f" current_worktree={current_worktree}",
f" current_branch={current_branch_name}",
f" base_branch={base_branch}",
f" base_worktree={base_worktree if base_worktree is not None else 'missing'}",
f" commit_count={len(commits)}",
f" wrote={'yes' if wrote else 'no'}",
]
if commits:
lines.append("commits:")
for commit in commits:
lines.append(f" - {commit}")
if errors:
lines.append("errors:")
for error in errors:
lines.append(f" - {error}")
else:
lines.append("status: ready")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
args = parse_args(list(argv or []))
errors: list[str] = []
current_worktree = REPO_ROOT.resolve()
current_branch_name = current_branch(repo_root=current_worktree)
preferred_base = canonical_base_worktree_path(repo_root=current_worktree, branch_name=args.base_branch)
base_worktree = branch_worktree_path(
repo_root=current_worktree, branch_name=args.base_branch, preferred_path=preferred_base
)
if not current_branch_name:
errors.append("current worktree is not on a branch")
if current_branch_name == args.base_branch:
errors.append("current worktree is already on the base branch")
if base_worktree is None:
errors.append(
f"no canonical landing worktree found for base branch {args.base_branch}; run worktree_base.py first"
)
elif base_worktree == current_worktree:
errors.append("base branch worktree resolves to the current worktree")
elif base_worktree != preferred_base.resolve():
errors.append(
f"base branch is checked out at {base_worktree}, not at canonical landing path {preferred_base.resolve()}"
)
commits: list[str] = []
if not errors:
commits = commits_ahead_of_base(repo_root=current_worktree, base_branch=args.base_branch)
if not commits:
errors.append(f"no commits ahead of {args.base_branch} to land")
if not is_clean_worktree(repo_root=current_worktree):
errors.append("current worktree has uncommitted changes")
if base_worktree is not None and not is_clean_worktree(repo_root=base_worktree):
errors.append(f"base worktree is dirty: {base_worktree}")
wrote = False
if not errors and args.write and base_worktree is not None:
cherry_pick_commits(base_worktree=base_worktree, commits=commits)
wrote = True
if args.pretty:
print(
render_pretty(
current_worktree=current_worktree,
current_branch_name=current_branch_name,
base_branch=args.base_branch,
base_worktree=base_worktree,
commits=commits,
errors=errors,
wrote=wrote,
)
)
else:
print(
json.dumps(
{
"current_worktree": str(current_worktree),
"current_branch": current_branch_name,
"base_branch": args.base_branch,
"base_worktree": str(base_worktree) if base_worktree is not None else None,
"commits": commits,
"errors": errors,
"wrote": wrote,
},
indent=2,
sort_keys=True,
)
)
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))