blob: f8685bf5b4f84406f915b344882ad7824d48d7bb [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""General tool to manage sync-branches as described in Fuchsia RFC-0153.
This tool provides several commands that simplify creation and management
of sync branches. A typical use case would be:
1) Use the `create` command to create a new local sync-branch, with
the right cherry-picked commits in it.
2) Optional: resolve any conflicts that happen during the cherry-pick
operation launched by the `create` command. Alternatively, it is possible
to rebase the sync branch, for example to re-order commits or update
documentation.
3) Use the `rebase` command to rebase the current sync-branch on top of
the current upstream reference.
4) Optional: resolve any conflicts that happen during the rebase operation.
5) Use the `merge` command to merge the sync branch into the
main development branch.
Use `<command> --help` for command-specific details.
"""
import argparse
import json
import shlex
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Iterable, List, Sequence
_DEFAULT_UPSTREAM_REF = "origin/upstream/master"
_DEFAULT_DEV_BRANCH = "fuchsia-rfc-0153"
_DEFAULT_SYNC_BRANCH_NAME = "sync-branch-" + time.strftime("%Y-%m-%d", time.gmtime())
_VERBOSE = False
def log(msg: str):
if _VERBOSE:
print("LOG: " + msg, file=sys.stderr)
def cmd_args_to_list(cmd_args: Iterable[Any]) -> Sequence[str]:
return [str(c) for c in cmd_args]
def cmd_quote(cmd_args: Iterable[Any]) -> str:
return " ".join(shlex.quote(str(c)) for c in cmd_args)
def run_command(cmd_args: Iterable[Any], **kwargs) -> subprocess.CompletedProcess:
log("CMD: " + cmd_quote(cmd_args))
return subprocess.run(cmd_args_to_list(cmd_args), **kwargs)
def get_command_output(cmd_args: Iterable[Any], **kwargs) -> str:
assert "capture_output" not in kwargs
assert "text" not in kwargs
kwargs["capture_output"] = True
kwargs["text"] = True
ret = run_command(cmd_args, **kwargs)
ret.check_returncode()
return ret.stdout.strip()
def read_file(path: Path) -> str:
with open(path) as f:
return f.read()
def write_file(path: Path, content: str):
path.parent.mkdir(exist_ok=True, parents=True)
with open(path, "w") as f:
f.write(content)
class CommandError(Exception):
pass
class GitDirectory(object):
"""A Git directory."""
def __init__(self, git_dir: Path):
assert git_dir.is_dir(), "Not a directory: %s" % git_dir
assert (git_dir / ".git").is_dir(), (
"Not a git directory (missing .git/): %s" % git_dir
)
self._git_dir = git_dir
self._cfg_dir = git_dir / ".git" / "sync_branch"
@property
def git_dir(self) -> Path:
return self._git_dir
@property
def cfg_dir(self) -> Path:
return self._cfg_dir
@property
def cfg_file_path(self) -> Path:
return self._cfg_dir / "config.json"
def cmd(self, args: List[Any]):
ret = run_command(["git", "-C", self._git_dir] + args)
ret.check_returncode()
def cmd_output(self, args: List) -> str:
return get_command_output(["git", "-C", self._git_dir] + args)
class PrintingGitDirectory(GitDirectory):
"""A GitDirectory subclass that prints commands instead of running them."""
def __init__(self, parent: GitDirectory):
self._parent = parent
def cmd(self, args: Iterable[Any]) -> None:
print(
"git -C %s %s"
% (self._parent.git_dir, " ".join(shlex.quote(a) for a in args))
)
class SyncBranchConfig(object):
def __init__(self, git_dir: GitDirectory):
self.git_dir = git_dir
self.upstream_ref: str = _DEFAULT_UPSTREAM_REF
self.dev_branch_name: str = _DEFAULT_DEV_BRANCH
self.sync_branch_name: str = _DEFAULT_SYNC_BRANCH_NAME
self.stem_commit: str = ""
self.upstream_commit: str = ""
self.src_commits: List[str] = []
def init_from_create_args(self, args: argparse.Namespace) -> None:
if args.name:
self.sync_branch_name = args.name
if args.upstream_ref:
self.upstream_ref = args.upstream_ref
if args.dev_branch:
self.dev_branch_name = args.dev_branch
# Verify that the branch does not exist yet.
g = self.git_dir
has_sync_branch = False
on_sync_branch = False
current_branch = g.cmd_output(["branch", "--show-current"])
if current_branch and current_branch == self.sync_branch_name:
has_sync_branch = True
on_sync_branch = True
else:
has_sync_branch = bool(
g.cmd_output(["branch", "--list", self.sync_branch_name])
)
if has_sync_branch:
if not args.force:
raise CommandError(
"Cannot create new sync-branch over current one, use --force to override!"
)
if on_sync_branch:
g.cmd(["checkout", "--force", self.dev_branch_name])
g.cmd(["branch", "-D", self.sync_branch_name])
self.upstream_commit = self.git_dir.cmd_output(["rev-parse", self.upstream_ref])
# Compute stem commit, this the first common ancestor of
# the upstream and development branch. For example in:
#
# upstream --A1-------A2---A3
# \ \
# \ +--B1"--B2"--C2"
# \ \ \
# \ B1'--B2' \
# \ \ \
# dev -------------B1---B2---------C1--C2--D1--D2
#
# The stem commit is A2
#
self.stem_commit = g.cmd_output(
["merge-base", self.upstream_ref, self.dev_branch_name]
).strip()
if self.stem_commit == self.upstream_commit and not args.force:
raise CommandError(
"All upstream commits in current branch, no sync branch needed!"
)
# Compute source commits
#
# To do that, use --ancestry-path to limit the result to
# commits that belong only to the most recent sync branch
# is required.
#
# For example, without --ancestry-path, a command like
# `git rev-list STEM..dev` would return the following commits
# from the previous example history, which contains multiple
# sync branches originating from the same stem:
#
# upstream A2
# \
# +--B1"--B2"--C2"
# \ \
# B1'--B2' \
# \ \
# dev C1--C2--D1--D2
#
# By using --ancestry-path=C2", the result is
# limited to only the commits from the most recent sync branch, i.e.:
#
# upstream A2
# \
# +--B1"--B2"--C2"
# \
# \
# \
# dev D1--D2
#
# The commit to pass to --ancestry-path is the _second_
# parent of the most recent merge in the dev branch.
#
# To find it, the command below uses `rev-list --merges --format=%P`
# to print two lines per merge commit that is in the common ancestry
# of the stem and the dev HEAD.
#
# In the example above, the stem is A2, the HEAD is D2, and
# they share two merge commits C1 and D1, and the commands
# prints the following:
#
# commit <D1>
# <C2> <C2">
# commit <C1>
# <B2> <B2'>
#
# Taking the last hash of the second line of output gives us
# the commit value for --ancestry-path.
merge_commits_lines = g.cmd_output(
[
"rev-list",
"%s..%s" % (self.upstream_ref, self.dev_branch_name),
"--merges",
"--format=%P",
]
).splitlines()
if len(merge_commits_lines) == 0:
# No merge commit means we never created a sync branch
# in the past. In that case, do not use --ancestry-path
ancestry_path_args = []
else:
if len(merge_commits_lines) < 2:
raise CommandError(
"Invalid git rev-list output!\n% %s\n"
% " \n".join(merge_commits_lines)
)
parents = merge_commits_lines[1].split(" ")
if len(parents) != 2:
# We really don't know how to handle merges with more
# than 2 parents. These were not created by the sync-branch tool.
raise CommandError(
"Most recent merge has more than 2 parents!\n %s\n"
% " \n".join(merge_commits_lines)
)
ancestry_path_args = ["--ancestry-path=" + parents[1]]
# Now list all the source commits.
self.src_commits = g.cmd_output(
[
"rev-list",
"--reverse",
"--no-merges",
"--pretty=oneline",
"%s..%s" % (self.stem_commit, self.dev_branch_name),
]
+ ancestry_path_args
).splitlines()
def has_config_file(self) -> bool:
return self.git_dir.cfg_file_path.exists()
def read_config_file(self):
c = json.loads(read_file(self.git_dir.cfg_file_path))
self.upstream_ref = c["upstream_ref"]
self.dev_branch_name = c["dev_branch_name"]
self.sync_branch_name = c["sync_branch_name"]
self.stem_commit = c["stem_commit"]
self.src_commits = c["src_commits"]
def write_config_file(self) -> None:
c = {
"upstream_ref": self.upstream_ref,
"dev_branch_name": self.dev_branch_name,
"sync_branch_name": self.sync_branch_name,
"stem_commit": self.stem_commit,
"src_commits": self.src_commits,
}
write_file(self.git_dir.cfg_file_path, json.dumps(c, sort_keys=True, indent=2))
def clear_config_file(self) -> None:
cfg_dir = self.git_dir.cfg_dir
if cfg_dir.exists():
shutil.rmtree(cfg_dir)
def get_git_directory(args: argparse.Namespace) -> GitDirectory:
# Compute git directory.
if args.git_dir:
git_dir = Path(args.git_dir)
else:
git_dir = Path.cwd()
if not git_dir.is_dir():
raise CommandError("Not a directory: %s" % git_dir)
if not (git_dir / ".git").is_dir():
raise CommandError("Not a git directory (missing .git): %s" % git_dir)
return GitDirectory(git_dir)
def command_create(args: argparse.Namespace) -> None:
git_dir = get_git_directory(args)
sbc = SyncBranchConfig(git_dir)
sbc.init_from_create_args(args)
sbc.write_config_file()
if args.print_only:
git_dir = PrintingGitDirectory(git_dir)
# Create a tag pointing to the stem commit, for debugging.
git_dir.cmd(["tag", "-f", "SYNC_BRANCH_STEM", sbc.stem_commit])
git_dir.cmd(["checkout", "-b", sbc.sync_branch_name, sbc.stem_commit])
git_dir.cmd(["cherry-pick"] + [c.split(" ")[0] for c in sbc.src_commits])
def command_rebase(args: argparse.Namespace) -> None:
git_dir = get_git_directory(args)
sbc = SyncBranchConfig(git_dir)
if not sbc.has_config_file():
raise CommandError("No current sync branch, please use `create` command first!")
sbc.read_config_file()
if args.print_only:
git_dir = PrintingGitDirectory(git_dir)
cmd_args = [
"rebase",
"--onto",
sbc.upstream_ref,
sbc.stem_commit,
sbc.sync_branch_name,
]
git_dir.cmd(cmd_args)
def command_merge(args: argparse.Namespace) -> None:
git_dir = get_git_directory(args)
sbc = SyncBranchConfig(git_dir)
if not sbc.has_config_file():
raise CommandError("No current sync branch, please use `create` command first!")
sbc.read_config_file()
if args.print_only:
git_dir = PrintingGitDirectory(git_dir)
git_dir.cmd(["checkout", sbc.dev_branch_name])
merge_cmd = ["merge", "--no-ff", "-X", "theirs", sbc.sync_branch_name]
if args.no_commit:
merge_cmd += ["--no-commit"]
git_dir.cmd(merge_cmd)
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument("--verbose", action="store_true", help="Enable verbose mode")
parser.add_argument("--git-dir", help="Specify git directory.")
parser.add_argument(
"--print-only",
action="store_true",
help="Only print the git commands, do not run them.",
)
subparsers = parser.add_subparsers(required=True, help="Available commands")
parser_create = subparsers.add_parser(
"create",
help="Create new local sync-branch",
formatter_class=argparse.RawTextHelpFormatter,
description=r"""
Create a new local sync branch, which will start at the 'stem' commit that
is common to both the upstream and development branches, and will include
all commits between them (as cherry picks).
For example, consider the following initial state:
upstream ---A1--A2--A3
\
dev - - - - - B1--B2--B3
The command will create a new branch starting from A2 that contains
cherry picks of B1..B3, as in:
sync-branch B1'--B2'--B3'
/
upstream ---A1--A2--A3
\
dev - - - - - B1--B2--B3
Note that the cherry-pick command launched by `create` may fail in case of
rare conflicts. If this happens, just fix the issue manually then use
`git cherry-pick --continue` to complete the operation,
After the cherry pick has completed, the user is free to rebase the branch,
for example to reorder commits, or update documentation, before invoking
the `rebase` command.
Note that `create` will not work if a local sync branch already exists, or
when upstream commits are already reachable in the developement branch.
These conditions can be overriden by using the `--force` option.
""",
)
parser_create.add_argument(
"--force", action="store_true", help="Discard any existing sync branch."
)
parser_create.add_argument(
"--name",
default=_DEFAULT_SYNC_BRANCH_NAME,
help=f"Specify sync branch name (default {_DEFAULT_SYNC_BRANCH_NAME})",
)
parser_create.add_argument(
"--upstream-ref",
default=_DEFAULT_UPSTREAM_REF,
help=f"Specify upstream branch reference (default {_DEFAULT_UPSTREAM_REF})",
)
parser_create.add_argument(
"--dev-branch",
default=_DEFAULT_DEV_BRANCH,
help=f"Specify development branch (default {_DEFAULT_DEV_BRANCH})",
)
parser_create.set_defaults(func=command_create)
parser_rebase = subparsers.add_parser(
"rebase",
help="Rebase current sync-branch on top of upstream.",
formatter_class=argparse.RawTextHelpFormatter,
description=r"""
Rebase the current sync branch on top of the current upstream commit.
This must be called after the completion of a `create` operation.
For example consider the following state resulting from a previous
`create` operation:
sync-branch B1'--B2'--B3'
/
upstream ---A1--A2--A3
\
dev - - - - - B1--B2--B3
After the rebase, the sync branch should be something like:
sync-branch B1''--B2''--B3''
/
upstream ---A1--A2--A3
\
dev - - - - - B1--B2--B3
Where both B3 and B3'' correspond to the new desired sources.
After the rebase, the user is free to inspect the branch, rebase it or do
any necessary cleanups, and verify that everything still works as expected.
The user will then launch the `merge` command to merge the result into the
main development branch.
""",
)
parser_rebase.set_defaults(func=command_rebase)
parser_merge = subparsers.add_parser(
"merge",
help="Merge sync-branch to main development branch.",
formatter_class=argparse.RawTextHelpFormatter,
description=r"""
Merge the current sync branch into the main development branch. This should
only happen after a successful `rebase` command invocation.
For example consider the following state resulting from a previous
`rebase` operation:
sync-branch B1''--B2''--B3''
/
upstream ---A1--A2--A3
\
dev - - - - - B1--B2--B3
The end result will be:
upstream ---A1--A2--A3
\
sync-branch B1''--B2''--B3''
\
dev - - - - - B1--B2--B3-----------C1
Where C1 is the new merge commit on the main development branch.
Note that A3 will become the stem commit for future 'create'
operations.
""",
)
parser_merge.add_argument(
"--no-commit", action="store_true", help="Do not commit the merge."
)
parser_merge.set_defaults(func=command_merge)
args = parser.parse_args()
if args.verbose:
global _VERBOSE
_VERBOSE = True
try:
args.func(args)
except CommandError as e:
print(str(e), file=sys.stderr)
return 1
except subprocess.CalledProcessError as e:
print("%s:\n%s\n" % (e, e.stderr), file=sys.stderr)
return e.returncode
return 0
if __name__ == "__main__":
sys.exit(main())