diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 7196784b950a3b3258e1dcb84af7c2986868cb53_LmdpdGxhYi1jaS55bWw=..e5a54fefe3f09cc2c611eb677ab4446e8849cdc7_LmdpdGxhYi1jaS55bWw= 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -2,6 +2,9 @@
   - main
   - compat
 
+before_script:
+  - pip3 install --user -r dev-requirements.txt
+
 variables:
   EVOLVE_REPO_URL: https://mirror.octobus.net/evolve
   EVOLVE_LOCAL_REPO: /ci/repos/evolve
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..e5a54fefe3f09cc2c611eb677ab4446e8849cdc7_Y29uZnRlc3QucHk=
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,27 @@
+import pytest
+
+
+@pytest.fixture(scope='module')
+def grpc_add_to_server():
+    from hgitaly.stub.commit_pb2_grpc import add_CommitServiceServicer_to_server
+
+    return add_CommitServiceServicer_to_server
+
+
+@pytest.fixture(scope='module')
+def server_repos_root(tmp_path_factory):
+    return tmp_path_factory.mktemp("server-repos")
+
+
+@pytest.fixture(scope='module')
+def grpc_servicer(server_repos_root):
+    from hgitaly.servicer import Servicer
+
+    return Servicer(str(server_repos_root).encode())
+
+
+@pytest.fixture(scope='module')
+def grpc_stub_cls(grpc_channel):
+    from hgitaly.stub.commit_pb2_grpc import CommitServiceStub
+
+    return CommitServiceStub
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 7196784b950a3b3258e1dcb84af7c2986868cb53_ZGV2LXJlcXVpcmVtZW50cy50eHQ=..e5a54fefe3f09cc2c611eb677ab4446e8849cdc7_ZGV2LXJlcXVpcmVtZW50cy50eHQ= 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -1,2 +1,4 @@
 flake8
 pytest-cov
+pytest-grpc
+grpcio-tools
diff --git a/hgitaly/servicer.py b/hgitaly/servicer.py
new file mode 100644
index 0000000000000000000000000000000000000000..e5a54fefe3f09cc2c611eb677ab4446e8849cdc7_aGdpdGFseS9zZXJ2aWNlci5weQ==
--- /dev/null
+++ b/hgitaly/servicer.py
@@ -0,0 +1,113 @@
+import logging
+import os
+from mercurial import (
+    error as hg_error,
+    hg,
+    node,
+    ui as uimod,
+)
+from mercurial.utils import stringutil as hg_stringutil
+from google.protobuf.timestamp_pb2 import Timestamp
+
+from .stub.shared_pb2 import (
+    CommitAuthor,
+    GitCommit,
+    Repository,
+)
+from .stub.commit_pb2 import (
+    FindCommitRequest,
+    FindCommitResponse,
+)
+from .stub.commit_pb2_grpc import CommitServiceServicer
+
+logger = logging.getLogger(__name__)
+
+
+def changectx_to_git_author(ctx):
+    auth = ctx.user()
+    date = Timestamp()
+    # hg time resolution is the second, see
+    # https://www.mercurial-scm.org/wiki/ChangeSet
+    date.FromSeconds(int(ctx.date()[0]))
+    return CommitAuthor(
+        email=hg_stringutil.email(auth),
+        name=hg_stringutil.person(auth),
+        date=date,
+        )
+
+
+def changectx_to_git_commit(ctx):
+    """Return :class:`GitCommit` object from Mercurial :class:`changectx`.
+
+    subject and body are as in gitaly/internal/git/log/commitmessage.go::
+
+      var body string
+      if split := strings.SplitN(commitString, "\n\n", 2); len(split) == 2 {
+          body = split[1]
+      }
+      subject := strings.TrimRight(strings.SplitN(body, "\n", 2)[0], "\r\n")
+
+    See also slightly different stripping gitlab/lib/gitlab/git/commit.rb::
+
+        message_split = raw_commit.message.split("\n", 2)
+        Gitaly::GitCommit.new(
+          id: raw_commit.oid,
+          subject: message_split[0] ? message_split[0].chomp.b : "",
+          body: raw_commit.message.b,
+          parent_ids: raw_commit.parent_ids,
+          author: gitaly_commit_author_from_rugged(raw_commit.author),
+          committer: gitaly_commit_author_from_rugged(raw_commit.committer)
+        )
+    """
+    descr = ctx.description()
+    author = changectx_to_git_author(ctx)
+    return GitCommit(id=ctx.hex(),
+                     subject=descr.split(b'\n', 1)[0].rstrip(b'\r\n'),
+                     body=descr,
+                     parent_ids=[p.hex().decode()
+                                 for p in ctx.parents()
+                                 if p.rev() != node.nullrev],
+                     author=author,
+                     committer=author,
+                     )
+
+
+class Servicer(CommitServiceServicer):
+    """CommitService implementation.
+
+    Attributes:
+
+    - `repositories_root`: should be given as bytes, since we'll have
+      to convert paths to bytes anyway, which is the only promise a filesystem
+      can make, and what Mercurial expects.
+    """
+
+    def __init__(self, repositories_root):
+        self.repos_root = repositories_root
+        self.ui = uimod.ui.load()
+
+    def load_repo(self, repository: Repository):
+        # shamelessly taken from heptapod.wsgi for the Hgitaly bootstrap
+        # note that Gitaly Repository has more than just a relative path,
+        # we'll have to decide what we make of the extra information
+        rpath = repository.relative_path
+        if rpath.endswith('.git'):
+            rpath = rpath[:-4] + '.hg'
+        # GitLab filesystem paths are always ASCII
+        repo_path = os.path.join(self.repos_root, rpath.encode('ascii'))
+        logger.info("loading repo at %r", repo_path)
+
+        # ensure caller gets private copy of ui
+        return hg.repository(self.ui.copy(), repo_path)
+
+    def FindCommit(self,
+                   request: FindCommitRequest, context) -> FindCommitResponse:
+        repo = self.load_repo(request.repository)
+        logger.debug("FindCommit revision=%r", request.revision)
+        try:
+            ctx = repo[request.revision]
+        except hg_error.RepoLookupError:
+            commit = None
+        else:
+            commit = changectx_to_git_commit(ctx)
+        return FindCommitResponse(commit=commit)
diff --git a/hgitaly/tests/test_commit.py b/hgitaly/tests/test_commit.py
new file mode 100644
index 0000000000000000000000000000000000000000..e5a54fefe3f09cc2c611eb677ab4446e8849cdc7_aGdpdGFseS90ZXN0cy90ZXN0X2NvbW1pdC5weQ==
--- /dev/null
+++ b/hgitaly/tests/test_commit.py
@@ -0,0 +1,50 @@
+import time
+
+from ..stub.commit_pb2 import FindCommitRequest
+from ..stub.shared_pb2 import Repository
+from ..testhelpers import LocalRepoWrapper
+
+
+def test_find_commit(grpc_stub, server_repos_root):
+    repo_path = server_repos_root / 'repo1'
+    wrapper = LocalRepoWrapper.init(repo_path)
+    now = time.time()
+    ctx = wrapper.write_commit('foo',
+                               utc_timestamp=now,
+                               user="HGitaly Test <hgitaly@heptapod.test>")
+    ctx2 = wrapper.write_commit('foo',
+                                parent=ctx,
+                                message="Foo deux\n\nA very interesting bar")
+
+    request = FindCommitRequest(
+        repository=Repository(relative_path='repo1'),
+        revision=ctx.node())
+    response = grpc_stub.FindCommit(request)
+
+    commit = response.commit
+    assert commit is not None
+    assert commit.id == ctx.hex().decode()
+    assert commit.parent_ids == []
+    assert commit.author.name == b"HGitaly Test"
+    assert commit.author.email == b"hgitaly@heptapod.test"
+    assert commit.author.date.seconds == int(now)
+
+    request = FindCommitRequest(
+        repository=Repository(relative_path='repo1'),
+        revision=ctx2.node())
+    response = grpc_stub.FindCommit(request)
+
+    commit2 = response.commit
+    assert commit2 is not None
+    assert commit2.subject == b'Foo deux'
+    assert commit2.body == b"Foo deux\n\nA very interesting bar"
+    assert commit2.parent_ids == [ctx.hex().decode()]
+
+    # TODO check with two parents, it'd be nice to have a helper to create
+    # merge commits very quickly
+
+    request = FindCommitRequest(
+        repository=Repository(relative_path='repo1'),
+        revision=b'.' * 20)
+    response = grpc_stub.FindCommit(request)
+    assert not response.HasField('commit')