#!/usr/bin/python3 """Rename a branch locally and, as much as possible, remotely. This was designed to help with the master/main transition, but can be used with any branch name combination.""" import argparse import logging import os import re from subprocess import run, check_call, check_output, CalledProcessError, DEVNULL import gitlab def main(): logging.basicConfig(format="%(levelname)s: %(message)s", level="INFO") args = RenamerArgumentParser().parse_args() if args.quiet: git_output = DEVNULL else: # None actually means "normal", ie. stdout git_output = None try: check_output(("git", "show-ref", "refs/heads/%s" % args.from_branch)) except CalledProcessError: logging.warning( "branch %s does not exist, assuming already renamed", args.from_branch ) else: logging.info("renaming %s to %s", args.from_branch, args.to_branch) check_call( ("git", "branch", "--move", args.from_branch, args.to_branch), stdout=git_output, ) logging.info("fetching remote %s to see if it needs a rename", args.remote) check_call(("git", "fetch", args.remote), stdout=git_output) logging.info("reseting %s/HEAD to %s unconditionnally", args.remote, args.to_branch) check_call( ( "git", "symbolic-ref", f"refs/remotes/{args.remote}/HEAD", f"refs/remotes/{args.remote}/{args.to_branch}", ), stdout=git_output, ) logging.info( "setting local branch to follow %s/%s unconditionnally", args.remote, args.to_branch, ) if ( run( ("git", "branch", "-u", f"{args.remote}/{args.to_branch}"), stdout=DEVNULL if args.quiet else None, ).returncode == 0 ): logging.info( "remote branch %s/%s already exists, all done", args.remote, args.to_branch ) return logging.info("remote branch %s not found, pushing new branch", args.to_branch) check_call(("git", "push", "-u", args.remote, args.to_branch), stdout=git_output) remote_ssh, remote_url_http, forge_url, project = guess_remote_urls(args.remote) if "@" in remote_ssh: ssh_cmd = ("ssh", remote_ssh, f"git symbolic-ref HEAD {args.to_branch}") logging.info( "SSH remote detected, trying to fix default branch with: %s", ssh_cmd ) if run(ssh_cmd, stdout=git_output).returncode != 0: logging.warning("failed to change HEAD on remote with SSH") logging.info("trying to delete old branch %s from remote", args.from_branch) if ( not run( ("git", "push", "-d", args.remote, args.from_branch), stdout=git_output ).returncode == 0 ): logging.warning("push denied by remote, maybe a branch protected in GitLab?") # TODO: GitHub support gitlab_branch_change_default( forge_url, project, args.from_branch, args.to_branch ) logging.info( "trying to delete old branch %s from remote, again", args.from_branch ) check_call( ("git", "push", "-d", args.remote, args.from_branch), stdout=git_output ) logging.info( "all done, branch %s renamed to %s locally and on remote %s", args.from_branch, args.to_branch, args.remote, ) def gitlab_branch_change_default(forge_url, project, from_branch, to_branch): """wrapper around the branch default change Just changing the default is not enough: we also want to apply the same protections as the previous branch to the new branch. """ private_token = os.environ.get("GITLAB_PRIVATE_TOKEN", None) if private_token is None: logging.error( "cannot talk to the GitLab forge without the GITLAB_PRIVATE_TOKEN environment variable" ) return gl = gitlab.Gitlab(forge_url, private_token=private_token) gl_project = gl.projects.get(project) logging.info("protecting new branch %s", to_branch) gl_project.branches.get(to_branch).protect() logging.info("unprotecting old branch %s", from_branch) gl_project.branches.get(from_branch).unprotect() logging.info("changing default branch to %s", to_branch) gl_project.default_branch = to_branch gl_project.save() logging.info("all done with GitLab host %s", forge_url) def guess_remote_urls(remote): """convenience wrapper around parse_remote_urls""" return parse_remote_urls( check_output(("git", "remote", "get-url", remote)).strip().decode("utf-8") ) def parse_remote_urls(remote_url): """this mess looks at the git remote URL and tries to guess a bunch of things In particular, it tries to guess an HTTP URL from a SSH-looking URL. Then It will try to guess the project (whatever comes after the slash), and *then* the name of the site, which we call the "forge_url", on which the site is hosted. >>> parse_remote_urls("https://example.com/foo.git") ('example.com', 'https://example.com/foo.git', 'https://example.com/', 'foo') >>> parse_remote_urls("git@example.com:foo.git") ('git@example.com', 'https://example.com/foo.git', 'https://example.com/', 'foo') >>> parse_remote_urls("ssh://example.com/foo.git") ('example.com', 'https://example.com/foo.git', 'https://example.com/', 'foo') Other URL formats are untested. """ remote_ssh = remote_url_http = remote_url if "@" in remote_url: remote_url_http = re.sub(r".*@", "https://", remote_url.replace(":", "/")) logging.warning("rewritten URL %s to %s", remote_url, remote_url_http) remote_ssh = re.sub(":.*", "", remote_ssh) elif remote_url.startswith("ssh://"): # strip leading ssh:// remote_ssh = re.sub(r"^ssh://", "", remote_url) remote_url_http = "https://" + remote_ssh # strip project path to keep just user@host.example.com remote_ssh = re.sub(r"/.*", "", remote_ssh) elif remote_url.startswith("https://"): # strip project path and url, keeping only host.example.com remote_ssh = re.sub(r"/.*", "", re.sub(r"^https://", "", remote_url)) else: assert not remote_url.startswith("http://"), "cleartext HTTP URL unsupported" logging.warning("unsupported scheme for remote URL: %s", remote_url) logging.info("guessed remote URL %s", remote_url_http) project = re.sub(r"^https://[^/]*/(.*?)(\.git)?$", r"\1", remote_url_http) logging.info("guessed project path %s", project) forge_url = re.sub(r"^(https://[^/]*/).*$", r"\1", remote_url_http) logging.info("guessed forge URL %s", forge_url) return remote_ssh, remote_url_http, forge_url, project class LoggingAction(argparse.Action): """change log level on the fly The logging system should be initialized befure this, using `basicConfig`.""" def __init__(self, *args, **kwargs): """setup the action parameters This enforces a selection of logging levels. It also checks if const is provided, in which case we assume it's an argument like `--verbose` or `--debug` without an argument. """ kwargs["choices"] = logging._nameToLevel.keys() if "const" in kwargs: kwargs["nargs"] = 0 super().__init__(*args, **kwargs) def __call__(self, parser, ns, values, option): """if const was specified it means argument-less parameters""" if self.const: logging.getLogger("").setLevel(self.const) else: logging.getLogger("").setLevel(values) # cargo-culted from _StoreConstAction setattr(ns, self.dest, self.const) class RenamerArgumentParser(argparse.ArgumentParser): def __init__(self, *args, **kwargs): "add parameters to the argument parser" super().__init__( description="rename a git branch locally and remotely", epilog=__doc__, *args, *kwargs, ) self.add_argument( "-f", "--from-branch", default="master", help="branch to rename from, default: %(default)s", ) self.add_argument( "-t", "--to-branch", default="main", help="branch to rename to, default: %(default)s", ) self.add_argument( "-r", "--remote", default="origin", help="remote to also operate on, default: %(default)s", ) self.add_argument( "-q", "--quiet", action=LoggingAction, const="WARNING", help="enable verbose messages", ) self.add_argument( "-d", "--debug", action=LoggingAction, const="DEBUG", help="enable debugging messages", ) if __name__ == "__main__": main()