michael@0: """Functions for interacting with hg""" michael@0: import os michael@0: import re michael@0: import subprocess michael@0: from urlparse import urlsplit michael@0: from ConfigParser import RawConfigParser michael@0: michael@0: from util.commands import run_cmd, get_output, remove_path michael@0: from util.retry import retry michael@0: michael@0: import logging michael@0: log = logging.getLogger(__name__) michael@0: michael@0: michael@0: class DefaultShareBase: michael@0: pass michael@0: DefaultShareBase = DefaultShareBase() michael@0: michael@0: michael@0: class HgUtilError(Exception): michael@0: pass michael@0: michael@0: michael@0: def _make_absolute(repo): michael@0: if repo.startswith("file://"): michael@0: path = repo[len("file://"):] michael@0: repo = "file://%s" % os.path.abspath(path) michael@0: elif "://" not in repo: michael@0: repo = os.path.abspath(repo) michael@0: return repo michael@0: michael@0: michael@0: def make_hg_url(hgHost, repoPath, protocol='https', revision=None, michael@0: filename=None): michael@0: """construct a valid hg url from a base hg url (hg.mozilla.org), michael@0: repoPath, revision and possible filename""" michael@0: base = '%s://%s' % (protocol, hgHost) michael@0: repo = '/'.join(p.strip('/') for p in [base, repoPath]) michael@0: if not filename: michael@0: if not revision: michael@0: return repo michael@0: else: michael@0: return '/'.join([p.strip('/') for p in [repo, 'rev', revision]]) michael@0: else: michael@0: assert revision michael@0: return '/'.join([p.strip('/') for p in [repo, 'raw-file', revision, filename]]) michael@0: michael@0: michael@0: def get_repo_name(repo): michael@0: return repo.rstrip('/').split('/')[-1] michael@0: michael@0: michael@0: def get_repo_path(repo): michael@0: repo = _make_absolute(repo) michael@0: if repo.startswith("/"): michael@0: return repo.lstrip("/") michael@0: else: michael@0: return urlsplit(repo).path.lstrip("/") michael@0: michael@0: michael@0: def get_revision(path): michael@0: """Returns which revision directory `path` currently has checked out.""" michael@0: return get_output(['hg', 'parent', '--template', '{node|short}'], cwd=path) michael@0: michael@0: michael@0: def get_branch(path): michael@0: return get_output(['hg', 'branch'], cwd=path).strip() michael@0: michael@0: michael@0: def get_branches(path): michael@0: branches = [] michael@0: for line in get_output(['hg', 'branches', '-c'], cwd=path).splitlines(): michael@0: branches.append(line.split()[0]) michael@0: return branches michael@0: michael@0: michael@0: def is_hg_cset(rev): michael@0: """Retruns True if passed revision represents a valid HG revision michael@0: (long or short(er) 40 bit hex)""" michael@0: try: michael@0: int(rev, 16) michael@0: return True michael@0: except (TypeError, ValueError): michael@0: return False michael@0: michael@0: michael@0: def hg_ver(): michael@0: """Returns the current version of hg, as a tuple of michael@0: (major, minor, build)""" michael@0: ver_string = get_output(['hg', '-q', 'version']) michael@0: match = re.search("\(version ([0-9.]+)\)", ver_string) michael@0: if match: michael@0: bits = match.group(1).split(".") michael@0: if len(bits) < 3: michael@0: bits += (0,) michael@0: ver = tuple(int(b) for b in bits) michael@0: else: michael@0: ver = (0, 0, 0) michael@0: log.debug("Running hg version %s", ver) michael@0: return ver michael@0: michael@0: michael@0: def purge(dest): michael@0: """Purge the repository of all untracked and ignored files.""" michael@0: try: michael@0: run_cmd(['hg', '--config', 'extensions.purge=', 'purge', michael@0: '-a', '--all', dest], cwd=dest) michael@0: except subprocess.CalledProcessError, e: michael@0: log.debug('purge failed: %s' % e) michael@0: raise michael@0: michael@0: michael@0: def update(dest, branch=None, revision=None): michael@0: """Updates working copy `dest` to `branch` or `revision`. If neither is michael@0: set then the working copy will be updated to the latest revision on the michael@0: current branch. Local changes will be discarded.""" michael@0: # If we have a revision, switch to that michael@0: if revision is not None: michael@0: cmd = ['hg', 'update', '-C', '-r', revision] michael@0: run_cmd(cmd, cwd=dest) michael@0: else: michael@0: # Check & switch branch michael@0: local_branch = get_output(['hg', 'branch'], cwd=dest).strip() michael@0: michael@0: cmd = ['hg', 'update', '-C'] michael@0: michael@0: # If this is different, checkout the other branch michael@0: if branch and branch != local_branch: michael@0: cmd.append(branch) michael@0: michael@0: run_cmd(cmd, cwd=dest) michael@0: return get_revision(dest) michael@0: michael@0: michael@0: def clone(repo, dest, branch=None, revision=None, update_dest=True, michael@0: clone_by_rev=False, mirrors=None, bundles=None): michael@0: """Clones hg repo and places it at `dest`, replacing whatever else is michael@0: there. The working copy will be empty. michael@0: michael@0: If `revision` is set, only the specified revision and its ancestors will michael@0: be cloned. michael@0: michael@0: If `update_dest` is set, then `dest` will be updated to `revision` if michael@0: set, otherwise to `branch`, otherwise to the head of default. michael@0: michael@0: If `mirrors` is set, will try and clone from the mirrors before michael@0: cloning from `repo`. michael@0: michael@0: If `bundles` is set, will try and download the bundle first and michael@0: unbundle it. If successful, will pull in new revisions from mirrors or michael@0: the master repo. If unbundling fails, will fall back to doing a regular michael@0: clone from mirrors or the master repo. michael@0: michael@0: Regardless of how the repository ends up being cloned, the 'default' path michael@0: will point to `repo`. michael@0: """ michael@0: if os.path.exists(dest): michael@0: remove_path(dest) michael@0: michael@0: if bundles: michael@0: log.info("Attempting to initialize clone with bundles") michael@0: for bundle in bundles: michael@0: if os.path.exists(dest): michael@0: remove_path(dest) michael@0: init(dest) michael@0: log.info("Trying to use bundle %s", bundle) michael@0: try: michael@0: if not unbundle(bundle, dest): michael@0: remove_path(dest) michael@0: continue michael@0: adjust_paths(dest, default=repo) michael@0: # Now pull / update michael@0: return pull(repo, dest, update_dest=update_dest, michael@0: mirrors=mirrors, revision=revision, branch=branch) michael@0: except Exception: michael@0: remove_path(dest) michael@0: log.exception("Problem unbundling/pulling from %s", bundle) michael@0: continue michael@0: else: michael@0: log.info("Using bundles failed; falling back to clone") michael@0: michael@0: if mirrors: michael@0: log.info("Attempting to clone from mirrors") michael@0: for mirror in mirrors: michael@0: log.info("Cloning from %s", mirror) michael@0: try: michael@0: retval = clone(mirror, dest, branch, revision, michael@0: update_dest=update_dest, clone_by_rev=clone_by_rev) michael@0: adjust_paths(dest, default=repo) michael@0: return retval michael@0: except: michael@0: log.exception("Problem cloning from mirror %s", mirror) michael@0: continue michael@0: else: michael@0: log.info("Pulling from mirrors failed; falling back to %s", repo) michael@0: # We may have a partial repo here; mercurial() copes with that michael@0: # We need to make sure our paths are correct though michael@0: if os.path.exists(os.path.join(dest, '.hg')): michael@0: adjust_paths(dest, default=repo) michael@0: return mercurial(repo, dest, branch, revision, autoPurge=True, michael@0: update_dest=update_dest, clone_by_rev=clone_by_rev) michael@0: michael@0: cmd = ['hg', 'clone'] michael@0: if not update_dest: michael@0: cmd.append('-U') michael@0: michael@0: if clone_by_rev: michael@0: if revision: michael@0: cmd.extend(['-r', revision]) michael@0: elif branch: michael@0: # hg >= 1.6 supports -b branch for cloning michael@0: ver = hg_ver() michael@0: if ver >= (1, 6, 0): michael@0: cmd.extend(['-b', branch]) michael@0: michael@0: cmd.extend([repo, dest]) michael@0: run_cmd(cmd) michael@0: michael@0: if update_dest: michael@0: return update(dest, branch, revision) michael@0: michael@0: michael@0: def common_args(revision=None, branch=None, ssh_username=None, ssh_key=None): michael@0: """Fill in common hg arguments, encapsulating logic checks that depend on michael@0: mercurial versions and provided arguments""" michael@0: args = [] michael@0: if ssh_username or ssh_key: michael@0: opt = ['-e', 'ssh'] michael@0: if ssh_username: michael@0: opt[1] += ' -l %s' % ssh_username michael@0: if ssh_key: michael@0: opt[1] += ' -i %s' % ssh_key michael@0: args.extend(opt) michael@0: if revision: michael@0: args.extend(['-r', revision]) michael@0: elif branch: michael@0: if hg_ver() >= (1, 6, 0): michael@0: args.extend(['-b', branch]) michael@0: return args michael@0: michael@0: michael@0: def pull(repo, dest, update_dest=True, mirrors=None, **kwargs): michael@0: """Pulls changes from hg repo and places it in `dest`. michael@0: michael@0: If `update_dest` is set, then `dest` will be updated to `revision` if michael@0: set, otherwise to `branch`, otherwise to the head of default. michael@0: michael@0: If `mirrors` is set, will try and pull from the mirrors first before michael@0: `repo`.""" michael@0: michael@0: if mirrors: michael@0: for mirror in mirrors: michael@0: try: michael@0: return pull(mirror, dest, update_dest=update_dest, **kwargs) michael@0: except: michael@0: log.exception("Problem pulling from mirror %s", mirror) michael@0: continue michael@0: else: michael@0: log.info("Pulling from mirrors failed; falling back to %s", repo) michael@0: michael@0: # Convert repo to an absolute path if it's a local repository michael@0: repo = _make_absolute(repo) michael@0: cmd = ['hg', 'pull'] michael@0: # Don't pass -r to "hg pull", except when it's a valid HG revision. michael@0: # Pulling using tag names is dangerous: it uses the local .hgtags, so if michael@0: # the tag has moved on the remote side you won't pull the new revision the michael@0: # remote tag refers to. michael@0: pull_kwargs = kwargs.copy() michael@0: if 'revision' in pull_kwargs and \ michael@0: not is_hg_cset(pull_kwargs['revision']): michael@0: del pull_kwargs['revision'] michael@0: michael@0: cmd.extend(common_args(**pull_kwargs)) michael@0: michael@0: cmd.append(repo) michael@0: run_cmd(cmd, cwd=dest) michael@0: michael@0: if update_dest: michael@0: branch = None michael@0: if 'branch' in kwargs and kwargs['branch']: michael@0: branch = kwargs['branch'] michael@0: revision = None michael@0: if 'revision' in kwargs and kwargs['revision']: michael@0: revision = kwargs['revision'] michael@0: return update(dest, branch=branch, revision=revision) michael@0: michael@0: # Defines the places of attributes in the tuples returned by `out' michael@0: REVISION, BRANCH = 0, 1 michael@0: michael@0: michael@0: def out(src, remote, **kwargs): michael@0: """Check for outgoing changesets present in a repo""" michael@0: cmd = ['hg', '-q', 'out', '--template', '{node} {branches}\n'] michael@0: cmd.extend(common_args(**kwargs)) michael@0: cmd.append(remote) michael@0: if os.path.exists(src): michael@0: try: michael@0: revs = [] michael@0: for line in get_output(cmd, cwd=src).rstrip().split("\n"): michael@0: try: michael@0: rev, branch = line.split() michael@0: # Mercurial displays no branch at all if the revision is on michael@0: # "default" michael@0: except ValueError: michael@0: rev = line.rstrip() michael@0: branch = "default" michael@0: revs.append((rev, branch)) michael@0: return revs michael@0: except subprocess.CalledProcessError, inst: michael@0: # In some situations, some versions of Mercurial return "1" michael@0: # if no changes are found, so we need to ignore this return code michael@0: if inst.returncode == 1: michael@0: return [] michael@0: raise michael@0: michael@0: michael@0: def push(src, remote, push_new_branches=True, force=False, **kwargs): michael@0: cmd = ['hg', 'push'] michael@0: cmd.extend(common_args(**kwargs)) michael@0: if force: michael@0: cmd.append('-f') michael@0: if push_new_branches: michael@0: cmd.append('--new-branch') michael@0: cmd.append(remote) michael@0: run_cmd(cmd, cwd=src) michael@0: michael@0: michael@0: def mercurial(repo, dest, branch=None, revision=None, update_dest=True, michael@0: shareBase=DefaultShareBase, allowUnsharedLocalClones=False, michael@0: clone_by_rev=False, mirrors=None, bundles=None, autoPurge=False): michael@0: """Makes sure that `dest` is has `revision` or `branch` checked out from michael@0: `repo`. michael@0: michael@0: Do what it takes to make that happen, including possibly clobbering michael@0: dest. michael@0: michael@0: If allowUnsharedLocalClones is True and we're trying to use the share michael@0: extension but fail, then we will be able to clone from the shared repo to michael@0: our destination. If this is False, the default, then if we don't have the michael@0: share extension we will just clone from the remote repository. michael@0: michael@0: If `clone_by_rev` is True, use 'hg clone -r ' instead of 'hg clone'. michael@0: This is slower, but useful when cloning repos with lots of heads. michael@0: michael@0: If `mirrors` is set, will try and use the mirrors before `repo`. michael@0: michael@0: If `bundles` is set, will try and download the bundle first and michael@0: unbundle it instead of doing a full clone. If successful, will pull in michael@0: new revisions from mirrors or the master repo. If unbundling fails, will michael@0: fall back to doing a regular clone from mirrors or the master repo. michael@0: """ michael@0: dest = os.path.abspath(dest) michael@0: if shareBase is DefaultShareBase: michael@0: shareBase = os.environ.get("HG_SHARE_BASE_DIR", None) michael@0: michael@0: log.info("Reporting hg version in use") michael@0: cmd = ['hg', '-q', 'version'] michael@0: run_cmd(cmd, cwd='.') michael@0: michael@0: if shareBase: michael@0: # Check that 'hg share' works michael@0: try: michael@0: log.info("Checking if share extension works") michael@0: output = get_output(['hg', 'help', 'share'], dont_log=True) michael@0: if 'no commands defined' in output: michael@0: # Share extension is enabled, but not functional michael@0: log.info("Disabling sharing since share extension doesn't seem to work (1)") michael@0: shareBase = None michael@0: elif 'unknown command' in output: michael@0: # Share extension is disabled michael@0: log.info("Disabling sharing since share extension doesn't seem to work (2)") michael@0: shareBase = None michael@0: except subprocess.CalledProcessError: michael@0: # The command failed, so disable sharing michael@0: log.info("Disabling sharing since share extension doesn't seem to work (3)") michael@0: shareBase = None michael@0: michael@0: # Check that our default path is correct michael@0: if os.path.exists(os.path.join(dest, '.hg')): michael@0: hgpath = path(dest, "default") michael@0: michael@0: # Make sure that our default path is correct michael@0: if hgpath != _make_absolute(repo): michael@0: log.info("hg path isn't correct (%s should be %s); clobbering", michael@0: hgpath, _make_absolute(repo)) michael@0: remove_path(dest) michael@0: michael@0: # If the working directory already exists and isn't using share we update michael@0: # the working directory directly from the repo, ignoring the sharing michael@0: # settings michael@0: if os.path.exists(dest): michael@0: if not os.path.exists(os.path.join(dest, ".hg")): michael@0: log.warning("%s doesn't appear to be a valid hg directory; clobbering", dest) michael@0: remove_path(dest) michael@0: elif not os.path.exists(os.path.join(dest, ".hg", "sharedpath")): michael@0: try: michael@0: if autoPurge: michael@0: purge(dest) michael@0: return pull(repo, dest, update_dest=update_dest, branch=branch, michael@0: revision=revision, michael@0: mirrors=mirrors) michael@0: except subprocess.CalledProcessError: michael@0: log.warning("Error pulling changes into %s from %s; clobbering", dest, repo) michael@0: log.debug("Exception:", exc_info=True) michael@0: remove_path(dest) michael@0: michael@0: # If that fails for any reason, and sharing is requested, we'll try to michael@0: # update the shared repository, and then update the working directory from michael@0: # that. michael@0: if shareBase: michael@0: sharedRepo = os.path.join(shareBase, get_repo_path(repo)) michael@0: dest_sharedPath = os.path.join(dest, '.hg', 'sharedpath') michael@0: michael@0: if os.path.exists(sharedRepo): michael@0: hgpath = path(sharedRepo, "default") michael@0: michael@0: # Make sure that our default path is correct michael@0: if hgpath != _make_absolute(repo): michael@0: log.info("hg path isn't correct (%s should be %s); clobbering", michael@0: hgpath, _make_absolute(repo)) michael@0: # we need to clobber both the shared checkout and the dest, michael@0: # since hgrc needs to be in both places michael@0: remove_path(sharedRepo) michael@0: remove_path(dest) michael@0: michael@0: if os.path.exists(dest_sharedPath): michael@0: # Make sure that the sharedpath points to sharedRepo michael@0: dest_sharedPath_data = os.path.normpath( michael@0: open(dest_sharedPath).read()) michael@0: norm_sharedRepo = os.path.normpath(os.path.join(sharedRepo, '.hg')) michael@0: if dest_sharedPath_data != norm_sharedRepo: michael@0: # Clobber! michael@0: log.info("We're currently shared from %s, but are being requested to pull from %s (%s); clobbering", michael@0: dest_sharedPath_data, repo, norm_sharedRepo) michael@0: remove_path(dest) michael@0: michael@0: try: michael@0: log.info("Updating shared repo") michael@0: mercurial(repo, sharedRepo, branch=branch, revision=revision, michael@0: update_dest=False, shareBase=None, clone_by_rev=clone_by_rev, michael@0: mirrors=mirrors, bundles=bundles, autoPurge=False) michael@0: if os.path.exists(dest): michael@0: if autoPurge: michael@0: purge(dest) michael@0: return update(dest, branch=branch, revision=revision) michael@0: michael@0: try: michael@0: log.info("Trying to share %s to %s", sharedRepo, dest) michael@0: return share(sharedRepo, dest, branch=branch, revision=revision) michael@0: except subprocess.CalledProcessError: michael@0: if not allowUnsharedLocalClones: michael@0: # Re-raise the exception so it gets caught below. michael@0: # We'll then clobber dest, and clone from original repo michael@0: raise michael@0: michael@0: log.warning("Error calling hg share from %s to %s;" michael@0: "falling back to normal clone from shared repo", michael@0: sharedRepo, dest) michael@0: # Do a full local clone first, and then update to the michael@0: # revision we want michael@0: # This lets us use hardlinks for the local clone if the OS michael@0: # supports it michael@0: clone(sharedRepo, dest, update_dest=False, michael@0: mirrors=mirrors, bundles=bundles) michael@0: return update(dest, branch=branch, revision=revision) michael@0: except subprocess.CalledProcessError: michael@0: log.warning( michael@0: "Error updating %s from sharedRepo (%s): ", dest, sharedRepo) michael@0: log.debug("Exception:", exc_info=True) michael@0: remove_path(dest) michael@0: # end if shareBase michael@0: michael@0: if not os.path.exists(os.path.dirname(dest)): michael@0: os.makedirs(os.path.dirname(dest)) michael@0: michael@0: # Share isn't available or has failed, clone directly from the source michael@0: return clone(repo, dest, branch, revision, michael@0: update_dest=update_dest, mirrors=mirrors, michael@0: bundles=bundles, clone_by_rev=clone_by_rev) michael@0: michael@0: michael@0: def apply_and_push(localrepo, remote, changer, max_attempts=10, michael@0: ssh_username=None, ssh_key=None, force=False): michael@0: """This function calls `changer' to make changes to the repo, and tries michael@0: its hardest to get them to the origin repo. `changer' must be a michael@0: callable object that receives two arguments: the directory of the local michael@0: repository, and the attempt number. This function will push ALL michael@0: changesets missing from remote.""" michael@0: assert callable(changer) michael@0: branch = get_branch(localrepo) michael@0: changer(localrepo, 1) michael@0: for n in range(1, max_attempts + 1): michael@0: new_revs = [] michael@0: try: michael@0: new_revs = out(src=localrepo, remote=remote, michael@0: ssh_username=ssh_username, michael@0: ssh_key=ssh_key) michael@0: if len(new_revs) < 1: michael@0: raise HgUtilError("No revs to push") michael@0: push(src=localrepo, remote=remote, ssh_username=ssh_username, michael@0: ssh_key=ssh_key, force=force) michael@0: return michael@0: except subprocess.CalledProcessError, e: michael@0: log.debug("Hit error when trying to push: %s" % str(e)) michael@0: if n == max_attempts: michael@0: log.debug("Tried %d times, giving up" % max_attempts) michael@0: for r in reversed(new_revs): michael@0: run_cmd(['hg', '--config', 'extensions.mq=', 'strip', '-n', michael@0: r[REVISION]], cwd=localrepo) michael@0: raise HgUtilError("Failed to push") michael@0: pull(remote, localrepo, update_dest=False, michael@0: ssh_username=ssh_username, ssh_key=ssh_key) michael@0: # After we successfully rebase or strip away heads the push is michael@0: # is attempted again at the start of the loop michael@0: try: michael@0: run_cmd(['hg', '--config', 'ui.merge=internal:merge', michael@0: 'rebase'], cwd=localrepo) michael@0: except subprocess.CalledProcessError, e: michael@0: log.debug("Failed to rebase: %s" % str(e)) michael@0: update(localrepo, branch=branch) michael@0: for r in reversed(new_revs): michael@0: run_cmd(['hg', '--config', 'extensions.mq=', 'strip', '-n', michael@0: r[REVISION]], cwd=localrepo) michael@0: changer(localrepo, n + 1) michael@0: michael@0: michael@0: def share(source, dest, branch=None, revision=None): michael@0: """Creates a new working directory in "dest" that shares history with michael@0: "source" using Mercurial's share extension""" michael@0: run_cmd(['hg', 'share', '-U', source, dest]) michael@0: return update(dest, branch=branch, revision=revision) michael@0: michael@0: michael@0: def cleanOutgoingRevs(reponame, remote, username, sshKey): michael@0: outgoingRevs = retry(out, kwargs=dict(src=reponame, remote=remote, michael@0: ssh_username=username, michael@0: ssh_key=sshKey)) michael@0: for r in reversed(outgoingRevs): michael@0: run_cmd(['hg', '--config', 'extensions.mq=', 'strip', '-n', michael@0: r[REVISION]], cwd=reponame) michael@0: michael@0: michael@0: def path(src, name='default'): michael@0: """Returns the remote path associated with "name" """ michael@0: try: michael@0: return get_output(['hg', 'path', name], cwd=src).strip() michael@0: except subprocess.CalledProcessError: michael@0: return None michael@0: michael@0: michael@0: def init(dest): michael@0: """Initializes an empty repo in `dest`""" michael@0: run_cmd(['hg', 'init', dest]) michael@0: michael@0: michael@0: def unbundle(bundle, dest): michael@0: """Unbundles the bundle located at `bundle` into `dest`. michael@0: michael@0: `bundle` can be a local file or remote url.""" michael@0: try: michael@0: get_output(['hg', 'unbundle', bundle], cwd=dest, include_stderr=True) michael@0: return True michael@0: except subprocess.CalledProcessError: michael@0: return False michael@0: michael@0: michael@0: def adjust_paths(dest, **paths): michael@0: """Adjusts paths in `dest`/.hg/hgrc so that names in `paths` are set to michael@0: paths[name]. michael@0: michael@0: Note that any comments in the hgrc will be lost if changes are made to the michael@0: file.""" michael@0: hgrc = os.path.join(dest, '.hg', 'hgrc') michael@0: config = RawConfigParser() michael@0: config.read(hgrc) michael@0: michael@0: if not config.has_section('paths'): michael@0: config.add_section('paths') michael@0: michael@0: changed = False michael@0: for path_name, path_value in paths.items(): michael@0: if (not config.has_option('paths', path_name) or michael@0: config.get('paths', path_name) != path_value): michael@0: changed = True michael@0: config.set('paths', path_name, path_value) michael@0: michael@0: if changed: michael@0: config.write(open(hgrc, 'w')) michael@0: michael@0: michael@0: def commit(dest, msg, user=None): michael@0: cmd = ['hg', 'commit', '-m', msg] michael@0: if user: michael@0: cmd.extend(['-u', user]) michael@0: run_cmd(cmd, cwd=dest) michael@0: return get_revision(dest) michael@0: michael@0: michael@0: def tag(dest, tags, user=None, msg=None, rev=None, force=None): michael@0: cmd = ['hg', 'tag'] michael@0: if user: michael@0: cmd.extend(['-u', user]) michael@0: if msg: michael@0: cmd.extend(['-m', msg]) michael@0: if rev: michael@0: cmd.extend(['-r', rev]) michael@0: if force: michael@0: cmd.append('-f') michael@0: cmd.extend(tags) michael@0: run_cmd(cmd, cwd=dest) michael@0: return get_revision(dest)