Patchwork D9208: clonebundles: move a bundle of clone bundle related code to a new module

login
register
mail settings
Submitter phabricator
Date Oct. 14, 2020, 11:35 p.m.
Message ID <differential-rev-PHID-DREV-2iptkubaox4da6ombegj-req@mercurial-scm.org>
Download mbox | patch
Permalink /patch/47471/
State Superseded
Headers show

Comments

phabricator - Oct. 14, 2020, 11:35 p.m.
marmoute created this revision.
Herald added a reviewer: hg-reviewers.
Herald added a subscriber: mercurial-patches.

REVISION SUMMARY
  In the process on general clone bundle automatically, we need to make some
  function available more widely. This is a good opportunity to extract a
  significant amount of code from `mercurial.exchange` into a new
  `mercurial.bundlecaches`. This make `mercurial.exchange` move under the 3K line
  range (hooray…).
  
  The module is called `bundlecaches` because I expect it to be eventually useful
  for more than just clone bundle (like pull bunbles).

REPOSITORY
  rHG Mercurial

BRANCH
  default

REVISION DETAIL
  https://phab.mercurial-scm.org/D9208

AFFECTED FILES
  hgext/lfs/__init__.py
  mercurial/bundlecaches.py
  mercurial/commands.py
  mercurial/exchange.py
  mercurial/wireprotov1server.py
  tests/flagprocessorext.py
  tests/test-pull-bundle.t

CHANGE DETAILS




To: marmoute, #hg-reviewers
Cc: mercurial-patches, mercurial-devel

Patch

diff --git a/tests/test-pull-bundle.t b/tests/test-pull-bundle.t
--- a/tests/test-pull-bundle.t
+++ b/tests/test-pull-bundle.t
@@ -52,7 +52,7 @@ 
   > 1.hg BUNDLESPEC=bzip2-v2 heads=ed1b79f46b9a29f5a6efa59cf12fcfca43bead5a bases=bbd179dfa0a71671c253b3ae0aa1513b60d199fa
   > 0.hg BUNDLESPEC=gzip-v2 heads=bbd179dfa0a71671c253b3ae0aa1513b60d199fa
   > EOF
-  $ hg --config blackbox.track=debug --debug serve -p $HGPORT2 -d --pid-file=../repo.pid
+  $ hg --config blackbox.track=debug --debug serve -p $HGPORT2 -d --pid-file=../repo.pid -E ../error.txt
   listening at http://*:$HGPORT2/ (bound to $LOCALIP:$HGPORT2) (glob) (?)
   $ cat ../repo.pid >> $DAEMON_PIDS
   $ cd ..
@@ -64,6 +64,7 @@ 
   new changesets bbd179dfa0a7 (1 drafts)
   updating to branch default
   1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cat error.txt
   $ cd repo.pullbundle
   $ hg pull -r 1
   pulling from http://localhost:$HGPORT2/
diff --git a/tests/flagprocessorext.py b/tests/flagprocessorext.py
--- a/tests/flagprocessorext.py
+++ b/tests/flagprocessorext.py
@@ -6,8 +6,8 @@ 
 import zlib
 
 from mercurial import (
+    bundlecaches,
     changegroup,
-    exchange,
     extensions,
     revlog,
     util,
@@ -134,8 +134,8 @@ 
     revlog.REVIDX_FLAGS_ORDER.extend(flags)
 
     # Teach exchange to use changegroup 3
-    for k in exchange._bundlespeccontentopts.keys():
-        exchange._bundlespeccontentopts[k][b"cg.version"] = b"03"
+    for k in bundlecaches._bundlespeccontentopts.keys():
+        bundlecaches._bundlespeccontentopts[k][b"cg.version"] = b"03"
 
     # Register flag processors for each extension
     flagutil.addflagprocessor(
diff --git a/mercurial/wireprotov1server.py b/mercurial/wireprotov1server.py
--- a/mercurial/wireprotov1server.py
+++ b/mercurial/wireprotov1server.py
@@ -19,6 +19,7 @@ 
 
 from . import (
     bundle2,
+    bundlecaches,
     changegroup as changegroupmod,
     discovery,
     encoding,
@@ -387,8 +388,8 @@ 
     manifest = repo.vfs.tryread(b'pullbundles.manifest')
     if not manifest:
         return None
-    res = exchange.parseclonebundlesmanifest(repo, manifest)
-    res = exchange.filterclonebundleentries(repo, res)
+    res = bundlecaches.parseclonebundlesmanifest(repo, manifest)
+    res = bundlecaches.filterclonebundleentries(repo, res)
     if not res:
         return None
     cl = repo.unfiltered().changelog
diff --git a/mercurial/exchange.py b/mercurial/exchange.py
--- a/mercurial/exchange.py
+++ b/mercurial/exchange.py
@@ -16,10 +16,10 @@ 
     nullid,
     nullrev,
 )
-from .thirdparty import attr
 from . import (
     bookmarks as bookmod,
     bundle2,
+    bundlecaches,
     changegroup,
     discovery,
     error,
@@ -34,7 +34,6 @@ 
     pycompat,
     requirements,
     scmutil,
-    sslutil,
     streamclone,
     url as urlmod,
     util,
@@ -50,202 +49,6 @@ 
 
 _NARROWACL_SECTION = b'narrowacl'
 
-# Maps bundle version human names to changegroup versions.
-_bundlespeccgversions = {
-    b'v1': b'01',
-    b'v2': b'02',
-    b'packed1': b's1',
-    b'bundle2': b'02',  # legacy
-}
-
-# Maps bundle version with content opts to choose which part to bundle
-_bundlespeccontentopts = {
-    b'v1': {
-        b'changegroup': True,
-        b'cg.version': b'01',
-        b'obsolescence': False,
-        b'phases': False,
-        b'tagsfnodescache': False,
-        b'revbranchcache': False,
-    },
-    b'v2': {
-        b'changegroup': True,
-        b'cg.version': b'02',
-        b'obsolescence': False,
-        b'phases': False,
-        b'tagsfnodescache': True,
-        b'revbranchcache': True,
-    },
-    b'packed1': {b'cg.version': b's1'},
-}
-_bundlespeccontentopts[b'bundle2'] = _bundlespeccontentopts[b'v2']
-
-_bundlespecvariants = {
-    b"streamv2": {
-        b"changegroup": False,
-        b"streamv2": True,
-        b"tagsfnodescache": False,
-        b"revbranchcache": False,
-    }
-}
-
-# Compression engines allowed in version 1. THIS SHOULD NEVER CHANGE.
-_bundlespecv1compengines = {b'gzip', b'bzip2', b'none'}
-
-
-@attr.s
-class bundlespec(object):
-    compression = attr.ib()
-    wirecompression = attr.ib()
-    version = attr.ib()
-    wireversion = attr.ib()
-    params = attr.ib()
-    contentopts = attr.ib()
-
-
-def parsebundlespec(repo, spec, strict=True):
-    """Parse a bundle string specification into parts.
-
-    Bundle specifications denote a well-defined bundle/exchange format.
-    The content of a given specification should not change over time in
-    order to ensure that bundles produced by a newer version of Mercurial are
-    readable from an older version.
-
-    The string currently has the form:
-
-       <compression>-<type>[;<parameter0>[;<parameter1>]]
-
-    Where <compression> is one of the supported compression formats
-    and <type> is (currently) a version string. A ";" can follow the type and
-    all text afterwards is interpreted as URI encoded, ";" delimited key=value
-    pairs.
-
-    If ``strict`` is True (the default) <compression> is required. Otherwise,
-    it is optional.
-
-    Returns a bundlespec object of (compression, version, parameters).
-    Compression will be ``None`` if not in strict mode and a compression isn't
-    defined.
-
-    An ``InvalidBundleSpecification`` is raised when the specification is
-    not syntactically well formed.
-
-    An ``UnsupportedBundleSpecification`` is raised when the compression or
-    bundle type/version is not recognized.
-
-    Note: this function will likely eventually return a more complex data
-    structure, including bundle2 part information.
-    """
-
-    def parseparams(s):
-        if b';' not in s:
-            return s, {}
-
-        params = {}
-        version, paramstr = s.split(b';', 1)
-
-        for p in paramstr.split(b';'):
-            if b'=' not in p:
-                raise error.InvalidBundleSpecification(
-                    _(
-                        b'invalid bundle specification: '
-                        b'missing "=" in parameter: %s'
-                    )
-                    % p
-                )
-
-            key, value = p.split(b'=', 1)
-            key = urlreq.unquote(key)
-            value = urlreq.unquote(value)
-            params[key] = value
-
-        return version, params
-
-    if strict and b'-' not in spec:
-        raise error.InvalidBundleSpecification(
-            _(
-                b'invalid bundle specification; '
-                b'must be prefixed with compression: %s'
-            )
-            % spec
-        )
-
-    if b'-' in spec:
-        compression, version = spec.split(b'-', 1)
-
-        if compression not in util.compengines.supportedbundlenames:
-            raise error.UnsupportedBundleSpecification(
-                _(b'%s compression is not supported') % compression
-            )
-
-        version, params = parseparams(version)
-
-        if version not in _bundlespeccgversions:
-            raise error.UnsupportedBundleSpecification(
-                _(b'%s is not a recognized bundle version') % version
-            )
-    else:
-        # Value could be just the compression or just the version, in which
-        # case some defaults are assumed (but only when not in strict mode).
-        assert not strict
-
-        spec, params = parseparams(spec)
-
-        if spec in util.compengines.supportedbundlenames:
-            compression = spec
-            version = b'v1'
-            # Generaldelta repos require v2.
-            if b'generaldelta' in repo.requirements:
-                version = b'v2'
-            # Modern compression engines require v2.
-            if compression not in _bundlespecv1compengines:
-                version = b'v2'
-        elif spec in _bundlespeccgversions:
-            if spec == b'packed1':
-                compression = b'none'
-            else:
-                compression = b'bzip2'
-            version = spec
-        else:
-            raise error.UnsupportedBundleSpecification(
-                _(b'%s is not a recognized bundle specification') % spec
-            )
-
-    # Bundle version 1 only supports a known set of compression engines.
-    if version == b'v1' and compression not in _bundlespecv1compengines:
-        raise error.UnsupportedBundleSpecification(
-            _(b'compression engine %s is not supported on v1 bundles')
-            % compression
-        )
-
-    # The specification for packed1 can optionally declare the data formats
-    # required to apply it. If we see this metadata, compare against what the
-    # repo supports and error if the bundle isn't compatible.
-    if version == b'packed1' and b'requirements' in params:
-        requirements = set(params[b'requirements'].split(b','))
-        missingreqs = requirements - repo.supportedformats
-        if missingreqs:
-            raise error.UnsupportedBundleSpecification(
-                _(b'missing support for repository features: %s')
-                % b', '.join(sorted(missingreqs))
-            )
-
-    # Compute contentopts based on the version
-    contentopts = _bundlespeccontentopts.get(version, {}).copy()
-
-    # Process the variants
-    if b"stream" in params and params[b"stream"] == b"v2":
-        variant = _bundlespecvariants[b"streamv2"]
-        contentopts.update(variant)
-
-    engine = util.compengines.forbundlename(compression)
-    compression, wirecompression = engine.bundletype()
-    wireversion = _bundlespeccgversions[version]
-
-    return bundlespec(
-        compression, wirecompression, version, wireversion, params, contentopts
-    )
-
 
 def readbundle(ui, fh, fname, vfs=None):
     header = changegroup.readexactly(fh, 4)
@@ -2867,7 +2670,7 @@ 
     # attempt.
     pullop.clonebundleattempted = True
 
-    entries = parseclonebundlesmanifest(repo, res)
+    entries = bundlecaches.parseclonebundlesmanifest(repo, res)
     if not entries:
         repo.ui.note(
             _(
@@ -2877,7 +2680,7 @@ 
         )
         return
 
-    entries = filterclonebundleentries(
+    entries = bundlecaches.filterclonebundleentries(
         repo, entries, streamclonerequested=pullop.streamclonerequested
     )
 
@@ -2898,7 +2701,7 @@ 
         )
         return
 
-    entries = sortclonebundleentries(repo.ui, entries)
+    entries = bundlecaches.sortclonebundleentries(repo.ui, entries)
 
     url = entries[0][b'URL']
     repo.ui.status(_(b'applying clone bundle from %s\n') % url)
@@ -2923,214 +2726,6 @@ 
         )
 
 
-def parseclonebundlesmanifest(repo, s):
-    """Parses the raw text of a clone bundles manifest.
-
-    Returns a list of dicts. The dicts have a ``URL`` key corresponding
-    to the URL and other keys are the attributes for the entry.
-    """
-    m = []
-    for line in s.splitlines():
-        fields = line.split()
-        if not fields:
-            continue
-        attrs = {b'URL': fields[0]}
-        for rawattr in fields[1:]:
-            key, value = rawattr.split(b'=', 1)
-            key = urlreq.unquote(key)
-            value = urlreq.unquote(value)
-            attrs[key] = value
-
-            # Parse BUNDLESPEC into components. This makes client-side
-            # preferences easier to specify since you can prefer a single
-            # component of the BUNDLESPEC.
-            if key == b'BUNDLESPEC':
-                try:
-                    bundlespec = parsebundlespec(repo, value)
-                    attrs[b'COMPRESSION'] = bundlespec.compression
-                    attrs[b'VERSION'] = bundlespec.version
-                except error.InvalidBundleSpecification:
-                    pass
-                except error.UnsupportedBundleSpecification:
-                    pass
-
-        m.append(attrs)
-
-    return m
-
-
-def isstreamclonespec(bundlespec):
-    # Stream clone v1
-    if bundlespec.wirecompression == b'UN' and bundlespec.wireversion == b's1':
-        return True
-
-    # Stream clone v2
-    if (
-        bundlespec.wirecompression == b'UN'
-        and bundlespec.wireversion == b'02'
-        and bundlespec.contentopts.get(b'streamv2')
-    ):
-        return True
-
-    return False
-
-
-def filterclonebundleentries(repo, entries, streamclonerequested=False):
-    """Remove incompatible clone bundle manifest entries.
-
-    Accepts a list of entries parsed with ``parseclonebundlesmanifest``
-    and returns a new list consisting of only the entries that this client
-    should be able to apply.
-
-    There is no guarantee we'll be able to apply all returned entries because
-    the metadata we use to filter on may be missing or wrong.
-    """
-    newentries = []
-    for entry in entries:
-        spec = entry.get(b'BUNDLESPEC')
-        if spec:
-            try:
-                bundlespec = parsebundlespec(repo, spec, strict=True)
-
-                # If a stream clone was requested, filter out non-streamclone
-                # entries.
-                if streamclonerequested and not isstreamclonespec(bundlespec):
-                    repo.ui.debug(
-                        b'filtering %s because not a stream clone\n'
-                        % entry[b'URL']
-                    )
-                    continue
-
-            except error.InvalidBundleSpecification as e:
-                repo.ui.debug(stringutil.forcebytestr(e) + b'\n')
-                continue
-            except error.UnsupportedBundleSpecification as e:
-                repo.ui.debug(
-                    b'filtering %s because unsupported bundle '
-                    b'spec: %s\n' % (entry[b'URL'], stringutil.forcebytestr(e))
-                )
-                continue
-        # If we don't have a spec and requested a stream clone, we don't know
-        # what the entry is so don't attempt to apply it.
-        elif streamclonerequested:
-            repo.ui.debug(
-                b'filtering %s because cannot determine if a stream '
-                b'clone bundle\n' % entry[b'URL']
-            )
-            continue
-
-        if b'REQUIRESNI' in entry and not sslutil.hassni:
-            repo.ui.debug(
-                b'filtering %s because SNI not supported\n' % entry[b'URL']
-            )
-            continue
-
-        if b'REQUIREDRAM' in entry:
-            try:
-                requiredram = util.sizetoint(entry[b'REQUIREDRAM'])
-            except error.ParseError:
-                repo.ui.debug(
-                    b'filtering %s due to a bad REQUIREDRAM attribute\n'
-                    % entry[b'URL']
-                )
-                continue
-            actualram = repo.ui.estimatememory()
-            if actualram is not None and actualram * 0.66 < requiredram:
-                repo.ui.debug(
-                    b'filtering %s as it needs more than 2/3 of system memory\n'
-                    % entry[b'URL']
-                )
-                continue
-
-        newentries.append(entry)
-
-    return newentries
-
-
-class clonebundleentry(object):
-    """Represents an item in a clone bundles manifest.
-
-    This rich class is needed to support sorting since sorted() in Python 3
-    doesn't support ``cmp`` and our comparison is complex enough that ``key=``
-    won't work.
-    """
-
-    def __init__(self, value, prefers):
-        self.value = value
-        self.prefers = prefers
-
-    def _cmp(self, other):
-        for prefkey, prefvalue in self.prefers:
-            avalue = self.value.get(prefkey)
-            bvalue = other.value.get(prefkey)
-
-            # Special case for b missing attribute and a matches exactly.
-            if avalue is not None and bvalue is None and avalue == prefvalue:
-                return -1
-
-            # Special case for a missing attribute and b matches exactly.
-            if bvalue is not None and avalue is None and bvalue == prefvalue:
-                return 1
-
-            # We can't compare unless attribute present on both.
-            if avalue is None or bvalue is None:
-                continue
-
-            # Same values should fall back to next attribute.
-            if avalue == bvalue:
-                continue
-
-            # Exact matches come first.
-            if avalue == prefvalue:
-                return -1
-            if bvalue == prefvalue:
-                return 1
-
-            # Fall back to next attribute.
-            continue
-
-        # If we got here we couldn't sort by attributes and prefers. Fall
-        # back to index order.
-        return 0
-
-    def __lt__(self, other):
-        return self._cmp(other) < 0
-
-    def __gt__(self, other):
-        return self._cmp(other) > 0
-
-    def __eq__(self, other):
-        return self._cmp(other) == 0
-
-    def __le__(self, other):
-        return self._cmp(other) <= 0
-
-    def __ge__(self, other):
-        return self._cmp(other) >= 0
-
-    def __ne__(self, other):
-        return self._cmp(other) != 0
-
-
-def sortclonebundleentries(ui, entries):
-    prefers = ui.configlist(b'ui', b'clonebundleprefers')
-    if not prefers:
-        return list(entries)
-
-    def _split(p):
-        if b'=' not in p:
-            hint = _(b"each comma separated item should be key=value pairs")
-            raise error.Abort(
-                _(b"invalid ui.clonebundleprefers item: %s") % p, hint=hint
-            )
-        return p.split(b'=', 1)
-
-    prefers = [_split(p) for p in prefers]
-
-    items = sorted(clonebundleentry(v, prefers) for v in entries)
-    return [i.value for i in items]
-
-
 def trypullbundlefromurl(ui, repo, url):
     """Attempt to apply a bundle from a URL."""
     with repo.lock(), repo.transaction(b'bundleurl') as tr:
diff --git a/mercurial/commands.py b/mercurial/commands.py
--- a/mercurial/commands.py
+++ b/mercurial/commands.py
@@ -26,6 +26,7 @@ 
     archival,
     bookmarks,
     bundle2,
+    bundlecaches,
     changegroup,
     cmdutil,
     copies,
@@ -1547,7 +1548,9 @@ 
 
     bundletype = opts.get(b'type', b'bzip2').lower()
     try:
-        bundlespec = exchange.parsebundlespec(repo, bundletype, strict=False)
+        bundlespec = bundlecaches.parsebundlespec(
+            repo, bundletype, strict=False
+        )
     except error.UnsupportedBundleSpecification as e:
         raise error.Abort(
             pycompat.bytestr(e),
diff --git a/mercurial/bundlecaches.py b/mercurial/bundlecaches.py
new file mode 100644
--- /dev/null
+++ b/mercurial/bundlecaches.py
@@ -0,0 +1,422 @@ 
+# bundlecaches.py - utility to deal with pre-computed bundle for servers
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+from .i18n import _
+
+from .thirdparty import attr
+
+from . import (
+    error,
+    sslutil,
+    util,
+)
+from .utils import stringutil
+
+urlreq = util.urlreq
+
+
+@attr.s
+class bundlespec(object):
+    compression = attr.ib()
+    wirecompression = attr.ib()
+    version = attr.ib()
+    wireversion = attr.ib()
+    params = attr.ib()
+    contentopts = attr.ib()
+
+
+# Maps bundle version human names to changegroup versions.
+_bundlespeccgversions = {
+    b'v1': b'01',
+    b'v2': b'02',
+    b'packed1': b's1',
+    b'bundle2': b'02',  # legacy
+}
+
+# Maps bundle version with content opts to choose which part to bundle
+_bundlespeccontentopts = {
+    b'v1': {
+        b'changegroup': True,
+        b'cg.version': b'01',
+        b'obsolescence': False,
+        b'phases': False,
+        b'tagsfnodescache': False,
+        b'revbranchcache': False,
+    },
+    b'v2': {
+        b'changegroup': True,
+        b'cg.version': b'02',
+        b'obsolescence': False,
+        b'phases': False,
+        b'tagsfnodescache': True,
+        b'revbranchcache': True,
+    },
+    b'packed1': {b'cg.version': b's1'},
+}
+_bundlespeccontentopts[b'bundle2'] = _bundlespeccontentopts[b'v2']
+
+_bundlespecvariants = {
+    b"streamv2": {
+        b"changegroup": False,
+        b"streamv2": True,
+        b"tagsfnodescache": False,
+        b"revbranchcache": False,
+    }
+}
+
+# Compression engines allowed in version 1. THIS SHOULD NEVER CHANGE.
+_bundlespecv1compengines = {b'gzip', b'bzip2', b'none'}
+
+
+def parsebundlespec(repo, spec, strict=True):
+    """Parse a bundle string specification into parts.
+
+    Bundle specifications denote a well-defined bundle/exchange format.
+    The content of a given specification should not change over time in
+    order to ensure that bundles produced by a newer version of Mercurial are
+    readable from an older version.
+
+    The string currently has the form:
+
+       <compression>-<type>[;<parameter0>[;<parameter1>]]
+
+    Where <compression> is one of the supported compression formats
+    and <type> is (currently) a version string. A ";" can follow the type and
+    all text afterwards is interpreted as URI encoded, ";" delimited key=value
+    pairs.
+
+    If ``strict`` is True (the default) <compression> is required. Otherwise,
+    it is optional.
+
+    Returns a bundlespec object of (compression, version, parameters).
+    Compression will be ``None`` if not in strict mode and a compression isn't
+    defined.
+
+    An ``InvalidBundleSpecification`` is raised when the specification is
+    not syntactically well formed.
+
+    An ``UnsupportedBundleSpecification`` is raised when the compression or
+    bundle type/version is not recognized.
+
+    Note: this function will likely eventually return a more complex data
+    structure, including bundle2 part information.
+    """
+
+    def parseparams(s):
+        if b';' not in s:
+            return s, {}
+
+        params = {}
+        version, paramstr = s.split(b';', 1)
+
+        for p in paramstr.split(b';'):
+            if b'=' not in p:
+                raise error.InvalidBundleSpecification(
+                    _(
+                        b'invalid bundle specification: '
+                        b'missing "=" in parameter: %s'
+                    )
+                    % p
+                )
+
+            key, value = p.split(b'=', 1)
+            key = urlreq.unquote(key)
+            value = urlreq.unquote(value)
+            params[key] = value
+
+        return version, params
+
+    if strict and b'-' not in spec:
+        raise error.InvalidBundleSpecification(
+            _(
+                b'invalid bundle specification; '
+                b'must be prefixed with compression: %s'
+            )
+            % spec
+        )
+
+    if b'-' in spec:
+        compression, version = spec.split(b'-', 1)
+
+        if compression not in util.compengines.supportedbundlenames:
+            raise error.UnsupportedBundleSpecification(
+                _(b'%s compression is not supported') % compression
+            )
+
+        version, params = parseparams(version)
+
+        if version not in _bundlespeccgversions:
+            raise error.UnsupportedBundleSpecification(
+                _(b'%s is not a recognized bundle version') % version
+            )
+    else:
+        # Value could be just the compression or just the version, in which
+        # case some defaults are assumed (but only when not in strict mode).
+        assert not strict
+
+        spec, params = parseparams(spec)
+
+        if spec in util.compengines.supportedbundlenames:
+            compression = spec
+            version = b'v1'
+            # Generaldelta repos require v2.
+            if b'generaldelta' in repo.requirements:
+                version = b'v2'
+            # Modern compression engines require v2.
+            if compression not in _bundlespecv1compengines:
+                version = b'v2'
+        elif spec in _bundlespeccgversions:
+            if spec == b'packed1':
+                compression = b'none'
+            else:
+                compression = b'bzip2'
+            version = spec
+        else:
+            raise error.UnsupportedBundleSpecification(
+                _(b'%s is not a recognized bundle specification') % spec
+            )
+
+    # Bundle version 1 only supports a known set of compression engines.
+    if version == b'v1' and compression not in _bundlespecv1compengines:
+        raise error.UnsupportedBundleSpecification(
+            _(b'compression engine %s is not supported on v1 bundles')
+            % compression
+        )
+
+    # The specification for packed1 can optionally declare the data formats
+    # required to apply it. If we see this metadata, compare against what the
+    # repo supports and error if the bundle isn't compatible.
+    if version == b'packed1' and b'requirements' in params:
+        requirements = set(params[b'requirements'].split(b','))
+        missingreqs = requirements - repo.supportedformats
+        if missingreqs:
+            raise error.UnsupportedBundleSpecification(
+                _(b'missing support for repository features: %s')
+                % b', '.join(sorted(missingreqs))
+            )
+
+    # Compute contentopts based on the version
+    contentopts = _bundlespeccontentopts.get(version, {}).copy()
+
+    # Process the variants
+    if b"stream" in params and params[b"stream"] == b"v2":
+        variant = _bundlespecvariants[b"streamv2"]
+        contentopts.update(variant)
+
+    engine = util.compengines.forbundlename(compression)
+    compression, wirecompression = engine.bundletype()
+    wireversion = _bundlespeccgversions[version]
+
+    return bundlespec(
+        compression, wirecompression, version, wireversion, params, contentopts
+    )
+
+
+def parseclonebundlesmanifest(repo, s):
+    """Parses the raw text of a clone bundles manifest.
+
+    Returns a list of dicts. The dicts have a ``URL`` key corresponding
+    to the URL and other keys are the attributes for the entry.
+    """
+    m = []
+    for line in s.splitlines():
+        fields = line.split()
+        if not fields:
+            continue
+        attrs = {b'URL': fields[0]}
+        for rawattr in fields[1:]:
+            key, value = rawattr.split(b'=', 1)
+            key = util.urlreq.unquote(key)
+            value = util.urlreq.unquote(value)
+            attrs[key] = value
+
+            # Parse BUNDLESPEC into components. This makes client-side
+            # preferences easier to specify since you can prefer a single
+            # component of the BUNDLESPEC.
+            if key == b'BUNDLESPEC':
+                try:
+                    bundlespec = parsebundlespec(repo, value)
+                    attrs[b'COMPRESSION'] = bundlespec.compression
+                    attrs[b'VERSION'] = bundlespec.version
+                except error.InvalidBundleSpecification:
+                    pass
+                except error.UnsupportedBundleSpecification:
+                    pass
+
+        m.append(attrs)
+
+    return m
+
+
+def isstreamclonespec(bundlespec):
+    # Stream clone v1
+    if bundlespec.wirecompression == b'UN' and bundlespec.wireversion == b's1':
+        return True
+
+    # Stream clone v2
+    if (
+        bundlespec.wirecompression == b'UN'
+        and bundlespec.wireversion == b'02'
+        and bundlespec.contentopts.get(b'streamv2')
+    ):
+        return True
+
+    return False
+
+
+def filterclonebundleentries(repo, entries, streamclonerequested=False):
+    """Remove incompatible clone bundle manifest entries.
+
+    Accepts a list of entries parsed with ``parseclonebundlesmanifest``
+    and returns a new list consisting of only the entries that this client
+    should be able to apply.
+
+    There is no guarantee we'll be able to apply all returned entries because
+    the metadata we use to filter on may be missing or wrong.
+    """
+    newentries = []
+    for entry in entries:
+        spec = entry.get(b'BUNDLESPEC')
+        if spec:
+            try:
+                bundlespec = parsebundlespec(repo, spec, strict=True)
+
+                # If a stream clone was requested, filter out non-streamclone
+                # entries.
+                if streamclonerequested and not isstreamclonespec(bundlespec):
+                    repo.ui.debug(
+                        b'filtering %s because not a stream clone\n'
+                        % entry[b'URL']
+                    )
+                    continue
+
+            except error.InvalidBundleSpecification as e:
+                repo.ui.debug(stringutil.forcebytestr(e) + b'\n')
+                continue
+            except error.UnsupportedBundleSpecification as e:
+                repo.ui.debug(
+                    b'filtering %s because unsupported bundle '
+                    b'spec: %s\n' % (entry[b'URL'], stringutil.forcebytestr(e))
+                )
+                continue
+        # If we don't have a spec and requested a stream clone, we don't know
+        # what the entry is so don't attempt to apply it.
+        elif streamclonerequested:
+            repo.ui.debug(
+                b'filtering %s because cannot determine if a stream '
+                b'clone bundle\n' % entry[b'URL']
+            )
+            continue
+
+        if b'REQUIRESNI' in entry and not sslutil.hassni:
+            repo.ui.debug(
+                b'filtering %s because SNI not supported\n' % entry[b'URL']
+            )
+            continue
+
+        if b'REQUIREDRAM' in entry:
+            try:
+                requiredram = util.sizetoint(entry[b'REQUIREDRAM'])
+            except error.ParseError:
+                repo.ui.debug(
+                    b'filtering %s due to a bad REQUIREDRAM attribute\n'
+                    % entry[b'URL']
+                )
+                continue
+            actualram = repo.ui.estimatememory()
+            if actualram is not None and actualram * 0.66 < requiredram:
+                repo.ui.debug(
+                    b'filtering %s as it needs more than 2/3 of system memory\n'
+                    % entry[b'URL']
+                )
+                continue
+
+        newentries.append(entry)
+
+    return newentries
+
+
+class clonebundleentry(object):
+    """Represents an item in a clone bundles manifest.
+
+    This rich class is needed to support sorting since sorted() in Python 3
+    doesn't support ``cmp`` and our comparison is complex enough that ``key=``
+    won't work.
+    """
+
+    def __init__(self, value, prefers):
+        self.value = value
+        self.prefers = prefers
+
+    def _cmp(self, other):
+        for prefkey, prefvalue in self.prefers:
+            avalue = self.value.get(prefkey)
+            bvalue = other.value.get(prefkey)
+
+            # Special case for b missing attribute and a matches exactly.
+            if avalue is not None and bvalue is None and avalue == prefvalue:
+                return -1
+
+            # Special case for a missing attribute and b matches exactly.
+            if bvalue is not None and avalue is None and bvalue == prefvalue:
+                return 1
+
+            # We can't compare unless attribute present on both.
+            if avalue is None or bvalue is None:
+                continue
+
+            # Same values should fall back to next attribute.
+            if avalue == bvalue:
+                continue
+
+            # Exact matches come first.
+            if avalue == prefvalue:
+                return -1
+            if bvalue == prefvalue:
+                return 1
+
+            # Fall back to next attribute.
+            continue
+
+        # If we got here we couldn't sort by attributes and prefers. Fall
+        # back to index order.
+        return 0
+
+    def __lt__(self, other):
+        return self._cmp(other) < 0
+
+    def __gt__(self, other):
+        return self._cmp(other) > 0
+
+    def __eq__(self, other):
+        return self._cmp(other) == 0
+
+    def __le__(self, other):
+        return self._cmp(other) <= 0
+
+    def __ge__(self, other):
+        return self._cmp(other) >= 0
+
+    def __ne__(self, other):
+        return self._cmp(other) != 0
+
+
+def sortclonebundleentries(ui, entries):
+    prefers = ui.configlist(b'ui', b'clonebundleprefers')
+    if not prefers:
+        return list(entries)
+
+    def _split(p):
+        if b'=' not in p:
+            hint = _(b"each comma separated item should be key=value pairs")
+            raise error.Abort(
+                _(b"invalid ui.clonebundleprefers item: %s") % p, hint=hint
+            )
+        return p.split(b'=', 1)
+
+    prefers = [_split(p) for p in prefers]
+
+    items = sorted(clonebundleentry(v, prefers) for v in entries)
+    return [i.value for i in items]
diff --git a/hgext/lfs/__init__.py b/hgext/lfs/__init__.py
--- a/hgext/lfs/__init__.py
+++ b/hgext/lfs/__init__.py
@@ -127,10 +127,10 @@ 
 from mercurial.i18n import _
 
 from mercurial import (
+    bundlecaches,
     config,
     context,
     error,
-    exchange,
     extensions,
     exthelper,
     filelog,
@@ -351,7 +351,7 @@ 
     # Make bundle choose changegroup3 instead of changegroup2. This affects
     # "hg bundle" command. Note: it does not cover all bundle formats like
     # "packed1". Using "packed1" with lfs will likely cause trouble.
-    exchange._bundlespeccontentopts[b"v2"][b"cg.version"] = b"03"
+    bundlecaches._bundlespeccontentopts[b"v2"][b"cg.version"] = b"03"
 
 
 @eh.filesetpredicate(b'lfs()')