Bug 794580 - mach mercurial-setup; r=nalexander
authorGregory Szorc <gps@mozilla.com>
Mon, 29 Jul 2013 16:58:40 -0700
changeset 140497 57bf6621279b17e24aa3a30fab1969f9695363b1
parent 140496 37b47ffd5a409af98b6ef492c57298824c727918
child 140498 f8bf18dceb1cbc543981a35cd65304e22db0e9a3
push id1970
push userryanvm@gmail.com
push dateTue, 30 Jul 2013 17:12:32 +0000
treeherderfx-team@72240998c094 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander
bugs794580
milestone25.0a1
Bug 794580 - mach mercurial-setup; r=nalexander DONTBUILD (NPOTB)
build/mach_bootstrap.py
python/mozversioncontrol/mozversioncontrol/__init__.py
python/mozversioncontrol/mozversioncontrol/repoupdate.py
tools/mercurial/hgsetup/__init__.py
tools/mercurial/hgsetup/config.py
tools/mercurial/hgsetup/wizard.py
tools/mercurial/mach_commands.py
--- a/build/mach_bootstrap.py
+++ b/build/mach_bootstrap.py
@@ -23,16 +23,17 @@ want to export this environment variable
 '''.lstrip()
 
 
 # TODO Bug 794506 Integrate with the in-tree virtualenv configuration.
 SEARCH_PATHS = [
     'python/mach',
     'python/mozboot',
     'python/mozbuild',
+    'python/mozversioncontrol',
     'python/blessings',
     'python/configobj',
     'python/psutil',
     'python/which',
     'build/pymake',
     'config',
     'other-licenses/ply',
     'xpcom/idl-parser',
@@ -62,16 +63,18 @@ MACH_MODULES = [
     'python/mozboot/mozboot/mach_commands.py',
     'python/mozbuild/mozbuild/config.py',
     'python/mozbuild/mozbuild/mach_commands.py',
     'python/mozbuild/mozbuild/frontend/mach_commands.py',
     'testing/marionette/mach_commands.py',
     'testing/mochitest/mach_commands.py',
     'testing/xpcshell/mach_commands.py',
     'testing/talos/mach_commands.py',
+    'testing/xpcshell/mach_commands.py',
+    'tools/mercurial/mach_commands.py',
     'tools/mach_commands.py',
 ]
 
 
 CATEGORIES = {
     'build': {
         'short': 'Build Commands',
         'long': 'Interact with the build system',
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozversioncontrol/mozversioncontrol/repoupdate.py
@@ -0,0 +1,30 @@
+# 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 unicode_literals
+
+import os
+import subprocess
+
+
+# The logic here is far from robust. Improvements are welcome.
+
+def update_mercurial_repo(hg, repo, path, revision='default'):
+    """Ensure a HG repository exists at a path and is up to date."""
+    if os.path.exists(path):
+        subprocess.check_call([hg, 'pull', repo], cwd=path)
+    else:
+        subprocess.check_call([hg, 'clone', repo, path])
+
+    subprocess.check_call([hg, 'update', '-r', revision], cwd=path)
+
+
+def update_git_repo(git, repo, path, revision='origin/master'):
+    """Ensure a Git repository exists at a path and is up to date."""
+    if os.path.exists(path):
+        subprocess.check_call([git, 'fetch', '--all'], cwd=path)
+    else:
+        subprocess.check_call([git, 'clone', repo, path])
+
+    subprocess.check_call([git, 'checkout', revision], cwd=path)
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/tools/mercurial/hgsetup/config.py
@@ -0,0 +1,113 @@
+# 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 unicode_literals
+
+from configobj import ConfigObj
+
+
+BUGZILLA_FINGERPRINT = '45:77:35:fd:6f:2c:1c:c2:90:4b:f7:b4:4d:60:c6:97:c5:5c:47:27'
+HG_FINGERPRINT = '10:78:e8:57:2d:95:de:7c:de:90:bd:22:e1:38:17:67:c5:a7:9c:14'
+
+
+class MercurialConfig(object):
+    """Interface for manipulating a Mercurial config file."""
+
+    def __init__(self, infile=None):
+        """Create a new instance, optionally from an existing hgrc file."""
+
+        self._c = ConfigObj(infile=infile, encoding='utf-8',
+            write_empty_values=True)
+
+    @property
+    def config(self):
+        return self._c
+
+    @property
+    def extensions(self):
+        """Returns the set of currently enabled extensions (by name)."""
+        return set(self._c.get('extensions', {}).keys())
+
+    def write(self, fh):
+        return self._c.write(fh)
+
+    def have_valid_username(self):
+        if 'ui' not in self._c:
+            return False
+
+        if 'username' not in self._c['ui']:
+            return False
+
+        # TODO perform actual validation here.
+
+        return True
+
+    def add_mozilla_host_fingerprints(self):
+        """Add host fingerprints so SSL connections don't warn."""
+        if 'hostfingerprints' not in self._c:
+            self._c['hostfingerprints'] = {}
+
+        self._c['hostfingerprints']['bugzilla.mozilla.org'] = \
+            BUGZILLA_FINGERPRINT
+        self._c['hostfingerprints']['hg.mozilla.org'] = HG_FINGERPRINT
+
+    def set_username(self, name, email):
+        """Set the username to use for commits.
+
+        The username consists of a name (typically <firstname> <lastname>) and
+        a well-formed e-mail address.
+        """
+        if 'ui' not in self._c:
+            self._c['ui'] = {}
+
+        username = '%s <%s>' % (name, email)
+
+        self._c['ui']['username'] = username.strip()
+
+    def activate_extension(self, name, path=None):
+        """Activate an extension.
+
+        An extension is defined by its name (in the config) and a filesystem
+        path). For built-in extensions, an empty path is specified.
+        """
+        if not path:
+            path = ''
+
+        if 'extensions' not in self._c:
+            self._c['extensions'] = {}
+
+        self._c['extensions'][name] = path
+
+    def have_recommended_diff_settings(self):
+        if 'diff' not in self._c:
+            return False
+
+        old = dict(self._c['diff'])
+        try:
+            self.ensure_recommended_diff_settings()
+        finally:
+            self._c['diff'].update(old)
+
+        return self._c['diff'] == old
+
+    def ensure_recommended_diff_settings(self):
+        if 'diff' not in self._c:
+            self._c['diff'] = {}
+
+        d = self._c['diff']
+        d['git'] = 1
+        d['showfunc'] = 1
+        d['unified'] = 8
+
+    def autocommit_mq(self, value=True):
+        if 'mqext' not in self._c:
+            self._c['mqext'] = {}
+
+        if value:
+            self._c['mqext']['mqcommit'] = 'auto'
+        else:
+            try:
+                del self._c['mqext']['mqcommit']
+            except KeyError:
+                pass
new file mode 100644
--- /dev/null
+++ b/tools/mercurial/hgsetup/wizard.py
@@ -0,0 +1,285 @@
+# 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 unicode_literals
+
+import difflib
+import errno
+import os
+import sys
+import which
+
+from StringIO import StringIO
+
+from mozversioncontrol.repoupdate import (
+    update_mercurial_repo,
+    update_git_repo,
+)
+
+from .config import MercurialConfig
+
+
+INITIAL_MESSAGE = '''
+I'm going to help you ensure your Mercurial is configured for optimal
+development on Mozilla projects.
+
+If your environment is missing some recommended settings, I'm going to prompt
+you whether you want me to make changes: I won't change anything you might not
+want me changing without your permission!
+
+If your config is up-to-date, I'm just going to ensure all 3rd party extensions
+are up to date and you won't have to do anything.
+
+To begin, press the enter/return key.
+'''.strip()
+
+MISSING_USERNAME = '''
+You don't have a username defined in your Mercurial config file. In order to
+send patches to Mozilla, you'll need to attach a name and email address. If you
+aren't comfortable giving us your full name, pseudonames are acceptable.
+'''.strip()
+
+EXTENSIONS_BEGIN = '''
+I can help you configure a number of Mercurial extensions to make your life
+easier and more productive. I'm going to ask you a series of questions about
+what extensions you want enabled.
+'''.strip()
+
+BAD_DIFF_SETTINGS = '''
+Mozilla developers produce patches in a standard format, but your Mercurial is
+not configured to produce patches in that format.
+'''.strip()
+
+BZEXPORT_INFO = '''
+If you plan on uploading patches to Mozilla, there is an extension called
+bzexport that makes it easy to upload patches from the command line via the
+|hg bzexport| command. More info is available at
+https://hg.mozilla.org/users/tmielczarek_mozilla.com/bzexport
+'''.strip()
+
+MQEXT_INFO = '''
+The mqext extension (https://bitbucket.org/sfink/mqext) provides a number of
+useful abilities to Mercurial, including automatically committing changes to
+your mq patch queue.
+'''.strip()
+
+QIMPORTBZ_INFO = '''
+The qimportbz extension
+(https://hg.mozilla.org/users/robarnold_cmu.edu/qimportbz) makes it possible to
+import patches from Bugzilla using a friendly bz:// URL handler. e.g.
+|hg qimport bz://123456|.
+'''.strip()
+
+FINISHED = '''
+Your Mercurial should now be properly configured and recommended extensions
+should be up to date!
+'''.strip()
+
+
+class MercurialSetupWizard(object):
+    """Command-line wizard to help users configure Mercurial."""
+
+    def __init__(self, state_dir):
+        self.state_dir = state_dir
+        self.ext_dir = os.path.join(state_dir, 'mercurial', 'extensions')
+
+    def run(self, config_path):
+        try:
+            os.makedirs(self.ext_dir)
+        except OSError as e:
+            if e.errno != errno.EEXIST:
+                raise
+
+        try:
+            hg = which.which('hg')
+        except which.whichError as e:
+            print(e)
+            print('Try running |mach bootstrap| to ensure your environment is '
+                'up to date.')
+            return 1
+
+        c = MercurialConfig(config_path)
+
+        print(INITIAL_MESSAGE)
+        raw_input()
+
+        if not c.have_valid_username():
+            print(MISSING_USERNAME)
+            print('')
+
+            name = self._prompt('What is your name?')
+            email = self._prompt('What is your email address?')
+            c.set_username(name, email)
+            print('Updated your username.')
+            print('')
+
+        if not c.have_recommended_diff_settings():
+            print(BAD_DIFF_SETTINGS)
+            print('')
+            if self._prompt_yn('Would you like me to fix this for you'):
+                c.ensure_recommended_diff_settings()
+                print('Fixed patch settings.')
+                print('')
+
+        active = c.extensions
+
+        if 'progress' not in active:
+            if self._prompt_yn('Would you like to see progress bars during '
+                'long-running Mercurial operations'):
+                c.activate_extension('progress')
+                print('Activated progress extension.')
+                print('')
+
+        if 'color' not in active:
+            if self._prompt_yn('Would you like Mercurial to colorize output '
+                'to your terminal'):
+                c.activate_extension('color')
+                print('Activated color extension.')
+                print('')
+
+        update_bzexport = 'bzexport' in active
+        if 'bzexport' not in active:
+            print(BZEXPORT_INFO)
+            if self._prompt_yn('Would you like to activate bzexport'):
+                update_bzexport = True
+                c.activate_extension('bzexport', os.path.join(self.ext_dir,
+                    'bzexport'))
+                print('Activated bzexport extension.')
+                print('')
+
+        if update_bzexport:
+            self.update_mercurial_repo(
+                hg,
+                'https://hg.mozilla.org/users/tmielczarek_mozilla.com/bzexport',
+                os.path.join(self.ext_dir, 'bzexport'),
+                'default',
+                'Ensuring bzexport extension is up to date...')
+
+        if 'mq' not in active:
+            if self._prompt_yn('Would you like to activate the mq extension '
+                'to manage patches'):
+                c.activate_extension('mq')
+                print('Activated mq extension.')
+                print('')
+
+        active = c.extensions
+
+        if 'mq' in active:
+            update_mqext = 'mqext' in active
+            if 'mqext' not in active:
+                print(MQEXT_INFO)
+                if self._prompt_yn('Would you like to activate mqext and '
+                    'automatically commit changes as you modify patches'):
+                    update_mqext = True
+                    c.activate_extension('mqext', os.path.join(self.ext_dir,
+                        'mqext'))
+                    c.autocommit_mq(True)
+                    print('Activated mqext extension.')
+                    print('')
+
+            if update_mqext:
+                self.update_mercurial_repo(
+                hg,
+                'https://bitbucket.org/sfink/mqext',
+                os.path.join(self.ext_dir, 'mqext'),
+                'default',
+                'Ensuring mqext extension is up to date...')
+
+            update_qimportbz = 'qimportbz' in active
+            if 'qimportbz' not in active:
+                print(QIMPORTBZ_INFO)
+                if self._prompt_yn('Would you like to activate qimportbz'):
+                    update_qimportbz = True
+                    c.activate_extension('qimportbz',
+                        os.path.join(self.ext_dir, 'qimportbz'))
+                    print('Activated qimportbz extension.')
+                    print('')
+
+            if update_qimportbz:
+                self.update_mercurial_repo(
+                    hg,
+                    'https://hg.mozilla.org/users/robarnold_cmu.edu/qimportbz',
+                    os.path.join(self.ext_dir, 'qimportbz'),
+                    'default',
+                    'Ensuring qimportbz extension is up to date...')
+
+        c.add_mozilla_host_fingerprints()
+
+        b = StringIO()
+        c.write(b)
+        new_lines = [line.rstrip() for line in b.getvalue().splitlines()]
+        old_lines = []
+
+        if os.path.exists(config_path):
+            with open(config_path, 'rt') as fh:
+                old_lines = [line.rstrip() for line in fh.readlines()]
+
+        diff = list(difflib.unified_diff(old_lines, new_lines,
+            'hgrc.old', 'hgrc.new'))
+
+        if len(diff):
+            print('Your Mercurial config file needs updating. I can do this '
+                'for you if you like!')
+            if self._prompt_yn('Would you like to see a diff of the changes '
+                'first'):
+                for line in diff:
+                    print(line)
+                print('')
+
+            if self._prompt_yn('Would you like me to update your hgrc file'):
+                with open(config_path, 'wt') as fh:
+                    c.write(fh)
+                print('Wrote changes to %s.' % config_path)
+            else:
+                print('hgrc changes not written to file. I would have '
+                    'written the following:\n')
+                c.write(sys.stdout)
+                return 1
+
+        print(FINISHED)
+        return 0
+
+    def update_mercurial_repo(self, hg, url, dest, branch, msg):
+        return self._update_repo(hg, url, dest, branch, msg,
+            update_mercurial_repo)
+
+    def update_git_repo(self, git, url, dest, ref, msg):
+        return self._update_repo(git, url, dest, ref, msg, update_git_repo)
+
+    def _update_repo(self, binary, url, dest, branch, msg, fn):
+        print('=' * 80)
+        print(msg)
+        try:
+            fn(binary, url, dest, branch)
+        finally:
+            print('=' * 80)
+            print('')
+
+    def _prompt(self, msg):
+        print(msg)
+
+        while True:
+            response = raw_input()
+
+            if response:
+                return response
+
+            print('You must type something!')
+
+    def _prompt_yn(self, msg):
+        print('%s? [Y/n]' % msg)
+
+        while True:
+            choice = raw_input().lower().strip()
+
+            if not choice:
+                return True
+
+            if choice in ('y', 'yes'):
+                return True
+
+            if choice in ('n', 'no'):
+                return False
+
+            print('Must reply with one of {yes, no, y, no}.')
new file mode 100644
--- /dev/null
+++ b/tools/mercurial/mach_commands.py
@@ -0,0 +1,37 @@
+# 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 print_function, unicode_literals
+
+import os
+import sys
+
+from mach.decorators import (
+    CommandProvider,
+    Command,
+)
+
+
+@CommandProvider
+class VersionControlCommands(object):
+    def __init__(self, context):
+        self._context = context
+
+    @Command('mercurial-setup', category='devenv',
+        description='Help configure Mercurial for optimal development.')
+    def mercurial_bootstrap(self):
+        sys.path.append(os.path.dirname(__file__))
+
+        from hgsetup.wizard import MercurialSetupWizard
+
+        wizard = MercurialSetupWizard(self._context.state_dir)
+        result = wizard.run(os.path.expanduser('~/.hgrc'))
+
+        # Touch a file so we can periodically prompt to update extensions.
+        state_path = os.path.join(self._context.state_dir,
+            'mercurial/setup.lastcheck')
+        with open(state_path, 'a'):
+            os.utime(state_path, None)
+
+        return result