331 lines
11 KiB
Python
Executable File
331 lines
11 KiB
Python
Executable File
#! /usr/bin/env python3
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import json
|
|
import os.path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
from typing import Dict, List, Union
|
|
|
|
|
|
FDB_BASE_REPOSITORY = "https://github.com/apple/foundationdb"
|
|
|
|
|
|
def _silent_subprocess(command, mute_even_failed: bool=False, *args, **kwargs) -> str:
|
|
output = None
|
|
try:
|
|
output = (
|
|
subprocess.check_output(command, *args, **kwargs).decode("utf-8").strip()
|
|
)
|
|
return output
|
|
except:
|
|
if not mute_even_failed:
|
|
print(f"Error running subprocess [{' '.join(command)}]:\n{output}", file=sys.stderr)
|
|
raise
|
|
|
|
|
|
CURRENT_BRANCH = _silent_subprocess(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
CURRENT_HEAD = _silent_subprocess(["git", "rev-parse", "--verify", "HEAD"])
|
|
GIT_ROOT = _silent_subprocess(["git", "rev-parse", "--show-toplevel"])
|
|
|
|
|
|
_fetch_upstream = lambda: _silent_subprocess(["git", "fetch", "--all"])
|
|
|
|
|
|
# TODO: Rewrite this as a module
|
|
class MergeOperation:
|
|
MERGE_FILE_PATH = os.path.join(GIT_ROOT, ".git", "__cherrypick_merge.status")
|
|
|
|
class AlreadyMergeInProgressException(RuntimeError):
|
|
pass
|
|
|
|
@staticmethod
|
|
def is_in_merge_process() -> bool:
|
|
"""Check if currently in a merge process"""
|
|
return os.path.exists(MergeOperation.MERGE_FILE_PATH)
|
|
|
|
@dataclasses.dataclass
|
|
class MergeProgress:
|
|
# ID of the pull request
|
|
pr_id: int
|
|
# The id of the merge commit
|
|
merge_id: Union[str, None]
|
|
# The commit id on main branch right before the merge
|
|
merge_on: str
|
|
# Commits that merged to main
|
|
commit_ids: List[str]
|
|
# Number of commits in the merge
|
|
total_commits: int
|
|
# Number of commits that are already cherry-picked
|
|
cherry_picked: int
|
|
|
|
class MergeProgressEncoder(json.JSONEncoder):
|
|
def default(self, o: object):
|
|
if isinstance(o, MergeOperation.MergeProgress):
|
|
return {
|
|
"pr_id": o.pr_id,
|
|
"merge_id": o.merge_id,
|
|
"merge_on": o.merge_on,
|
|
"commit_ids": o.commit_ids,
|
|
"total_commits": o.total_commits,
|
|
"cherry_picked": o.cherry_picked,
|
|
}
|
|
return json.JSONEncoder.default(self, o)
|
|
|
|
@staticmethod
|
|
def _as_merge_progress(dct: Dict) -> "MergeOperation.MergeProgress":
|
|
return MergeOperation.MergeProgress(
|
|
pr_id=int(dct.get("pr_id")),
|
|
merge_id=str(dct.get("merge_id")),
|
|
merge_on=str(dct.get("merge_on")),
|
|
commit_ids=list(dct.get("commit_ids")),
|
|
total_commits=int(dct.get("total_commits")),
|
|
cherry_picked=int(dct.get("cherry_picked")),
|
|
)
|
|
|
|
@staticmethod
|
|
def delete_merge_progress():
|
|
if os.path.exists(MergeOperation.MERGE_FILE_PATH):
|
|
os.remove(MergeOperation.MERGE_FILE_PATH)
|
|
|
|
@staticmethod
|
|
def read_merge_progress() -> MergeProgress:
|
|
with open(MergeOperation.MERGE_FILE_PATH) as stream:
|
|
return json.loads(
|
|
stream.read(), object_hook=MergeOperation._as_merge_progress
|
|
)
|
|
|
|
@staticmethod
|
|
def write_merge_progress(merge_progress: MergeProgress):
|
|
with open(MergeOperation.MERGE_FILE_PATH, "w") as stream:
|
|
stream.write(
|
|
json.dumps(merge_progress, cls=MergeOperation.MergeProgressEncoder)
|
|
)
|
|
|
|
@staticmethod
|
|
def is_merge(commit_id: str) -> bool:
|
|
"""Check if the commit is a merge commit, a merge commit has two parents"""
|
|
if not commit_id:
|
|
return False
|
|
cat_file = _silent_subprocess(["git", "cat-file", "-p", commit_id])
|
|
# If the commit has two parents, it has to be a merge commit
|
|
return len(re.findall(r"parent [0-9a-f]+\n", cat_file)) == 2
|
|
|
|
@staticmethod
|
|
def get_commits_from_merge_hash(merge: str) -> List[str]:
|
|
"""Get the commits in a merge"""
|
|
output_raw = _silent_subprocess(["git", "log", f"{merge}^-", "--pretty=%H"])
|
|
hashes = [h for h in output_raw.splitlines()]
|
|
hashes = list(reversed(hashes))
|
|
|
|
return hashes
|
|
|
|
@staticmethod
|
|
def cherry_pick_commit(commit_id: str):
|
|
command = ["git", "cherry-pick", commit_id, "--allow-empty"]
|
|
if MergeOperation.is_merge(commit_id):
|
|
# FIXME Do we need to do something?
|
|
return
|
|
_silent_subprocess(command)
|
|
|
|
|
|
def _get_pull_request_info(pr: int):
|
|
# e.g.
|
|
# gh pr -R https://github.com/apple/foundationdb view 9930 --json commits
|
|
output_raw = _silent_subprocess(
|
|
[
|
|
"gh",
|
|
"pr",
|
|
"-R",
|
|
FDB_BASE_REPOSITORY,
|
|
"view",
|
|
str(pr),
|
|
"--json",
|
|
"commits,mergeCommit,headRepository,headRepositoryOwner",
|
|
]
|
|
)
|
|
output = json.loads(output_raw)
|
|
commits = [commit["oid"] for commit in output["commits"]]
|
|
|
|
return {
|
|
"commits": commits,
|
|
"merge_commit": (output.get("mergeCommit") or {}).get("oid"),
|
|
"repository": output["headRepository"]["name"],
|
|
"owner": output["headRepositoryOwner"]["login"],
|
|
}
|
|
|
|
|
|
def _is_already_committed(commit: str, branch: str) -> bool:
|
|
output = _silent_subprocess(["git", "branch", branch, "--contains", commit])
|
|
return len(output.splitlines()) == 1
|
|
|
|
|
|
def _prepare_pull_request(pr: int) -> None:
|
|
"""Check the pull request locally"""
|
|
_silent_subprocess(["gh", "pr", "-R", FDB_BASE_REPOSITORY, "checkout", str(pr)])
|
|
_silent_subprocess(["git", "checkout", CURRENT_BRANCH])
|
|
|
|
|
|
def _tag(pr: int):
|
|
tag = f"cherry-pick-pr-{pr}"
|
|
try:
|
|
_silent_subprocess(["git", "rev-parse", tag], mute_even_failed=True)
|
|
_silent_subprocess(["git", "tag", "-d", f"cherry-pick-pr-{pr}"])
|
|
except subprocess.CalledProcessError:
|
|
# The tag does not exist
|
|
pass
|
|
_silent_subprocess(["git", "tag", tag])
|
|
print(f"Created tag {tag}")
|
|
|
|
|
|
def _start_merge(pr: int) -> MergeOperation.MergeProgress:
|
|
print(f"Cherry-picking pull request {pr}")
|
|
pr_info = _get_pull_request_info(pr)
|
|
|
|
if MergeOperation.is_merge(pr_info["merge_commit"]):
|
|
print(f"Using {pr_info['merge_commit']} as the merge commit")
|
|
commits_to_be_cherry_picked = MergeOperation.get_commits_from_merge_hash(
|
|
pr_info["merge_commit"]
|
|
)
|
|
return MergeOperation.MergeProgress(
|
|
pr_id=pr,
|
|
merge_id=commits_to_be_cherry_picked[-1],
|
|
merge_on=CURRENT_HEAD,
|
|
commit_ids=commits_to_be_cherry_picked[:-1],
|
|
total_commits=len(commits_to_be_cherry_picked) - 1,
|
|
cherry_picked=0,
|
|
)
|
|
else:
|
|
print(f"Using pull request branch")
|
|
# Might be a fast-forward merge without merge hash, need to cherry-pick from the original owner's repository
|
|
_prepare_pull_request(pr)
|
|
commits_to_be_cherry_picked = pr_info["commits"]
|
|
return MergeOperation.MergeProgress(
|
|
pr_id=pr,
|
|
merge_id=None,
|
|
merge_on=CURRENT_HEAD,
|
|
commit_ids=commits_to_be_cherry_picked,
|
|
total_commits=len(commits_to_be_cherry_picked),
|
|
cherry_picked=0,
|
|
)
|
|
|
|
|
|
def _report_merge_status():
|
|
merge_progress = MergeOperation.read_merge_progress()
|
|
print(
|
|
f"Current merging PR {merge_progress.pr_id} on commit {merge_progress.merge_on}"
|
|
)
|
|
print(
|
|
f"Merge progress: {merge_progress.cherry_picked}/{merge_progress.total_commits}"
|
|
)
|
|
if merge_progress.cherry_picked > 0:
|
|
print("Cherry-picked:")
|
|
for index in range(0, merge_progress.cherry_picked):
|
|
print(f" {merge_progress.commit_ids[index]}")
|
|
print("Undergoing:")
|
|
for index in range(merge_progress.cherry_picked, merge_progress.total_commits):
|
|
print(f" {merge_progress.commit_ids[index]}")
|
|
|
|
|
|
def _abort_merge():
|
|
merge_progress = MergeOperation.read_merge_progress()
|
|
print(f"Resetting to previous HEAD: {merge_progress.merge_on}")
|
|
_silent_subprocess(["git", "reset", "--hard", merge_progress.merge_on])
|
|
MergeOperation.delete_merge_progress()
|
|
print("Merge process file deleted")
|
|
|
|
|
|
def _continue_merge():
|
|
merge_progress = MergeOperation.read_merge_progress()
|
|
start_index = merge_progress.cherry_picked
|
|
for index in range(start_index, merge_progress.total_commits):
|
|
commit = merge_progress.commit_ids[index]
|
|
if _is_already_committed(commit, CURRENT_BRANCH):
|
|
print(f"Commit {commit} is already in branch {CURRENT_BRANCH}")
|
|
continue
|
|
else:
|
|
print(f"Cherry-picking {commit}")
|
|
MergeOperation.cherry_pick_commit(commit)
|
|
merge_progress.cherry_picked += 1
|
|
MergeOperation.write_merge_progress(merge_progress)
|
|
|
|
|
|
MergeOperation.delete_merge_progress()
|
|
|
|
# Tag the pr id to the last merge
|
|
_tag(merge_progress.pr_id)
|
|
|
|
|
|
def _drop_current_cherrypick():
|
|
if os.path.exists(os.path.join(GIT_ROOT, ".git", "CHERRY_PICK_HEAD")):
|
|
raise RuntimeError("Cherry pick unresolved")
|
|
merge_progress = MergeOperation.read_merge_progress()
|
|
print(f"Assuming commit {merge_progress.commit_ids[merge_progress.cherry_picked]} as resolved")
|
|
merge_progress.cherry_picked += 1
|
|
MergeOperation.write_merge_progress(merge_progress)
|
|
|
|
|
|
def _args():
|
|
parser = argparse.ArgumentParser(usage="prx.py [PR id] ...")
|
|
|
|
subparser = parser.add_subparsers(dest="action")
|
|
|
|
merge_parser = subparser.add_parser("merge", help="Merge a pull request")
|
|
merge_parser.add_argument(
|
|
"pr", nargs=1, type=int, help="The ID of the pull request"
|
|
)
|
|
merge_parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Do not trigger actual cherry-pick, only print out the list of commits",
|
|
)
|
|
|
|
continue_parser = subparser.add_parser(
|
|
"continue", help="Continue the current merge process"
|
|
)
|
|
|
|
abort_parser = subparser.add_parser("abort", help="Abort the current merge process")
|
|
|
|
status_parser = subparser.add_parser(
|
|
"status", help="Status of the current merge process"
|
|
)
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def _main():
|
|
args = _args()
|
|
is_in_merge_process = MergeOperation.is_in_merge_process()
|
|
|
|
if args.action == "merge":
|
|
if is_in_merge_process:
|
|
raise RuntimeError("Currently in a merge process")
|
|
merge_progress = _start_merge(args.pr[0])
|
|
MergeOperation.write_merge_progress(merge_progress)
|
|
|
|
if args.dry_run:
|
|
_report_merge_status()
|
|
MergeOperation.delete_merge_progress()
|
|
else:
|
|
_continue_merge()
|
|
|
|
return
|
|
|
|
if not is_in_merge_process:
|
|
raise RuntimeError("Currently not in a merge process")
|
|
|
|
if args.action == "continue":
|
|
_drop_current_cherrypick()
|
|
_continue_merge()
|
|
elif args.action == "abort":
|
|
_abort_merge()
|
|
elif args.action == "status":
|
|
_report_merge_status()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(_main())
|