tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

robustcheckout.py (30814B)


      1 # This software may be used and distributed according to the terms of the
      2 # GNU General Public License version 2 or any later version.
      3 
      4 """Robustly perform a checkout.
      5 
      6 This extension provides the ``hg robustcheckout`` command for
      7 ensuring a working directory is updated to the specified revision
      8 from a source repo using best practices to ensure optimal clone
      9 times and storage efficiency.
     10 """
     11 
     12 from __future__ import absolute_import
     13 
     14 import contextlib
     15 import json
     16 import os
     17 import random
     18 import re
     19 import socket
     20 import ssl
     21 import time
     22 
     23 from mercurial.i18n import _
     24 from mercurial.node import hex, nullid
     25 from mercurial import (
     26    commands,
     27    configitems,
     28    error,
     29    exchange,
     30    extensions,
     31    hg,
     32    match as matchmod,
     33    pycompat,
     34    registrar,
     35    scmutil,
     36    urllibcompat,
     37    util,
     38    vfs,
     39 )
     40 
     41 # Causes worker to purge caches on process exit and for task to retry.
     42 EXIT_PURGE_CACHE = 72
     43 
     44 testedwith = (
     45    b"4.5 4.6 4.7 4.8 4.9 5.0 5.1 5.2 5.3 5.4 5.5 5.6 5.7 5.8 5.9 6.0 6.1 6.2 6.3 6.4"
     46 )
     47 minimumhgversion = b"4.5"
     48 
     49 cmdtable = {}
     50 command = registrar.command(cmdtable)
     51 
     52 configtable = {}
     53 configitem = registrar.configitem(configtable)
     54 
     55 configitem(b"robustcheckout", b"retryjittermin", default=configitems.dynamicdefault)
     56 configitem(b"robustcheckout", b"retryjittermax", default=configitems.dynamicdefault)
     57 
     58 
     59 def getsparse():
     60    from mercurial import sparse
     61 
     62    return sparse
     63 
     64 
     65 def peerlookup(remote, v):
     66    with remote.commandexecutor() as e:
     67        return e.callcommand(b"lookup", {b"key": v}).result()
     68 
     69 
     70 @command(
     71    b"robustcheckout",
     72    [
     73        (b"", b"upstream", b"", b"URL of upstream repo to clone from"),
     74        (b"r", b"revision", b"", b"Revision to check out"),
     75        (b"b", b"branch", b"", b"Branch to check out"),
     76        (b"", b"purge", False, b"Whether to purge the working directory"),
     77        (b"", b"sharebase", b"", b"Directory where shared repos should be placed"),
     78        (
     79            b"",
     80            b"networkattempts",
     81            3,
     82            b"Maximum number of attempts for network " b"operations",
     83        ),
     84        (b"", b"sparseprofile", b"", b"Sparse checkout profile to use (path in repo)"),
     85        (
     86            b"U",
     87            b"noupdate",
     88            False,
     89            b"the clone will include an empty working directory\n"
     90            b"(only a repository)",
     91        ),
     92    ],
     93    b"[OPTION]... URL DEST",
     94    norepo=True,
     95 )
     96 def robustcheckout(
     97    ui,
     98    url,
     99    dest,
    100    upstream=None,
    101    revision=None,
    102    branch=None,
    103    purge=False,
    104    sharebase=None,
    105    networkattempts=None,
    106    sparseprofile=None,
    107    noupdate=False,
    108 ):
    109    """Ensure a working copy has the specified revision checked out.
    110 
    111    Repository data is automatically pooled into the common directory
    112    specified by ``--sharebase``, which is a required argument. It is required
    113    because pooling storage prevents excessive cloning, which makes operations
    114    complete faster.
    115 
    116    One of ``--revision`` or ``--branch`` must be specified. ``--revision``
    117    is preferred, as it is deterministic and there is no ambiguity as to which
    118    revision will actually be checked out.
    119 
    120    If ``--upstream`` is used, the repo at that URL is used to perform the
    121    initial clone instead of cloning from the repo where the desired revision
    122    is located.
    123 
    124    ``--purge`` controls whether to removed untracked and ignored files from
    125    the working directory. If used, the end state of the working directory
    126    should only contain files explicitly under version control for the requested
    127    revision.
    128 
    129    ``--sparseprofile`` can be used to specify a sparse checkout profile to use.
    130    The sparse checkout profile corresponds to a file in the revision to be
    131    checked out. If a previous sparse profile or config is present, it will be
    132    replaced by this sparse profile. We choose not to "widen" the sparse config
    133    so operations are as deterministic as possible. If an existing checkout
    134    is present and it isn't using a sparse checkout, we error. This is to
    135    prevent accidentally enabling sparse on a repository that may have
    136    clients that aren't sparse aware. Sparse checkout support requires Mercurial
    137    4.3 or newer and the ``sparse`` extension must be enabled.
    138    """
    139    if not revision and not branch:
    140        raise error.Abort(b"must specify one of --revision or --branch")
    141 
    142    if revision and branch:
    143        raise error.Abort(b"cannot specify both --revision and --branch")
    144 
    145    # Require revision to look like a SHA-1.
    146    if revision:
    147        if (
    148            len(revision) < 12
    149            or len(revision) > 40
    150            or not re.match(b"^[a-f0-9]+$", revision)
    151        ):
    152            raise error.Abort(
    153                b"--revision must be a SHA-1 fragment 12-40 " b"characters long"
    154            )
    155 
    156    sharebase = sharebase or ui.config(b"share", b"pool")
    157    if not sharebase:
    158        raise error.Abort(
    159            b"share base directory not defined; refusing to operate",
    160            hint=b"define share.pool config option or pass --sharebase",
    161        )
    162 
    163    # Sparse profile support was added in Mercurial 4.3, where it was highly
    164    # experimental. Because of the fragility of it, we only support sparse
    165    # profiles on 4.3. When 4.4 is released, we'll need to opt in to sparse
    166    # support. We /could/ silently fall back to non-sparse when not supported.
    167    # However, given that sparse has performance implications, we want to fail
    168    # fast if we can't satisfy the desired checkout request.
    169    if sparseprofile:
    170        try:
    171            extensions.find(b"sparse")
    172        except KeyError:
    173            raise error.Abort(
    174                b"sparse extension must be enabled to use " b"--sparseprofile"
    175            )
    176 
    177    ui.warn(b"(using Mercurial %s)\n" % util.version())
    178 
    179    # worker.backgroundclose only makes things faster if running anti-virus,
    180    # which our automation doesn't. Disable it.
    181    ui.setconfig(b"worker", b"backgroundclose", False)
    182    # Don't wait forever if the connection hangs
    183    ui.setconfig(b"http", b"timeout", 600)
    184 
    185    # By default the progress bar starts after 3s and updates every 0.1s. We
    186    # change this so it shows and updates every 1.0s.
    187    # We also tell progress to assume a TTY is present so updates are printed
    188    # even if there is no known TTY.
    189    # We make the config change here instead of in a config file because
    190    # otherwise we're at the whim of whatever configs are used in automation.
    191    ui.setconfig(b"progress", b"delay", 1.0)
    192    ui.setconfig(b"progress", b"refresh", 1.0)
    193    ui.setconfig(b"progress", b"assume-tty", True)
    194 
    195    sharebase = os.path.realpath(sharebase)
    196 
    197    optimes = []
    198    behaviors = set()
    199    start = time.time()
    200 
    201    try:
    202        return _docheckout(
    203            ui,
    204            url,
    205            dest,
    206            upstream,
    207            revision,
    208            branch,
    209            purge,
    210            sharebase,
    211            optimes,
    212            behaviors,
    213            networkattempts,
    214            sparse_profile=sparseprofile,
    215            noupdate=noupdate,
    216        )
    217    finally:
    218        overall = time.time() - start
    219 
    220        # We store the overall time multiple ways in order to help differentiate
    221        # the various "flavors" of operations.
    222 
    223        # ``overall`` is always the total operation time.
    224        optimes.append(("overall", overall))
    225 
    226        def record_op(name):
    227            # If special behaviors due to "corrupt" storage occur, we vary the
    228            # name to convey that.
    229            if "remove-store" in behaviors:
    230                name += "_rmstore"
    231            if "remove-wdir" in behaviors:
    232                name += "_rmwdir"
    233 
    234            optimes.append((name, overall))
    235 
    236        # We break out overall operations primarily by their network interaction
    237        # We have variants within for working directory operations.
    238        if "clone" in behaviors and "create-store" in behaviors:
    239            record_op("overall_clone")
    240 
    241            if "sparse-update" in behaviors:
    242                record_op("overall_clone_sparsecheckout")
    243            else:
    244                record_op("overall_clone_fullcheckout")
    245 
    246        elif "pull" in behaviors or "clone" in behaviors:
    247            record_op("overall_pull")
    248 
    249            if "sparse-update" in behaviors:
    250                record_op("overall_pull_sparsecheckout")
    251            else:
    252                record_op("overall_pull_fullcheckout")
    253 
    254            if "empty-wdir" in behaviors:
    255                record_op("overall_pull_emptywdir")
    256            else:
    257                record_op("overall_pull_populatedwdir")
    258 
    259        else:
    260            record_op("overall_nopull")
    261 
    262            if "sparse-update" in behaviors:
    263                record_op("overall_nopull_sparsecheckout")
    264            else:
    265                record_op("overall_nopull_fullcheckout")
    266 
    267            if "empty-wdir" in behaviors:
    268                record_op("overall_nopull_emptywdir")
    269            else:
    270                record_op("overall_nopull_populatedwdir")
    271 
    272        server_url = urllibcompat.urlreq.urlparse(url).netloc
    273 
    274        if "TASKCLUSTER_INSTANCE_TYPE" in os.environ:
    275            perfherder = {
    276                "framework": {
    277                    "name": "vcs",
    278                },
    279                "suites": [],
    280            }
    281            for op, duration in optimes:
    282                perfherder["suites"].append(
    283                    {
    284                        "name": op,
    285                        "value": duration,
    286                        "lowerIsBetter": True,
    287                        "shouldAlert": False,
    288                        "serverUrl": server_url.decode("utf-8"),
    289                        "hgVersion": util.version().decode("utf-8"),
    290                        "extraOptions": [os.environ["TASKCLUSTER_INSTANCE_TYPE"]],
    291                        "subtests": [],
    292                    }
    293                )
    294            ui.write(
    295                b"PERFHERDER_DATA: %s\n"
    296                % pycompat.bytestr(json.dumps(perfherder, sort_keys=True))
    297            )
    298 
    299 
    300 def _docheckout(
    301    ui,
    302    url,
    303    dest,
    304    upstream,
    305    revision,
    306    branch,
    307    purge,
    308    sharebase,
    309    optimes,
    310    behaviors,
    311    networkattemptlimit,
    312    networkattempts=None,
    313    sparse_profile=None,
    314    noupdate=False,
    315 ):
    316    if not networkattempts:
    317        networkattempts = [1]
    318 
    319    def callself():
    320        return _docheckout(
    321            ui,
    322            url,
    323            dest,
    324            upstream,
    325            revision,
    326            branch,
    327            purge,
    328            sharebase,
    329            optimes,
    330            behaviors,
    331            networkattemptlimit,
    332            networkattempts=networkattempts,
    333            sparse_profile=sparse_profile,
    334            noupdate=noupdate,
    335        )
    336 
    337    @contextlib.contextmanager
    338    def timeit(op, behavior):
    339        behaviors.add(behavior)
    340        errored = False
    341        try:
    342            start = time.time()
    343            yield
    344        except Exception:
    345            errored = True
    346            raise
    347        finally:
    348            elapsed = time.time() - start
    349 
    350            if errored:
    351                op += "_errored"
    352 
    353            optimes.append((op, elapsed))
    354 
    355    ui.write(b"ensuring %s@%s is available at %s\n" % (url, revision or branch, dest))
    356 
    357    # We assume that we're the only process on the machine touching the
    358    # repository paths that we were told to use. This means our recovery
    359    # scenario when things aren't "right" is to just nuke things and start
    360    # from scratch. This is easier to implement than verifying the state
    361    # of the data and attempting recovery. And in some scenarios (such as
    362    # potential repo corruption), it is probably faster, since verifying
    363    # repos can take a while.
    364 
    365    destvfs = vfs.vfs(dest, audit=False, realpath=True)
    366 
    367    def deletesharedstore(path=None):
    368        storepath = path or destvfs.read(b".hg/sharedpath").strip()
    369        if storepath.endswith(b".hg"):
    370            storepath = os.path.dirname(storepath)
    371 
    372        storevfs = vfs.vfs(storepath, audit=False)
    373        storevfs.rmtree(forcibly=True)
    374 
    375    if destvfs.exists() and not destvfs.exists(b".hg"):
    376        raise error.Abort(b"destination exists but no .hg directory")
    377 
    378    # Refuse to enable sparse checkouts on existing checkouts. The reasoning
    379    # here is that another consumer of this repo may not be sparse aware. If we
    380    # enabled sparse, we would lock them out.
    381    if destvfs.exists() and sparse_profile and not destvfs.exists(b".hg/sparse"):
    382        raise error.Abort(
    383            b"cannot enable sparse profile on existing " b"non-sparse checkout",
    384            hint=b"use a separate working directory to use sparse",
    385        )
    386 
    387    # And the other direction for symmetry.
    388    if not sparse_profile and destvfs.exists(b".hg/sparse"):
    389        raise error.Abort(
    390            b"cannot use non-sparse checkout on existing sparse " b"checkout",
    391            hint=b"use a separate working directory to use sparse",
    392        )
    393 
    394    # Require checkouts to be tied to shared storage because efficiency.
    395    if destvfs.exists(b".hg") and not destvfs.exists(b".hg/sharedpath"):
    396        ui.warn(b"(destination is not shared; deleting)\n")
    397        with timeit("remove_unshared_dest", "remove-wdir"):
    398            destvfs.rmtree(forcibly=True)
    399 
    400    # Verify the shared path exists and is using modern pooled storage.
    401    if destvfs.exists(b".hg/sharedpath"):
    402        storepath = destvfs.read(b".hg/sharedpath").strip()
    403 
    404        ui.write(b"(existing repository shared store: %s)\n" % storepath)
    405 
    406        if not os.path.exists(storepath):
    407            ui.warn(b"(shared store does not exist; deleting destination)\n")
    408            with timeit("removed_missing_shared_store", "remove-wdir"):
    409                destvfs.rmtree(forcibly=True)
    410        elif not re.search(rb"[a-f0-9]{40}/\.hg$", storepath.replace(b"\\", b"/")):
    411            ui.warn(
    412                b"(shared store does not belong to pooled storage; "
    413                b"deleting destination to improve efficiency)\n"
    414            )
    415            with timeit("remove_unpooled_store", "remove-wdir"):
    416                destvfs.rmtree(forcibly=True)
    417 
    418    if destvfs.isfileorlink(b".hg/wlock"):
    419        ui.warn(
    420            b"(dest has an active working directory lock; assuming it is "
    421            b"left over from a previous process and that the destination "
    422            b"is corrupt; deleting it just to be sure)\n"
    423        )
    424        with timeit("remove_locked_wdir", "remove-wdir"):
    425            destvfs.rmtree(forcibly=True)
    426 
    427    def handlerepoerror(e):
    428        if pycompat.bytestr(e) == _(b"abandoned transaction found"):
    429            ui.warn(b"(abandoned transaction found; trying to recover)\n")
    430            repo = hg.repository(ui, dest)
    431            if not repo.recover():
    432                ui.warn(b"(could not recover repo state; " b"deleting shared store)\n")
    433                with timeit("remove_unrecovered_shared_store", "remove-store"):
    434                    deletesharedstore()
    435 
    436            ui.warn(b"(attempting checkout from beginning)\n")
    437            return callself()
    438 
    439        raise
    440 
    441    # At this point we either have an existing working directory using
    442    # shared, pooled storage or we have nothing.
    443 
    444    def handlenetworkfailure():
    445        if networkattempts[0] >= networkattemptlimit:
    446            raise error.Abort(
    447                b"reached maximum number of network attempts; " b"giving up\n"
    448            )
    449 
    450        ui.warn(
    451            b"(retrying after network failure on attempt %d of %d)\n"
    452            % (networkattempts[0], networkattemptlimit)
    453        )
    454 
    455        # Do a backoff on retries to mitigate the thundering herd
    456        # problem. This is an exponential backoff with a multipler
    457        # plus random jitter thrown in for good measure.
    458        # With the default settings, backoffs will be:
    459        # 1) 2.5 - 6.5
    460        # 2) 5.5 - 9.5
    461        # 3) 11.5 - 15.5
    462        backoff = (2 ** networkattempts[0] - 1) * 1.5
    463        jittermin = ui.configint(b"robustcheckout", b"retryjittermin", 1000)
    464        jittermax = ui.configint(b"robustcheckout", b"retryjittermax", 5000)
    465        backoff += float(random.randint(jittermin, jittermax)) / 1000.0
    466        ui.warn(b"(waiting %.2fs before retry)\n" % backoff)
    467        time.sleep(backoff)
    468 
    469        networkattempts[0] += 1
    470 
    471    def handlepullerror(e):
    472        """Handle an exception raised during a pull.
    473 
    474        Returns True if caller should call ``callself()`` to retry.
    475        """
    476        if isinstance(e, error.Abort):
    477            if e.args[0] == _(b"repository is unrelated"):
    478                ui.warn(b"(repository is unrelated; deleting)\n")
    479                destvfs.rmtree(forcibly=True)
    480                return True
    481            elif e.args[0].startswith(_(b"stream ended unexpectedly")):
    482                ui.warn(b"%s\n" % e.args[0])
    483                # Will raise if failure limit reached.
    484                handlenetworkfailure()
    485                return True
    486        # TODO test this branch
    487        elif isinstance(e, error.ResponseError):
    488            if e.args[0].startswith(_(b"unexpected response from remote server:")):
    489                ui.warn(b"(unexpected response from remote server; retrying)\n")
    490                destvfs.rmtree(forcibly=True)
    491                # Will raise if failure limit reached.
    492                handlenetworkfailure()
    493                return True
    494        elif isinstance(e, ssl.SSLError):
    495            # Assume all SSL errors are due to the network, as Mercurial
    496            # should convert non-transport errors like cert validation failures
    497            # to error.Abort.
    498            ui.warn(b"ssl error: %s\n" % pycompat.bytestr(str(e)))
    499            handlenetworkfailure()
    500            return True
    501        elif isinstance(e, urllibcompat.urlerr.httperror) and e.code >= 500:
    502            ui.warn(b"http error: %s\n" % pycompat.bytestr(str(e.reason)))
    503            handlenetworkfailure()
    504            return True
    505        elif isinstance(e, urllibcompat.urlerr.urlerror):
    506            if isinstance(e.reason, socket.error):
    507                ui.warn(b"socket error: %s\n" % pycompat.bytestr(str(e.reason)))
    508                handlenetworkfailure()
    509                return True
    510            else:
    511                ui.warn(
    512                    b"unhandled URLError; reason type: %s; value: %s\n"
    513                    % (
    514                        pycompat.bytestr(e.reason.__class__.__name__),
    515                        pycompat.bytestr(str(e.reason)),
    516                    )
    517                )
    518        elif isinstance(e, socket.timeout):
    519            ui.warn(b"socket timeout\n")
    520            handlenetworkfailure()
    521            return True
    522        else:
    523            ui.warn(
    524                b"unhandled exception during network operation; type: %s; "
    525                b"value: %s\n"
    526                % (pycompat.bytestr(e.__class__.__name__), pycompat.bytestr(str(e)))
    527            )
    528 
    529        return False
    530 
    531    # Perform sanity checking of store. We may or may not know the path to the
    532    # local store. It depends if we have an existing destvfs pointing to a
    533    # share. To ensure we always find a local store, perform the same logic
    534    # that Mercurial's pooled storage does to resolve the local store path.
    535    cloneurl = upstream or url
    536 
    537    try:
    538        clonepeer = hg.peer(ui, {}, cloneurl)
    539        rootnode = peerlookup(clonepeer, b"0")
    540    except error.RepoLookupError:
    541        raise error.Abort(b"unable to resolve root revision from clone " b"source")
    542    except (
    543        error.Abort,
    544        ssl.SSLError,
    545        urllibcompat.urlerr.urlerror,
    546        socket.timeout,
    547    ) as e:
    548        if handlepullerror(e):
    549            return callself()
    550        raise
    551 
    552    if rootnode == nullid:
    553        raise error.Abort(b"source repo appears to be empty")
    554 
    555    storepath = os.path.join(sharebase, hex(rootnode))
    556    storevfs = vfs.vfs(storepath, audit=False)
    557 
    558    if storevfs.isfileorlink(b".hg/store/lock"):
    559        ui.warn(
    560            b"(shared store has an active lock; assuming it is left "
    561            b"over from a previous process and that the store is "
    562            b"corrupt; deleting store and destination just to be "
    563            b"sure)\n"
    564        )
    565        if destvfs.exists():
    566            with timeit("remove_dest_active_lock", "remove-wdir"):
    567                destvfs.rmtree(forcibly=True)
    568 
    569        with timeit("remove_shared_store_active_lock", "remove-store"):
    570            storevfs.rmtree(forcibly=True)
    571 
    572    if storevfs.exists() and not storevfs.exists(b".hg/requires"):
    573        ui.warn(
    574            b"(shared store missing requires file; this is a really "
    575            b"odd failure; deleting store and destination)\n"
    576        )
    577        if destvfs.exists():
    578            with timeit("remove_dest_no_requires", "remove-wdir"):
    579                destvfs.rmtree(forcibly=True)
    580 
    581        with timeit("remove_shared_store_no_requires", "remove-store"):
    582            storevfs.rmtree(forcibly=True)
    583 
    584    if storevfs.exists(b".hg/requires"):
    585        requires = set(storevfs.read(b".hg/requires").splitlines())
    586        # "share-safe" (enabled by default as of hg 6.1) moved most
    587        # requirements to a new file, so we need to look there as well to avoid
    588        # deleting and re-cloning each time
    589        if b"share-safe" in requires:
    590            requires |= set(storevfs.read(b".hg/store/requires").splitlines())
    591        # FUTURE when we require generaldelta, this is where we can check
    592        # for that.
    593        required = {b"dotencode", b"fncache"}
    594 
    595        missing = required - requires
    596        if missing:
    597            ui.warn(
    598                b"(shared store missing requirements: %s; deleting "
    599                b"store and destination to ensure optimal behavior)\n"
    600                % b", ".join(sorted(missing))
    601            )
    602            if destvfs.exists():
    603                with timeit("remove_dest_missing_requires", "remove-wdir"):
    604                    destvfs.rmtree(forcibly=True)
    605 
    606            with timeit("remove_shared_store_missing_requires", "remove-store"):
    607                storevfs.rmtree(forcibly=True)
    608 
    609    created = False
    610 
    611    if not destvfs.exists():
    612        # Ensure parent directories of destination exist.
    613        # Mercurial 3.8 removed ensuredirs and made makedirs race safe.
    614        if util.safehasattr(util, "ensuredirs"):
    615            makedirs = util.ensuredirs
    616        else:
    617            makedirs = util.makedirs
    618 
    619        makedirs(os.path.dirname(destvfs.base), notindexed=True)
    620        makedirs(sharebase, notindexed=True)
    621 
    622        if upstream:
    623            ui.write(b"(cloning from upstream repo %s)\n" % upstream)
    624 
    625        if not storevfs.exists():
    626            behaviors.add(b"create-store")
    627 
    628        try:
    629            with timeit("clone", "clone"):
    630                shareopts = {b"pool": sharebase, b"mode": b"identity"}
    631                res = hg.clone(
    632                    ui,
    633                    {},
    634                    clonepeer,
    635                    dest=dest,
    636                    update=False,
    637                    shareopts=shareopts,
    638                    stream=True,
    639                )
    640        except (
    641            error.Abort,
    642            ssl.SSLError,
    643            urllibcompat.urlerr.urlerror,
    644            socket.timeout,
    645        ) as e:
    646            if handlepullerror(e):
    647                return callself()
    648            raise
    649        except error.RepoError as e:
    650            return handlerepoerror(e)
    651        except error.RevlogError as e:
    652            ui.warn(b"(repo corruption: %s; deleting shared store)\n" % e)
    653            with timeit("remove_shared_store_revlogerror", "remote-store"):
    654                deletesharedstore()
    655            return callself()
    656 
    657        # TODO retry here.
    658        if res is None:
    659            raise error.Abort(b"clone failed")
    660 
    661        # Verify it is using shared pool storage.
    662        if not destvfs.exists(b".hg/sharedpath"):
    663            raise error.Abort(b"clone did not create a shared repo")
    664 
    665        created = True
    666 
    667    # The destination .hg directory should exist. Now make sure we have the
    668    # wanted revision.
    669 
    670    repo = hg.repository(ui, dest)
    671 
    672    # We only pull if we are using symbolic names or the requested revision
    673    # doesn't exist.
    674    havewantedrev = False
    675 
    676    if revision:
    677        try:
    678            ctx = scmutil.revsingle(repo, revision)
    679        except error.RepoLookupError:
    680            ctx = None
    681 
    682        if ctx:
    683            if not ctx.hex().startswith(revision):
    684                raise error.Abort(
    685                    b"--revision argument is ambiguous",
    686                    hint=b"must be the first 12+ characters of a " b"SHA-1 fragment",
    687                )
    688 
    689            checkoutrevision = ctx.hex()
    690            havewantedrev = True
    691 
    692    if not havewantedrev:
    693        ui.write(b"(pulling to obtain %s)\n" % (revision or branch,))
    694 
    695        remote = None
    696        try:
    697            remote = hg.peer(repo, {}, url)
    698            pullrevs = [peerlookup(remote, revision or branch)]
    699            checkoutrevision = hex(pullrevs[0])
    700            if branch:
    701                ui.warn(
    702                    b"(remote resolved %s to %s; "
    703                    b"result is not deterministic)\n" % (branch, checkoutrevision)
    704                )
    705 
    706            if checkoutrevision in repo:
    707                ui.warn(b"(revision already present locally; not pulling)\n")
    708            else:
    709                with timeit("pull", "pull"):
    710                    pullop = exchange.pull(repo, remote, heads=pullrevs)
    711                    if not pullop.rheads:
    712                        raise error.Abort(b"unable to pull requested revision")
    713        except (
    714            error.Abort,
    715            ssl.SSLError,
    716            urllibcompat.urlerr.urlerror,
    717            socket.timeout,
    718        ) as e:
    719            if handlepullerror(e):
    720                return callself()
    721            raise
    722        except error.RepoError as e:
    723            return handlerepoerror(e)
    724        except error.RevlogError as e:
    725            ui.warn(b"(repo corruption: %s; deleting shared store)\n" % e)
    726            deletesharedstore()
    727            return callself()
    728        finally:
    729            if remote:
    730                remote.close()
    731 
    732    # Now we should have the wanted revision in the store. Perform
    733    # working directory manipulation.
    734 
    735    # Avoid any working directory manipulations if `-U`/`--noupdate` was passed
    736    if noupdate:
    737        ui.write(b"(skipping update since `-U` was passed)\n")
    738        return None
    739 
    740    # Purge if requested. We purge before update because this way we're
    741    # guaranteed to not have conflicts on `hg update`.
    742    if purge and not created:
    743        ui.write(b"(purging working directory)\n")
    744        purge = getattr(commands, "purge", None)
    745        if not purge:
    746            purge = extensions.find(b"purge").purge
    747 
    748        # Mercurial 4.3 doesn't purge files outside the sparse checkout.
    749        # See https://bz.mercurial-scm.org/show_bug.cgi?id=5626. Force
    750        # purging by monkeypatching the sparse matcher.
    751        try:
    752            old_sparse_fn = getattr(repo.dirstate, "_sparsematchfn", None)
    753            if old_sparse_fn is not None:
    754                repo.dirstate._sparsematchfn = lambda: matchmod.always()
    755 
    756            with timeit("purge", "purge"):
    757                if purge(
    758                    ui,
    759                    repo,
    760                    all=True,
    761                    abort_on_err=True,
    762                    # The function expects all arguments to be
    763                    # defined.
    764                    **{"print": None, "print0": None, "dirs": None, "files": None}
    765                ):
    766                    raise error.Abort(b"error purging")
    767        finally:
    768            if old_sparse_fn is not None:
    769                repo.dirstate._sparsematchfn = old_sparse_fn
    770 
    771    # Update the working directory.
    772 
    773    if repo[b"."].node() == nullid:
    774        behaviors.add("empty-wdir")
    775    else:
    776        behaviors.add("populated-wdir")
    777 
    778    if sparse_profile:
    779        sparsemod = getsparse()
    780 
    781        # By default, Mercurial will ignore unknown sparse profiles. This could
    782        # lead to a full checkout. Be more strict.
    783        try:
    784            repo.filectx(sparse_profile, changeid=checkoutrevision).data()
    785        except error.ManifestLookupError:
    786            raise error.Abort(
    787                b"sparse profile %s does not exist at revision "
    788                b"%s" % (sparse_profile, checkoutrevision)
    789            )
    790 
    791        old_config = sparsemod.parseconfig(
    792            repo.ui, repo.vfs.tryread(b"sparse"), b"sparse"
    793        )
    794 
    795        old_includes, old_excludes, old_profiles = old_config
    796 
    797        if old_profiles == {sparse_profile} and not old_includes and not old_excludes:
    798            ui.write(
    799                b"(sparse profile %s already set; no need to update "
    800                b"sparse config)\n" % sparse_profile
    801            )
    802        else:
    803            if old_includes or old_excludes or old_profiles:
    804                ui.write(
    805                    b"(replacing existing sparse config with profile "
    806                    b"%s)\n" % sparse_profile
    807                )
    808            else:
    809                ui.write(b"(setting sparse config to profile %s)\n" % sparse_profile)
    810 
    811            # If doing an incremental update, this will perform two updates:
    812            # one to change the sparse profile and another to update to the new
    813            # revision. This is not desired. But there's not a good API in
    814            # Mercurial to do this as one operation.
    815            # TRACKING hg64 - Mercurial 6.4 and later require call to
    816            # dirstate.changing_parents(repo)
    817            def parentchange(repo):
    818                if util.safehasattr(repo.dirstate, "changing_parents"):
    819                    return repo.dirstate.changing_parents(repo)
    820                return repo.dirstate.parentchange()
    821 
    822            with repo.wlock(), parentchange(repo), timeit(
    823                "sparse_update_config", "sparse-update-config"
    824            ):
    825                # pylint --py3k: W1636
    826                fcounts = list(
    827                    map(
    828                        len,
    829                        sparsemod._updateconfigandrefreshwdir(
    830                            repo, [], [], [sparse_profile], force=True
    831                        ),
    832                    )
    833                )
    834 
    835                repo.ui.status(
    836                    b"%d files added, %d files dropped, "
    837                    b"%d files conflicting\n" % tuple(fcounts)
    838                )
    839 
    840            ui.write(b"(sparse refresh complete)\n")
    841 
    842    op = "update_sparse" if sparse_profile else "update"
    843    behavior = "update-sparse" if sparse_profile else "update"
    844 
    845    with timeit(op, behavior):
    846        if commands.update(ui, repo, rev=checkoutrevision, clean=True):
    847            raise error.Abort(b"error updating")
    848 
    849    ui.write(b"updated to %s\n" % checkoutrevision)
    850 
    851    return None
    852 
    853 
    854 def extsetup(ui):
    855    # Ensure required extensions are loaded.
    856    for ext in (b"purge", b"share"):
    857        try:
    858            extensions.find(ext)
    859        except KeyError:
    860            extensions.load(ui, ext, None)