# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. from __future__ import absolute_import, print_function import json import os import subprocess import sys from abc import ABCMeta, abstractmethod, abstractproperty GIT_CINNABAR_NOT_FOUND = """ Could not detect `git-cinnabar`. The `mach try` command requires git-cinnabar to be installed when pushing from git. For more information and installation instruction, please see: https://github.com/glandium/git-cinnabar """.lstrip() HG_PUSH_TO_TRY_NOT_FOUND = """ Could not detect `push-to-try`. The `mach try` command requires the push-to-try extension enabled when pushing from hg. Please install it by running: $ ./mach mercurial-setup """.lstrip() VCS_NOT_FOUND = """ Could not detect version control. Only `hg` or `git` are supported. """.strip() UNCOMMITTED_CHANGES = """ ERROR please commit changes before continuing """.strip() class VCSHelper(object): """A abstract base VCS helper that detects hg or git""" __metaclass__ = ABCMeta def __init__(self, root): self.root = root @classmethod def find_vcs(cls): # First check if we're in an hg repo, if not try git commands = ( ['hg', 'root'], ['git', 'rev-parse', '--show-toplevel'], ) for cmd in commands: try: output = subprocess.check_output(cmd, stderr=open(os.devnull, 'w')).strip() except (subprocess.CalledProcessError, OSError): continue return cmd[0], output return None, '' @classmethod def create(cls): vcs, root = cls.find_vcs() if not vcs: print(VCS_NOT_FOUND) sys.exit(1) return vcs_class[vcs](root) def run(self, cmd): proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate() if proc.returncode: print("Error running `{}`:".format(' '.join(cmd))) if out: print("stdout:\n{}".format(out)) if err: print("stderr:\n{}".format(err)) raise subprocess.CalledProcessError(proc.returncode, cmd, out) return out def write_task_config(self, labels, templates=None): config = os.path.join(self.root, 'try_task_config.json') with open(config, 'w') as fh: try_task_config = {'tasks': sorted(labels)} if templates: try_task_config['templates'] = templates json.dump(try_task_config, fh, indent=2, separators=(',', ':')) fh.write('\n') return config def check_working_directory(self, push=True): if not push: return if self.has_uncommitted_changes: print(UNCOMMITTED_CHANGES) sys.exit(1) def push_to_try(self, method, msg, labels=None, templates=None, push=True, closed_tree=False): closed_tree_string = " ON A CLOSED TREE" if closed_tree else "" commit_message = ('%s%s\n\nPushed via `mach try %s`' % (msg, closed_tree_string, method)) self.check_working_directory(push) config = None if labels or labels == []: config = self.write_task_config(labels, templates) try: if not push: print("Commit message:") print(commit_message) if config: print("Calculated try_task_config.json:") with open(config) as fh: print(fh.read()) return self._push_to_try(commit_message, config) finally: if config and os.path.isfile(config): os.remove(config) @abstractmethod def _push_to_try(self, msg, config): pass @abstractproperty def files_changed(self): pass @abstractproperty def has_uncommitted_changes(self): pass class HgHelper(VCSHelper): def _push_to_try(self, msg, config): try: if config: self.run(['hg', 'add', config]) return subprocess.check_call(['hg', 'push-to-try', '-m', msg]) except subprocess.CalledProcessError: try: self.run(['hg', 'showconfig', 'extensions.push-to-try']) except subprocess.CalledProcessError: print(HG_PUSH_TO_TRY_NOT_FOUND) return 1 finally: self.run(['hg', 'revert', '-a']) @property def files_changed(self): return self.run(['hg', 'log', '-r', '::. and not public()', '--template', '{join(files, "\n")}\n']).splitlines() @property def has_uncommitted_changes(self): stat = [s for s in self.run(['hg', 'status', '-amrn']).split() if s] return len(stat) > 0 class GitHelper(VCSHelper): def _push_to_try(self, msg, config): try: subprocess.check_output(['git', 'cinnabar', '--version'], stderr=subprocess.STDOUT) except subprocess.CalledProcessError: print(GIT_CINNABAR_NOT_FOUND) return 1 if config: self.run(['git', 'add', config]) subprocess.check_call(['git', 'commit', '--allow-empty', '-m', msg]) try: return subprocess.call(['git', 'push', 'hg::ssh://hg.mozilla.org/try', '+HEAD:refs/heads/branches/default/tip']) finally: self.run(['git', 'reset', 'HEAD~']) @property def files_changed(self): # This finds the files changed on the current branch based on the # diff of the current branch its merge-base base with other branches. current_branch = self.run(['git', 'rev-parse', 'HEAD']).strip() all_branches = self.run(['git', 'for-each-ref', 'refs/heads', 'refs/remotes', '--format=%(objectname)']).splitlines() other_branches = set(all_branches) - set([current_branch]) base_commit = self.run(['git', 'merge-base', 'HEAD'] + list(other_branches)).strip() return self.run(['git', 'diff', '--name-only', '-z', 'HEAD', base_commit]).strip('\0').split('\0') @property def has_uncommitted_changes(self): stat = [s for s in self.run(['git', 'diff', '--cached', '--name-only', '--diff-filter=AMD']).split() if s] return len(stat) > 0 vcs_class = { 'git': GitHelper, 'hg': HgHelper, }