diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e5a54fefe3f09cc2c611eb677ab4446e8849cdc7_LmdpdGxhYi1jaS55bWw=..225920b2ad2e1c61ca62fe7dab98dd0aa4e37aca_LmdpdGxhYi1jaS55bWw= 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -13,7 +13,7 @@
   stage: main
   image: octobus/ci-py-heptapod:py3
   script:
-    - flake8 --exclude stub hgitaly
+    - flake8 --exclude stub hgitaly hgext3rd
     - PYTHONPATH=/ci/repos/mercurial /usr/bin/pytest-3 --cov hgitaly --cov-config=.coveragerc -v
 
 tests-hg-stable:
diff --git a/hgext3rd/hgitaly/__init__.py b/hgext3rd/hgitaly/__init__.py
index e5a54fefe3f09cc2c611eb677ab4446e8849cdc7_aGdleHQzcmQvaGdpdGFseS9fX2luaXRfXy5weQ==..225920b2ad2e1c61ca62fe7dab98dd0aa4e37aca_aGdleHQzcmQvaGdpdGFseS9fX2luaXRfXy5weQ== 100644
--- a/hgext3rd/hgitaly/__init__.py
+++ b/hgext3rd/hgitaly/__init__.py
@@ -0,0 +1,111 @@
+# Copyright 2020 Georges Racinet <georges.racinet@octobus.net>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+import logging
+
+import grpc
+from mercurial.i18n import _
+from mercurial import (
+    error,
+    pycompat,
+    registrar,
+)
+
+from hgitaly.stub import (
+    commit_pb2_grpc,
+)
+from hgitaly.servicer import (
+    Servicer as CommitServicer,
+)
+
+# mercurial.urllibcompat seems to be willing to make urlparse access uniform,
+# but couldn't make it work on py3 right away, and it looks like dead code
+# anyway. Conditional import is way simpler, provided one does not do it by
+# catching ImportError (because of the demandimport trap)
+if pycompat.ispy3:
+    from urllib.parse import urlparse
+    from concurrent import futures
+else:
+    from urlparse import urlparse
+    import futures
+
+
+logger = logging.getLogger(__name__)
+cmdtable = {}
+command = registrar.command(cmdtable)
+
+
+DEFAULT_LISTEN_URL = b'tcp://127.0.0.1:9237'
+
+
+@command(b'hgitaly-serve',
+         [(b'', b'repositories-root',
+           None,
+           _(b'Path to the root directory for repositories storage. '
+             b'Defaults to the `heptapod.repositories-root` '
+             b'configuration item '),
+           _(b'REPOS_ROOT')),
+          (b'', b'listen',
+           [],
+           _(b'URL to listen on, default is %s, can be '
+             b'repeated to listen on several addresses' % DEFAULT_LISTEN_URL),
+           _(b'ADDRESS')),
+          ],
+         _('[OPTIONS]...'),
+         norepo=True
+         )
+def serve(ui, **opts):
+    """Start a HGitaly server.
+
+    By default the root of repositories is read from the
+    `heptapod.repositories-root` configuration item if present.
+    """
+    listen_urls = opts['listen']
+    if not listen_urls:
+        # Any default in the option declaration would be added to
+        # explicely passed values. This is not what we want, hence the late
+        # defaulting done here.
+
+        # Although gRPC defaults to IPv6, existing Gitaly setups
+        # (GDK and monolithic Docker container) bind explicitely on IPv4
+        listen_urls = [DEFAULT_LISTEN_URL]
+    repos_root = opts.get('repositories_root')
+    if repos_root is None:
+        repos_root = ui.config(b'heptapod', b'repositories-root')
+        if repos_root is None:
+            raise error.Abort(_(
+                b"No value found in configuration for "
+                b"'heptapod.repositories-root'. Please define it or run "
+                b"the command with the --repositories-root option."
+                ))
+    server = grpc.server(futures.ThreadPoolExecutor(max_workers=1))
+    commit_pb2_grpc.add_CommitServiceServicer_to_server(
+        CommitServicer(repos_root), server)
+
+    for url in listen_urls:
+        try:
+            parsed_url = urlparse(url)
+        except Exception as exc:
+            raise error.Abort(_(
+                b"Could not parse url %s: %s" % (
+                    url, pycompat.sysbytes(repr(exc)))))
+        if parsed_url.scheme != b'tcp':
+            raise error.Abort(_(
+                b"Only 'tcp://' URLs are supported for the time being "
+                b"to define listening addresses (got %s)" % url))
+        port = server.add_insecure_port(parsed_url.netloc)
+        if port == 0:
+            raise error.Abort(_(
+                b"Could not bind to socket for %s. "
+                b"Please check that this URL is correct." % url))
+
+        logger.info("Listening on %r, final port is %d",
+                    pycompat.sysstr(url), port)
+
+    server.start()
+    logger.info("Server started")
+    server.wait_for_termination()
diff --git a/setup.py b/setup.py
index e5a54fefe3f09cc2c611eb677ab4446e8849cdc7_c2V0dXAucHk=..225920b2ad2e1c61ca62fe7dab98dd0aa4e37aca_c2V0dXAucHk= 100644
--- a/setup.py
+++ b/setup.py
@@ -12,5 +12,5 @@
     license='GPLv2+',
     package_data=dict(heptapod=['*.hgrc']),
     packages=['hgitaly', 'hgext3rd.hgitaly'],
-    install_requires=[],
+    install_requires=['futures; python_version == "2.7"'],
 )