build/util/hg.py

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     1 """Functions for interacting with hg"""
     2 import os
     3 import re
     4 import subprocess
     5 from urlparse import urlsplit
     6 from ConfigParser import RawConfigParser
     8 from util.commands import run_cmd, get_output, remove_path
     9 from util.retry import retry
    11 import logging
    12 log = logging.getLogger(__name__)
    15 class DefaultShareBase:
    16     pass
    17 DefaultShareBase = DefaultShareBase()
    20 class HgUtilError(Exception):
    21     pass
    24 def _make_absolute(repo):
    25     if repo.startswith("file://"):
    26         path = repo[len("file://"):]
    27         repo = "file://%s" % os.path.abspath(path)
    28     elif "://" not in repo:
    29         repo = os.path.abspath(repo)
    30     return repo
    33 def make_hg_url(hgHost, repoPath, protocol='https', revision=None,
    34                 filename=None):
    35     """construct a valid hg url from a base hg url (hg.mozilla.org),
    36     repoPath, revision and possible filename"""
    37     base = '%s://%s' % (protocol, hgHost)
    38     repo = '/'.join(p.strip('/') for p in [base, repoPath])
    39     if not filename:
    40         if not revision:
    41             return repo
    42         else:
    43             return '/'.join([p.strip('/') for p in [repo, 'rev', revision]])
    44     else:
    45         assert revision
    46         return '/'.join([p.strip('/') for p in [repo, 'raw-file', revision, filename]])
    49 def get_repo_name(repo):
    50     return repo.rstrip('/').split('/')[-1]
    53 def get_repo_path(repo):
    54     repo = _make_absolute(repo)
    55     if repo.startswith("/"):
    56         return repo.lstrip("/")
    57     else:
    58         return urlsplit(repo).path.lstrip("/")
    61 def get_revision(path):
    62     """Returns which revision directory `path` currently has checked out."""
    63     return get_output(['hg', 'parent', '--template', '{node|short}'], cwd=path)
    66 def get_branch(path):
    67     return get_output(['hg', 'branch'], cwd=path).strip()
    70 def get_branches(path):
    71     branches = []
    72     for line in get_output(['hg', 'branches', '-c'], cwd=path).splitlines():
    73         branches.append(line.split()[0])
    74     return branches
    77 def is_hg_cset(rev):
    78     """Retruns True if passed revision represents a valid HG revision
    79     (long or short(er) 40 bit hex)"""
    80     try:
    81         int(rev, 16)
    82         return True
    83     except (TypeError, ValueError):
    84         return False
    87 def hg_ver():
    88     """Returns the current version of hg, as a tuple of
    89     (major, minor, build)"""
    90     ver_string = get_output(['hg', '-q', 'version'])
    91     match = re.search("\(version ([0-9.]+)\)", ver_string)
    92     if match:
    93         bits = match.group(1).split(".")
    94         if len(bits) < 3:
    95             bits += (0,)
    96         ver = tuple(int(b) for b in bits)
    97     else:
    98         ver = (0, 0, 0)
    99     log.debug("Running hg version %s", ver)
   100     return ver
   103 def purge(dest):
   104     """Purge the repository of all untracked and ignored files."""
   105     try:
   106         run_cmd(['hg', '--config', 'extensions.purge=', 'purge',
   107                  '-a', '--all', dest], cwd=dest)
   108     except subprocess.CalledProcessError, e:
   109         log.debug('purge failed: %s' % e)
   110         raise
   113 def update(dest, branch=None, revision=None):
   114     """Updates working copy `dest` to `branch` or `revision`.  If neither is
   115     set then the working copy will be updated to the latest revision on the
   116     current branch.  Local changes will be discarded."""
   117     # If we have a revision, switch to that
   118     if revision is not None:
   119         cmd = ['hg', 'update', '-C', '-r', revision]
   120         run_cmd(cmd, cwd=dest)
   121     else:
   122         # Check & switch branch
   123         local_branch = get_output(['hg', 'branch'], cwd=dest).strip()
   125         cmd = ['hg', 'update', '-C']
   127         # If this is different, checkout the other branch
   128         if branch and branch != local_branch:
   129             cmd.append(branch)
   131         run_cmd(cmd, cwd=dest)
   132     return get_revision(dest)
   135 def clone(repo, dest, branch=None, revision=None, update_dest=True,
   136           clone_by_rev=False, mirrors=None, bundles=None):
   137     """Clones hg repo and places it at `dest`, replacing whatever else is
   138     there.  The working copy will be empty.
   140     If `revision` is set, only the specified revision and its ancestors will
   141     be cloned.
   143     If `update_dest` is set, then `dest` will be updated to `revision` if
   144     set, otherwise to `branch`, otherwise to the head of default.
   146     If `mirrors` is set, will try and clone from the mirrors before
   147     cloning from `repo`.
   149     If `bundles` is set, will try and download the bundle first and
   150     unbundle it. If successful, will pull in new revisions from mirrors or
   151     the master repo. If unbundling fails, will fall back to doing a regular
   152     clone from mirrors or the master repo.
   154     Regardless of how the repository ends up being cloned, the 'default' path
   155     will point to `repo`.
   156     """
   157     if os.path.exists(dest):
   158         remove_path(dest)
   160     if bundles:
   161         log.info("Attempting to initialize clone with bundles")
   162         for bundle in bundles:
   163             if os.path.exists(dest):
   164                 remove_path(dest)
   165             init(dest)
   166             log.info("Trying to use bundle %s", bundle)
   167             try:
   168                 if not unbundle(bundle, dest):
   169                     remove_path(dest)
   170                     continue
   171                 adjust_paths(dest, default=repo)
   172                 # Now pull / update
   173                 return pull(repo, dest, update_dest=update_dest,
   174                             mirrors=mirrors, revision=revision, branch=branch)
   175             except Exception:
   176                 remove_path(dest)
   177                 log.exception("Problem unbundling/pulling from %s", bundle)
   178                 continue
   179         else:
   180             log.info("Using bundles failed; falling back to clone")
   182     if mirrors:
   183         log.info("Attempting to clone from mirrors")
   184         for mirror in mirrors:
   185             log.info("Cloning from %s", mirror)
   186             try:
   187                 retval = clone(mirror, dest, branch, revision,
   188                                update_dest=update_dest, clone_by_rev=clone_by_rev)
   189                 adjust_paths(dest, default=repo)
   190                 return retval
   191             except:
   192                 log.exception("Problem cloning from mirror %s", mirror)
   193                 continue
   194         else:
   195             log.info("Pulling from mirrors failed; falling back to %s", repo)
   196             # We may have a partial repo here; mercurial() copes with that
   197             # We need to make sure our paths are correct though
   198             if os.path.exists(os.path.join(dest, '.hg')):
   199                 adjust_paths(dest, default=repo)
   200             return mercurial(repo, dest, branch, revision, autoPurge=True,
   201                              update_dest=update_dest, clone_by_rev=clone_by_rev)
   203     cmd = ['hg', 'clone']
   204     if not update_dest:
   205         cmd.append('-U')
   207     if clone_by_rev:
   208         if revision:
   209             cmd.extend(['-r', revision])
   210         elif branch:
   211             # hg >= 1.6 supports -b branch for cloning
   212             ver = hg_ver()
   213             if ver >= (1, 6, 0):
   214                 cmd.extend(['-b', branch])
   216     cmd.extend([repo, dest])
   217     run_cmd(cmd)
   219     if update_dest:
   220         return update(dest, branch, revision)
   223 def common_args(revision=None, branch=None, ssh_username=None, ssh_key=None):
   224     """Fill in common hg arguments, encapsulating logic checks that depend on
   225        mercurial versions and provided arguments"""
   226     args = []
   227     if ssh_username or ssh_key:
   228         opt = ['-e', 'ssh']
   229         if ssh_username:
   230             opt[1] += ' -l %s' % ssh_username
   231         if ssh_key:
   232             opt[1] += ' -i %s' % ssh_key
   233         args.extend(opt)
   234     if revision:
   235         args.extend(['-r', revision])
   236     elif branch:
   237         if hg_ver() >= (1, 6, 0):
   238             args.extend(['-b', branch])
   239     return args
   242 def pull(repo, dest, update_dest=True, mirrors=None, **kwargs):
   243     """Pulls changes from hg repo and places it in `dest`.
   245     If `update_dest` is set, then `dest` will be updated to `revision` if
   246     set, otherwise to `branch`, otherwise to the head of default.
   248     If `mirrors` is set, will try and pull from the mirrors first before
   249     `repo`."""
   251     if mirrors:
   252         for mirror in mirrors:
   253             try:
   254                 return pull(mirror, dest, update_dest=update_dest, **kwargs)
   255             except:
   256                 log.exception("Problem pulling from mirror %s", mirror)
   257                 continue
   258         else:
   259             log.info("Pulling from mirrors failed; falling back to %s", repo)
   261     # Convert repo to an absolute path if it's a local repository
   262     repo = _make_absolute(repo)
   263     cmd = ['hg', 'pull']
   264     # Don't pass -r to "hg pull", except when it's a valid HG revision.
   265     # Pulling using tag names is dangerous: it uses the local .hgtags, so if
   266     # the tag has moved on the remote side you won't pull the new revision the
   267     # remote tag refers to.
   268     pull_kwargs = kwargs.copy()
   269     if 'revision' in pull_kwargs and \
   270        not is_hg_cset(pull_kwargs['revision']):
   271         del pull_kwargs['revision']
   273     cmd.extend(common_args(**pull_kwargs))
   275     cmd.append(repo)
   276     run_cmd(cmd, cwd=dest)
   278     if update_dest:
   279         branch = None
   280         if 'branch' in kwargs and kwargs['branch']:
   281             branch = kwargs['branch']
   282         revision = None
   283         if 'revision' in kwargs and kwargs['revision']:
   284             revision = kwargs['revision']
   285         return update(dest, branch=branch, revision=revision)
   287 # Defines the places of attributes in the tuples returned by `out'
   288 REVISION, BRANCH = 0, 1
   291 def out(src, remote, **kwargs):
   292     """Check for outgoing changesets present in a repo"""
   293     cmd = ['hg', '-q', 'out', '--template', '{node} {branches}\n']
   294     cmd.extend(common_args(**kwargs))
   295     cmd.append(remote)
   296     if os.path.exists(src):
   297         try:
   298             revs = []
   299             for line in get_output(cmd, cwd=src).rstrip().split("\n"):
   300                 try:
   301                     rev, branch = line.split()
   302                 # Mercurial displays no branch at all if the revision is on
   303                 # "default"
   304                 except ValueError:
   305                     rev = line.rstrip()
   306                     branch = "default"
   307                 revs.append((rev, branch))
   308             return revs
   309         except subprocess.CalledProcessError, inst:
   310             # In some situations, some versions of Mercurial return "1"
   311             # if no changes are found, so we need to ignore this return code
   312             if inst.returncode == 1:
   313                 return []
   314             raise
   317 def push(src, remote, push_new_branches=True, force=False, **kwargs):
   318     cmd = ['hg', 'push']
   319     cmd.extend(common_args(**kwargs))
   320     if force:
   321         cmd.append('-f')
   322     if push_new_branches:
   323         cmd.append('--new-branch')
   324     cmd.append(remote)
   325     run_cmd(cmd, cwd=src)
   328 def mercurial(repo, dest, branch=None, revision=None, update_dest=True,
   329               shareBase=DefaultShareBase, allowUnsharedLocalClones=False,
   330               clone_by_rev=False, mirrors=None, bundles=None, autoPurge=False):
   331     """Makes sure that `dest` is has `revision` or `branch` checked out from
   332     `repo`.
   334     Do what it takes to make that happen, including possibly clobbering
   335     dest.
   337     If allowUnsharedLocalClones is True and we're trying to use the share
   338     extension but fail, then we will be able to clone from the shared repo to
   339     our destination.  If this is False, the default, then if we don't have the
   340     share extension we will just clone from the remote repository.
   342     If `clone_by_rev` is True, use 'hg clone -r <rev>' instead of 'hg clone'.
   343     This is slower, but useful when cloning repos with lots of heads.
   345     If `mirrors` is set, will try and use the mirrors before `repo`.
   347     If `bundles` is set, will try and download the bundle first and
   348     unbundle it instead of doing a full clone. If successful, will pull in
   349     new revisions from mirrors or the master repo. If unbundling fails, will
   350     fall back to doing a regular clone from mirrors or the master repo.
   351     """
   352     dest = os.path.abspath(dest)
   353     if shareBase is DefaultShareBase:
   354         shareBase = os.environ.get("HG_SHARE_BASE_DIR", None)
   356     log.info("Reporting hg version in use")
   357     cmd = ['hg', '-q', 'version']
   358     run_cmd(cmd, cwd='.')
   360     if shareBase:
   361         # Check that 'hg share' works
   362         try:
   363             log.info("Checking if share extension works")
   364             output = get_output(['hg', 'help', 'share'], dont_log=True)
   365             if 'no commands defined' in output:
   366                 # Share extension is enabled, but not functional
   367                 log.info("Disabling sharing since share extension doesn't seem to work (1)")
   368                 shareBase = None
   369             elif 'unknown command' in output:
   370                 # Share extension is disabled
   371                 log.info("Disabling sharing since share extension doesn't seem to work (2)")
   372                 shareBase = None
   373         except subprocess.CalledProcessError:
   374             # The command failed, so disable sharing
   375             log.info("Disabling sharing since share extension doesn't seem to work (3)")
   376             shareBase = None
   378     # Check that our default path is correct
   379     if os.path.exists(os.path.join(dest, '.hg')):
   380         hgpath = path(dest, "default")
   382         # Make sure that our default path is correct
   383         if hgpath != _make_absolute(repo):
   384             log.info("hg path isn't correct (%s should be %s); clobbering",
   385                      hgpath, _make_absolute(repo))
   386             remove_path(dest)
   388     # If the working directory already exists and isn't using share we update
   389     # the working directory directly from the repo, ignoring the sharing
   390     # settings
   391     if os.path.exists(dest):
   392         if not os.path.exists(os.path.join(dest, ".hg")):
   393             log.warning("%s doesn't appear to be a valid hg directory; clobbering", dest)
   394             remove_path(dest)
   395         elif not os.path.exists(os.path.join(dest, ".hg", "sharedpath")):
   396             try:
   397                 if autoPurge:
   398                     purge(dest)
   399                 return pull(repo, dest, update_dest=update_dest, branch=branch,
   400                             revision=revision,
   401                             mirrors=mirrors)
   402             except subprocess.CalledProcessError:
   403                 log.warning("Error pulling changes into %s from %s; clobbering", dest, repo)
   404                 log.debug("Exception:", exc_info=True)
   405                 remove_path(dest)
   407     # If that fails for any reason, and sharing is requested, we'll try to
   408     # update the shared repository, and then update the working directory from
   409     # that.
   410     if shareBase:
   411         sharedRepo = os.path.join(shareBase, get_repo_path(repo))
   412         dest_sharedPath = os.path.join(dest, '.hg', 'sharedpath')
   414         if os.path.exists(sharedRepo):
   415             hgpath = path(sharedRepo, "default")
   417             # Make sure that our default path is correct
   418             if hgpath != _make_absolute(repo):
   419                 log.info("hg path isn't correct (%s should be %s); clobbering",
   420                          hgpath, _make_absolute(repo))
   421                 # we need to clobber both the shared checkout and the dest,
   422                 # since hgrc needs to be in both places
   423                 remove_path(sharedRepo)
   424                 remove_path(dest)
   426         if os.path.exists(dest_sharedPath):
   427             # Make sure that the sharedpath points to sharedRepo
   428             dest_sharedPath_data = os.path.normpath(
   429                 open(dest_sharedPath).read())
   430             norm_sharedRepo = os.path.normpath(os.path.join(sharedRepo, '.hg'))
   431             if dest_sharedPath_data != norm_sharedRepo:
   432                 # Clobber!
   433                 log.info("We're currently shared from %s, but are being requested to pull from %s (%s); clobbering",
   434                          dest_sharedPath_data, repo, norm_sharedRepo)
   435                 remove_path(dest)
   437         try:
   438             log.info("Updating shared repo")
   439             mercurial(repo, sharedRepo, branch=branch, revision=revision,
   440                       update_dest=False, shareBase=None, clone_by_rev=clone_by_rev,
   441                       mirrors=mirrors, bundles=bundles, autoPurge=False)
   442             if os.path.exists(dest):
   443                 if autoPurge:
   444                     purge(dest)
   445                 return update(dest, branch=branch, revision=revision)
   447             try:
   448                 log.info("Trying to share %s to %s", sharedRepo, dest)
   449                 return share(sharedRepo, dest, branch=branch, revision=revision)
   450             except subprocess.CalledProcessError:
   451                 if not allowUnsharedLocalClones:
   452                     # Re-raise the exception so it gets caught below.
   453                     # We'll then clobber dest, and clone from original repo
   454                     raise
   456                 log.warning("Error calling hg share from %s to %s;"
   457                             "falling back to normal clone from shared repo",
   458                             sharedRepo, dest)
   459                 # Do a full local clone first, and then update to the
   460                 # revision we want
   461                 # This lets us use hardlinks for the local clone if the OS
   462                 # supports it
   463                 clone(sharedRepo, dest, update_dest=False,
   464                       mirrors=mirrors, bundles=bundles)
   465                 return update(dest, branch=branch, revision=revision)
   466         except subprocess.CalledProcessError:
   467             log.warning(
   468                 "Error updating %s from sharedRepo (%s): ", dest, sharedRepo)
   469             log.debug("Exception:", exc_info=True)
   470             remove_path(dest)
   471     # end if shareBase
   473     if not os.path.exists(os.path.dirname(dest)):
   474         os.makedirs(os.path.dirname(dest))
   476     # Share isn't available or has failed, clone directly from the source
   477     return clone(repo, dest, branch, revision,
   478                  update_dest=update_dest, mirrors=mirrors,
   479                  bundles=bundles, clone_by_rev=clone_by_rev)
   482 def apply_and_push(localrepo, remote, changer, max_attempts=10,
   483                    ssh_username=None, ssh_key=None, force=False):
   484     """This function calls `changer' to make changes to the repo, and tries
   485        its hardest to get them to the origin repo. `changer' must be a
   486        callable object that receives two arguments: the directory of the local
   487        repository, and the attempt number. This function will push ALL
   488        changesets missing from remote."""
   489     assert callable(changer)
   490     branch = get_branch(localrepo)
   491     changer(localrepo, 1)
   492     for n in range(1, max_attempts + 1):
   493         new_revs = []
   494         try:
   495             new_revs = out(src=localrepo, remote=remote,
   496                            ssh_username=ssh_username,
   497                            ssh_key=ssh_key)
   498             if len(new_revs) < 1:
   499                 raise HgUtilError("No revs to push")
   500             push(src=localrepo, remote=remote, ssh_username=ssh_username,
   501                  ssh_key=ssh_key, force=force)
   502             return
   503         except subprocess.CalledProcessError, e:
   504             log.debug("Hit error when trying to push: %s" % str(e))
   505             if n == max_attempts:
   506                 log.debug("Tried %d times, giving up" % max_attempts)
   507                 for r in reversed(new_revs):
   508                     run_cmd(['hg', '--config', 'extensions.mq=', 'strip', '-n',
   509                              r[REVISION]], cwd=localrepo)
   510                 raise HgUtilError("Failed to push")
   511             pull(remote, localrepo, update_dest=False,
   512                  ssh_username=ssh_username, ssh_key=ssh_key)
   513             # After we successfully rebase or strip away heads the push is
   514             # is attempted again at the start of the loop
   515             try:
   516                 run_cmd(['hg', '--config', 'ui.merge=internal:merge',
   517                          'rebase'], cwd=localrepo)
   518             except subprocess.CalledProcessError, e:
   519                 log.debug("Failed to rebase: %s" % str(e))
   520                 update(localrepo, branch=branch)
   521                 for r in reversed(new_revs):
   522                     run_cmd(['hg', '--config', 'extensions.mq=', 'strip', '-n',
   523                              r[REVISION]], cwd=localrepo)
   524                 changer(localrepo, n + 1)
   527 def share(source, dest, branch=None, revision=None):
   528     """Creates a new working directory in "dest" that shares history with
   529        "source" using Mercurial's share extension"""
   530     run_cmd(['hg', 'share', '-U', source, dest])
   531     return update(dest, branch=branch, revision=revision)
   534 def cleanOutgoingRevs(reponame, remote, username, sshKey):
   535     outgoingRevs = retry(out, kwargs=dict(src=reponame, remote=remote,
   536                                           ssh_username=username,
   537                                           ssh_key=sshKey))
   538     for r in reversed(outgoingRevs):
   539         run_cmd(['hg', '--config', 'extensions.mq=', 'strip', '-n',
   540                  r[REVISION]], cwd=reponame)
   543 def path(src, name='default'):
   544     """Returns the remote path associated with "name" """
   545     try:
   546         return get_output(['hg', 'path', name], cwd=src).strip()
   547     except subprocess.CalledProcessError:
   548         return None
   551 def init(dest):
   552     """Initializes an empty repo in `dest`"""
   553     run_cmd(['hg', 'init', dest])
   556 def unbundle(bundle, dest):
   557     """Unbundles the bundle located at `bundle` into `dest`.
   559     `bundle` can be a local file or remote url."""
   560     try:
   561         get_output(['hg', 'unbundle', bundle], cwd=dest, include_stderr=True)
   562         return True
   563     except subprocess.CalledProcessError:
   564         return False
   567 def adjust_paths(dest, **paths):
   568     """Adjusts paths in `dest`/.hg/hgrc so that names in `paths` are set to
   569     paths[name].
   571     Note that any comments in the hgrc will be lost if changes are made to the
   572     file."""
   573     hgrc = os.path.join(dest, '.hg', 'hgrc')
   574     config = RawConfigParser()
   575     config.read(hgrc)
   577     if not config.has_section('paths'):
   578         config.add_section('paths')
   580     changed = False
   581     for path_name, path_value in paths.items():
   582         if (not config.has_option('paths', path_name) or
   583                 config.get('paths', path_name) != path_value):
   584             changed = True
   585             config.set('paths', path_name, path_value)
   587     if changed:
   588         config.write(open(hgrc, 'w'))
   591 def commit(dest, msg, user=None):
   592     cmd = ['hg', 'commit', '-m', msg]
   593     if user:
   594         cmd.extend(['-u', user])
   595     run_cmd(cmd, cwd=dest)
   596     return get_revision(dest)
   599 def tag(dest, tags, user=None, msg=None, rev=None, force=None):
   600     cmd = ['hg', 'tag']
   601     if user:
   602         cmd.extend(['-u', user])
   603     if msg:
   604         cmd.extend(['-m', msg])
   605     if rev:
   606         cmd.extend(['-r', rev])
   607     if force:
   608         cmd.append('-f')
   609     cmd.extend(tags)
   610     run_cmd(cmd, cwd=dest)
   611     return get_revision(dest)

mercurial