def _get_local_directory(self): repos_dir = pathlib.Path(user_cache_dir('forwardport')) repos_dir.mkdir(parents=True, exist_ok=True) repo_dir = repos_dir / self.repository.name if repo_dir.is_dir(): return git(repo_dir) else: _logger.info("Cloning out %s to %s", self.repository.name, repo_dir) subprocess.run([ 'git', 'clone', '--bare', 'https://{}:{}@github.com/{}'.format( self.repository.project_id.fp_github_name, self.repository.project_id.fp_github_token, self.repository.name, ), str(repo_dir) ], check=True) # add PR branches as local but namespaced (?) repo = git(repo_dir) # bare repos don't have a fetch spec by default (!) so adding one # removes the default behaviour and stops fetching the base # branches unless we add an explicit fetch spec for them repo.config('--add', 'remote.origin.fetch', '+refs/heads/*:refs/heads/*') repo.config('--add', 'remote.origin.fetch', '+refs/pull/*/head:refs/heads/pull/*') return repo
def _cleanup_cache(config, users): """ forwardport has a repo cache which it assumes is unique per name but tests always use the same repo paths / names for different repos (the repos get re-created), leading to divergent repo histories. So clear cache after each test, two tests should not share repos. """ yield cache_root = pathlib.Path(user_cache_dir('forwardport')) rmtree(cache_root / config['github']['owner'], ignore_errors=True) for login in users.values(): rmtree(cache_root / login, ignore_errors=True)
def _create_fp_branch(self, target_branch, fp_branch_name, cleanup): """ Creates a forward-port for the current PR to ``target_branch`` under ``fp_branch_name``. :param target_branch: the branch to port forward to :param fp_branch_name: the name of the branch to create the FP under :param ExitStack cleanup: so the working directories can be cleaned up :return: (conflictp, working_copy) :rtype: (bool, Repo) """ source = self._get_local_directory() # update all the branches & PRs _logger.info("Update %s", source._directory) source.with_params('gc.pruneExpire=1.day.ago').fetch('-p', 'origin') # FIXME: check that pr.head is pull/{number}'s head instead? source.cat_file(e=self.head) # create working copy _logger.info("Create working copy to forward-port %s:%d to %s", self.repository.name, self.number, target_branch.name) working_copy = source.clone(cleanup.enter_context( tempfile.TemporaryDirectory( prefix='%s:%d-to-%s-' % (self.repository.name, self.number, target_branch.name), dir=user_cache_dir('forwardport'))), branch=target_branch.name) project_id = self.repository.project_id # add target remote working_copy.remote( 'add', 'target', 'https://{p.fp_github_name}:{p.fp_github_token}@github.com/{r.fp_remote_target}' .format(r=self.repository, p=project_id)) _logger.info("Create FP branch %s", fp_branch_name) working_copy.checkout(b=fp_branch_name) root = self._get_root() try: root._cherry_pick(working_copy) return None, working_copy except CherrypickError as e: # using git diff | git apply -3 to get the entire conflict set # turns out to not work correctly: in case files have been moved # / removed (which turns out to be a common source of conflicts # when forward-porting) it'll just do nothing to the working copy # so the "conflict commit" will be empty # switch to a squashed-pr branch root_branch = 'origin/pull/%d' % root.number working_copy.checkout('-bsquashed', root_branch) root_commits = root.commits() to_tuple = operator.itemgetter('name', 'email', 'date') to_dict = lambda term, vals: { 'GIT_%s_NAME' % term: vals[0], 'GIT_%s_EMAIL' % term: vals[1], 'GIT_%s_DATE' % term: vals[2], } authors, committers = set(), set() for c in (c['commit'] for c in root_commits): authors.add(to_tuple(c['author'])) committers.add(to_tuple(c['committer'])) fp_authorship = (project_id.fp_github_name, project_id.fp_github_email, '') author = authors.pop() if len(authors) == 1 else fp_authorship committer = committers.pop() if len( committers) == 1 else fp_authorship conf = working_copy.with_config( env={ **to_dict('AUTHOR', author), **to_dict('COMMITTER', committer), 'GIT_COMMITTER_DATE': '', }) # squash to a single commit conf.reset('--soft', root_commits[0]['parents'][0]['sha']) conf.commit(a=True, message="temp") squashed = conf.stdout().rev_parse('HEAD').stdout.strip().decode() # switch back to the PR branch conf.checkout(fp_branch_name) # cherry-pick the squashed commit to generate the conflict conf.with_params('merge.renamelimit=0').with_config( check=False).cherry_pick(squashed) # if there was a single commit, reuse its message when committing # the conflict # TODO: still add conflict information to this? if len(root_commits) == 1: msg = root._make_fp_message(root_commits[0]) conf.with_config(input=str(msg).encode())\ .commit(all=True, allow_empty=True, file='-') else: conf.commit(all=True, allow_empty=True, message="""Cherry pick of %s failed stdout: %s stderr: %s """ % e.args) return e.args, working_copy
def _create_fp_branch(self, target_branch, fp_branch_name, cleanup): """ Creates a forward-port for the current PR to ``target_branch`` under ``fp_branch_name``. :param target_branch: the branch to port forward to :param fp_branch_name: the name of the branch to create the FP under :param ExitStack cleanup: so the working directories can be cleaned up :return: (conflictp, working_copy) :rtype: (bool, Repo) """ source = self._get_local_directory() # update all the branches & PRs _logger.info("Update %s", source._directory) source.with_params('gc.pruneExpire=1.day.ago').fetch('-p', 'origin') # FIXME: check that pr.head is pull/{number}'s head instead? source.cat_file(e=self.head) # create working copy _logger.info("Create working copy to forward-port %s:%d to %s", self.repository.name, self.number, target_branch.name) working_copy = source.clone(cleanup.enter_context( tempfile.TemporaryDirectory( prefix='%s:%d-to-%s' % (self.repository.name, self.number, target_branch.name), dir=user_cache_dir('forwardport'))), branch=target_branch.name) project_id = self.repository.project_id # configure local repo so commits automatically pickup bot identity working_copy.config('--local', 'user.name', project_id.fp_github_name) working_copy.config('--local', 'user.email', project_id.fp_github_email) # add target remote working_copy.remote( 'add', 'target', 'https://{p.fp_github_name}:{p.fp_github_token}@github.com/{r.fp_remote_target}' .format(r=self.repository, p=project_id)) _logger.info("Create FP branch %s", fp_branch_name) working_copy.checkout(b=fp_branch_name) root = self._get_root() try: root._cherry_pick(working_copy) except CherrypickError as e: # using git diff | git apply -3 to get the entire conflict set # turns out to not work correctly: in case files have been moved # / removed (which turns out to be a common source of conflicts # when forward-porting) it'll just do nothing to the working copy # so the "conflict commit" will be empty # switch to a squashed-pr branch root_branch = 'origin/pull/%d' % root.number working_copy.checkout('-bsquashed', root_branch) root_commits = root.commits() # squash to a single commit working_copy.reset('--soft', root_commits[0]['parents'][0]['sha']) working_copy.commit(a=True, message="temp") squashed = working_copy.stdout().rev_parse( 'HEAD').stdout.strip().decode() # switch back to the PR branch working_copy.checkout(fp_branch_name) # cherry-pick the squashed commit working_copy.with_params('merge.renamelimit=0').with_config( check=False).cherry_pick(squashed) # if there was a single commit, reuse its message when committing # the conflict # TODO: still add conflict information to this? if len(root_commits) == 1: working_copy.commit(all=True, allow_empty=True, reuse_message=root_commits[0]['sha']) else: working_copy.commit(all=True, allow_empty=True, message="""Cherry pick of %s failed stdout: %s stderr: %s """ % e.args) return e.args, working_copy else: return None, working_copy