diff --git a/hgitaly/manifest.py b/hgitaly/manifest.py
index 7d3ad038d4532c46bfedc5a2d19d83bfb56dada2_aGdpdGFseS9tYW5pZmVzdC5weQ==..7b633b815f9a653cd95517e40e29129d14520488_aGdpdGFseS9tYW5pZmVzdC5weQ== 100644
--- a/hgitaly/manifest.py
+++ b/hgitaly/manifest.py
@@ -194,6 +194,19 @@
                    prefix + b'/'.join(flat_path) if flat_path else dir_path
                    )
 
+    def file_names_by_regexp(self, rx, subdir=b''):
+        manifest = self.changeset.manifest()
+        subdir_prefix = subdir + b'/' if subdir else b''
+
+        for file_path in manifest.iterkeys():
+            if not file_path.startswith(subdir_prefix):
+                continue
+
+            if rx is not None and rx.search(file_path) is None:
+                continue
+
+            yield file_path
+
 
 def miner(changeset):
     """Return an appropriate manifest extractor for the given changeset.
diff --git a/hgitaly/service/commit.py b/hgitaly/service/commit.py
index 7d3ad038d4532c46bfedc5a2d19d83bfb56dada2_aGdpdGFseS9zZXJ2aWNlL2NvbW1pdC5weQ==..7b633b815f9a653cd95517e40e29129d14520488_aGdpdGFseS9zZXJ2aWNlL2NvbW1pdC5weQ== 100644
--- a/hgitaly/service/commit.py
+++ b/hgitaly/service/commit.py
@@ -196,4 +196,6 @@
                     "allowed size (%d)" % (size, max_size))
 
             data = filectx.data()
+            logger = LoggerAdapter(base_logger, context)
+
             limit = request.limit
@@ -199,4 +201,5 @@
             limit = request.limit
+            logger.warning("TreeEntry limit=%r, data=%r", limit, data)
             if limit != 0:
                 data = data[:limit]
 
diff --git a/hgitaly/service/repository.py b/hgitaly/service/repository.py
index 7d3ad038d4532c46bfedc5a2d19d83bfb56dada2_aGdpdGFseS9zZXJ2aWNlL3JlcG9zaXRvcnkucHk=..7b633b815f9a653cd95517e40e29129d14520488_aGdpdGFseS9zZXJ2aWNlL3JlcG9zaXRvcnkucHk= 100644
--- a/hgitaly/service/repository.py
+++ b/hgitaly/service/repository.py
@@ -8,6 +8,7 @@
 import itertools
 import logging
 import os
+import re
 import shutil
 import tempfile
 
@@ -34,7 +35,10 @@
     parse_keep_around_ref,
 )
 
-from .. import message
+from .. import (
+    manifest,
+    message,
+)
 from ..branch import (
     iter_gitlab_branches,
 )
@@ -67,6 +71,9 @@
     WRITE_BUFFER_SIZE,
     streaming_request_tempfile_extract,
 )
+from ..util import (
+    chunked,
+)
 from ..stub.repository_pb2 import (
     BackupCustomHooksRequest,
     BackupCustomHooksResponse,
@@ -121,6 +128,11 @@
     GetArchiveRequest.Format.Value('TAR_GZ'): b'tgz',
     GetArchiveRequest.Format.Value('TAR_BZ2'): b'tbz2',
 }
+SEARCH_FILES_FILTER_MAX_LENGTH = 1000
+"""Maximum size of regular expression in SearchFiles methods.
+
+Value taken from Gitaly's `internal/gitaly/service/repository/search_files.go'
+"""
 
 
 class RepositoryCreationError(ServiceError):
@@ -318,7 +330,47 @@
 
     def SearchFilesByName(self, request: SearchFilesByNameRequest,
                           context) -> SearchFilesByNameResponse:
-        not_implemented(context, issue=80)  # pragma no cover
+        repo = self.load_repo(request.repository, context)
+        changeset = gitlab_revision_changeset(repo, request.ref)
+        if changeset is None:
+            yield SearchFilesByNameResponse()
+            return
+
+        query = request.query
+        if not query:
+            context.abort(StatusCode.INVALID_ARGUMENT, "no query given")
+
+        subdir = b'' if query == '.' else query.encode('utf8')
+
+        miner = manifest.miner(changeset)
+
+        filt = request.filter
+        if not filt:
+            rx = None
+        else:
+            if len(filt) > SEARCH_FILES_FILTER_MAX_LENGTH:
+                context.abort(
+                    StatusCode.INVALID_ARGUMENT,
+                    "SearchFilesByName: filter exceeds maximum length")
+            # TODO what to do for invalid Regexp? Especially with incoming
+            # format being Golang's
+            try:
+                rx = re.compile(filt.encode('utf8'))
+            except re.error as exc:
+                context.abort(
+                    StatusCode.INVALID_ARGUMENT,
+                    f"SearchFilesByName: filter did not compile: {exc})"
+                )
+
+        start = request.offset
+        end = None if request.limit == 0 else start + request.limit
+
+        for paths in chunked(
+                itertools.islice(
+                    miner.file_names_by_regexp(rx, subdir),
+                    start, end)
+        ):
+            yield SearchFilesByNameResponse(files=paths)
 
     def SearchFilesByContent(self, request: SearchFilesByContentRequest,
                              context) -> SearchFilesByContentResponse:
diff --git a/hgitaly/service/tests/test_repository_service.py b/hgitaly/service/tests/test_repository_service.py
index 7d3ad038d4532c46bfedc5a2d19d83bfb56dada2_aGdpdGFseS9zZXJ2aWNlL3Rlc3RzL3Rlc3RfcmVwb3NpdG9yeV9zZXJ2aWNlLnB5..7b633b815f9a653cd95517e40e29129d14520488_aGdpdGFseS9zZXJ2aWNlL3Rlc3RzL3Rlc3RfcmVwb3NpdG9yeV9zZXJ2aWNlLnB5 100644
--- a/hgitaly/service/tests/test_repository_service.py
+++ b/hgitaly/service/tests/test_repository_service.py
@@ -49,6 +49,7 @@
     RemoveRepositoryRequest,
     RepositoryExistsRequest,
     RestoreCustomHooksRequest,
+    SearchFilesByNameRequest,
     SetFullPathRequest,
     WriteRefRequest,
 )
@@ -114,6 +115,16 @@
         self.repo_wrapper = self.make_repo_wrapper(rel_path,
                                                    storage_name=storage_name)
 
+    def search_files_by_name(self, ref=b'branch/default', **kwargs):
+        return [path
+                for resp in self.stub.SearchFilesByName(
+                        SearchFilesByNameRequest(
+                            repository=self.grpc_repo,
+                            ref=ref,
+                            **kwargs,
+                        ))
+                for path in resp.files]
+
     def restore_custom_hooks(self, tarball_path,
                              grpc_repo=None,
                              nb_chunks=2,
@@ -515,6 +526,61 @@
         storage_path.mkdir(exist_ok=True)
 
 
+def test_search_files_by_name(fixture_with_repo):
+    fixture = fixture_with_repo
+    wrapper = fixture.repo_wrapper
+
+    ctx0 = wrapper.write_commit('afoo', message="Some foo")
+    sub = (wrapper.path / 'sub')
+    sub.mkdir()
+    (sub / 'bar').write_text('bar content')
+    (sub / 'ba2').write_text('ba2 content')
+    # TODO OS indep for paths (actually TODO make wrapper.commit easier to
+    # use, e.g., check how to make it accept patterns)
+    wrapper.commit(rel_paths=['sub/bar', 'sub/ba2'],
+                   message="zebar", add_remove=True)
+
+    wrapper.write_commit('animals', message="A branch without subdir",
+                         branch='other', parent=ctx0)
+
+    search = fixture.search_files_by_name
+    assert search(filter='/bar', query='.') == [b'sub/bar']
+    assert search(filter='^sub/', query='.') == [b'sub/ba2', b'sub/bar']
+    # matching in subdirectory only
+    assert search(filter='a', query='sub') == [b'sub/ba2', b'sub/bar']
+    assert search(filter='b?r$', query='sub') == [b'sub/bar']
+
+    # without filter
+    assert search(query='.') == [b'afoo', b'sub/ba2', b'sub/bar']
+    assert search(query='sub') == [b'sub/ba2', b'sub/bar']
+
+    # offset and limit
+    assert search(query='.', limit=2) == [b'afoo', b'sub/ba2']
+    assert search(query='.', limit=1, offset=1) == [b'sub/ba2']
+
+    # from a different ref
+    assert search(filter='bar', query='.', ref=b'branch/other') == []
+    assert search(filter='.*', query='.', ref=b'branch/other') == [b'afoo',
+                                                                   b'animals']
+
+    # query is mandatory
+    with pytest.raises(grpc.RpcError) as exc_info:
+        search(filter='/ba')
+    assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
+
+    # problems with regexp
+    with pytest.raises(grpc.RpcError) as exc_info:
+        search(filter='\\', query='.')
+    assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
+
+    with pytest.raises(grpc.RpcError) as exc_info:
+        search(filter='a' * 1001, query='.')
+    assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
+
+    # unknown ref gives back an empty list of files, not an error
+    assert search(ref=b'unknown', query='.') == []
+
+
 def test_restore_custom_hooks(fixture_with_repo, tmpdir):
     fixture = fixture_with_repo
     wrapper = fixture.repo_wrapper
diff --git a/tests_with_gitaly/test_repository_service.py b/tests_with_gitaly/test_repository_service.py
index 7d3ad038d4532c46bfedc5a2d19d83bfb56dada2_dGVzdHNfd2l0aF9naXRhbHkvdGVzdF9yZXBvc2l0b3J5X3NlcnZpY2UucHk=..7b633b815f9a653cd95517e40e29129d14520488_dGVzdHNfd2l0aF9naXRhbHkvdGVzdF9yZXBvc2l0b3J5X3NlcnZpY2UucHk= 100644
--- a/tests_with_gitaly/test_repository_service.py
+++ b/tests_with_gitaly/test_repository_service.py
@@ -22,6 +22,7 @@
     FindMergeBaseRequest,
     FullPathRequest,
     RemoveRepositoryRequest,
+    SearchFilesByNameRequest,
     SetFullPathRequest,
 )
 from hgitaly.stub.repository_pb2_grpc import RepositoryServiceStub
@@ -546,3 +547,68 @@
 
     for vcs in ('hg', 'git'):
         assert not target_repo_path(vcs, 'broken-bundle').exists()
+
+
+def test_search_files_by_name(gitaly_comparison):
+    fixture = gitaly_comparison
+
+    wrapper = fixture.hg_repo_wrapper
+    ctx0 = wrapper.write_commit('afoo', message="Some foo")
+    sub = (wrapper.path / 'sub')
+    sub.mkdir()
+    (sub / 'bar').write_text('bar content')
+    (sub / 'ba2').write_text('ba2 content')
+    # TODO OS indep for paths (actually TODO make wrapper.commit easier to
+    # use, e.g., check how to make it accept patterns)
+    wrapper.commit(rel_paths=['sub/bar', 'sub/ba2'],
+                   message="zebar", add_remove=True)
+
+    wrapper.write_commit('animals', message="A branch without subdir",
+                         branch='other', parent=ctx0)
+
+    default_rev = b'branch/default'
+
+    rpc_helper = fixture.rpc_helper(stub_cls=RepositoryServiceStub,
+                                    method_name='SearchFilesByName',
+                                    request_cls=SearchFilesByNameRequest,
+                                    request_defaults=dict(
+                                        ref=default_rev,
+                                        limit=0,
+                                        offset=0),
+                                    streaming=True,
+                                    )
+    assert_compare = rpc_helper.assert_compare
+    assert_compare_errors = rpc_helper.assert_compare_errors
+
+    # precondition for the test: mirror worked
+    assert fixture.git_repo.branch_titles() == {
+        default_rev: b"zebar",
+        b'branch/other': b"A branch without subdir",
+    }
+
+    assert_compare(filter='/bar', query='.')
+    assert_compare(filter='^sub/', query='.')
+    assert_compare(filter='a', query='sub')
+    assert_compare(filter='b?r$', query='sub')
+
+    # without filter
+    assert_compare(query='.')
+    assert_compare(query='sub')
+
+    # offset and limit (we have 3 matches without filter)
+    assert_compare(query='.', limit=2)
+    assert_compare(query='.', limit=1, offset=1)  # both sort lexicographically
+
+    # from a different ref
+    assert_compare(filter='bar', query='.', ref=b'branch/other')  # no match
+    assert_compare(filter='.*', query='.', ref=b'branch/other')
+
+    # query is mandatory
+    assert_compare_errors(filter='/ba')
+
+    # problems with regexp
+    assert_compare_errors(filter='\\', query='.', same_details=False)
+    assert_compare_errors(filter='a' * 1001, query='.')
+
+    # unknown ref gives back an empty list of files, not an error
+    assert_compare(ref=b'unknown', query='.')