diff --git a/.hgtags b/.hgtags
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_LmhndGFncw==..9333633b4fb4fc6e84cb0ee30b22fed537d15879_LmhndGFncw== 100644
--- a/.hgtags
+++ b/.hgtags
@@ -85,3 +85,4 @@
 82589bde15240ebf54c61253bf4c8de1605f6599 0.44.1
 73a8fde7ac291e9fae5fed7ac877e394d5b01ff9 0.45.0
 109da5dc9155070e97d50a2ba76ff2e8e8314018 1.0.0
+c700ef6202eebaf480503b47fabf8dbfd1ac6d12 1.1.0
diff --git a/README.md b/README.md
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_UkVBRE1FLm1k..9333633b4fb4fc6e84cb0ee30b22fed537d15879_UkVBRE1FLm1k 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,174 @@
 
 HGitaly is Gitaly server for Mercurial.
 
-## Logging
+It implements the subset of the Gitaly gRPC protocol that is relevant for
+Mercurial repositories, as well as its own HGitaly protocol, with methods
+that are specific to Mercurial.
+
+It comes in two overlapping variants:
+
+- HGitaly proper is written in Python, using the `grpcio` official library.
+- RHGitaly is a high-performance partial implementation written in Rust, and
+  based on the [`tonic`](https://crates.io/crates/tonic) gRPC framework.
+
+  As of this writing, RHGitaly implements a strict subset of the methods
+  implemented in HGitaly, but it is possible that some methods would be
+  implemented in RHGitaly only in the future.
+
+## Installation
+
+### HGitaly (Python)
+
+In what follows, `$PYTHON` is often the Python interpreter in a virtualenv,
+but it can be a system-wide one (typical case in containers, strongly
+discouraged on user systemes).
+
+1. Install Mercurial with Rust parts (for the exact version, refer to the
+   requirements file in the Heptapod main repository sources)
+
+   ```
+   $PYTHON -m pip install --no-use-pep517 --global-option --rust Mercurial==6.6.2
+   ```
+
+2. Install HGitaly itself (check that it does not reinstall Mercurial)
+
+   ```
+   $PYTHON -m pip install hgitaly
+   ```
+
+### RHGitaly
+
+We distribute a self-contained source tarball. It includes the appropriate
+`hg-core` Rust sources.
+
+1. Fetch the tarball
+
+   ```
+   wget https://download.heptapod.net/rhgitaly/rhgitaly-x.y.z.tgz
+   ```
+
+2. Fetch and verify the GPG signature
+
+   ```
+   wget https://download.heptapod.net/rhgitaly/rhgitaly-x.y.z.tgz.asc
+   gpg --verify rhgitaly-x.y.z.tgz.asc
+   ```
+
+3. Build
+
+   ```
+   tar xzf rhgitaly-x.y.z.tgz
+   cd rhgitaly-x.y.z/rust
+   cargo build --locked --release
+   ```
+
+4. Install wherever you want. Example given for a system-wide installation
+
+   ```
+   sudo install -o root -g root target/release/rhgitaly /usr/local/bin
+   ```
+
+5. Define a service. Example given for systemd, to be adjusted for your needs.
+   Make sure in particular that user and all directories exist, with
+   appropriate permissions.
+
+   ```
+   [Unit]
+   Description=Heptapod RHGitaly Server
+
+   [Service]
+   User=hgitaly
+   # HGRCPATH not needed yet but probably will be at some point
+   Environment=HGRCPATH=/etc/heptapod/heptapod.hgrc
+   Environment=RHGITALY_LISTEN_URL=unix:/run/heptapod/rhgitaly.socket
+   Environment=RHGITALY_REPOSITORIES_ROOT=/home/hg/repositories
+   ExecStartPre=rm -f /run/heptapod/rhgitaly.socket
+   ExecStart=/user/local/bin/rhgitaly
+   Restart=on-failure
+
+   [Install]
+   WantedBy=default.target
+   ```
+
+### External executables
+
+HGitaly needs several other programs to be installed and will run them
+as separate processes.
+
+By default, it expects to find them on `$PATH`, but the actual path to
+each executable can be configured.
+
+#### Tokei
+
+[Tokei](https://crates.io/crates/tokei) is a programming languages analysis
+tool written in Rust. It is used by the [CommitLanguages](protos/commit.proto)
+method.
+
+Tokei is available in several Linux distributions.
+
+As of this writing, HGitaly supports versions 12.0 and 12.1
+
+#### Go license-detector
+
+Usually installed as `license-detector`, this standalone executable is
+part of the `go-enry` suite. Its library version is also used by Gitaly.
+
+It is used in the [FindLicense](protos/repository.proto) method.
+
+#### Git
+
+HGitaly can make use of some Git commands that do not involve repositories!
+This is for example the case of [GetPatchID](protos/diff.proto): the
+`git patch-id` command does not access any repository. Instead it computes any
+patch into an identifier.
+
+#### Mercurial
+
+In forthcoming versions, it is probable that HGitaly and/or RHGitaly will
+invoke Mercurial subprocesses.
+
+This is not yet the case as of this writing (HGitaly 1.1 / Heptapod 1.1).
+
+### Configuration
+
+HGitaly's configuration is done the standard way in the Mercurial world:
+through HGRC files.
+
+In a typical Heptapod installation, these are split into a managed file, for
+consistency with other components and another one for edit by the systems
+administrator (`/etc/gitlab/heptapod.hgrc` in Omnibus/Docker instances).
+
+Many Mercurial tweaks are interpreted simply because HGitaly internally
+calls into Mercurial, but HGitaly also gets its own section. Here are the
+settings available as of HGitaly 1.1
+
+```
+[hgitaly]
+# paths to external executables
+tokei-executable = tokei
+license-detector-executable = license-detector
+git-executable = git
+
+# The number of workers process default value is one plus half the CPU count.
+# It can be explicitly set this way:
+#workers = 4
+
+# Time to let a worker finish treating its current request, if any, when
+# gracefully restarted. Default is high because of backup requests.
+worker.graceful-shutdown-timeout-seconds = 300
+# Maximum allowed resident size for worker processes (MiB).
+# They get gracefully restarted if they cross that threshold
+worker.max_rss_mib = 1024
+# Interval between memory monitoring of workers (results dumped in logs)
+worker.monitoring-interval-seconds = 60
+```
+
+Also `heptapod.repositories-root` is used if `--repositories-root` is
+not passed on the command line.
+
+## Operation
+
+### Logging
 
 HGitaly is using the standard `logging` Python module, and the
 `loggingmod` Mercurial extension to emit logs from the Mercurial core
@@ -34,4 +201,5 @@
   instance a repository inconsistency should be logged at `WARNING`
   level, with a message including the path.
 
+## Development
 
@@ -37,3 +205,3 @@
 
-## Automated tests and Continuous Integration
+### Automated tests and Continuous Integration
 
@@ -39,5 +207,5 @@
 
-### How to run the tests
+#### How to run the tests
 
 Usually, that would be in a virtualenv, but it's not necessary.
 
@@ -49,7 +217,7 @@
 Hint: Check the contents of `run-all-tests`, it's just `pytest` with
 a standard set of options (mostly for coverage, see below).
 
-### Unit and Mercurial integration tests
+#### Unit and Mercurial integration tests
 
 These are the main tests. They lie inside the `hgitaly`
 and `hgext3rd.hgitaly` Python packages. The layout follows the style where
@@ -70,7 +238,7 @@
 - Gitaly documentation and source code.
 - sampling of Gitaly responses.
 
-### Gitaly comparison tests
+#### Gitaly comparison tests
 
 If an appropriate Gitaly installation is found, `run-all-tests` will also
 run the tests from the `tests_with_gitaly` package. This happens automatically
@@ -88,7 +256,7 @@
 of the implementation, with its various corner cases,
 should be left to the Mercurial integration tests.
 
-### Test coverage
+#### Test coverage
 
 This project is being developed with a strong test coverage policy, enforced by
 CI: without the Gitaly comparison tests, the coverage has to stay at 100%.
@@ -115,5 +283,5 @@
 On the other hand, Gitaly comparison tests will warn us when we bump upstream
 GitLab if some critical behaviour has changed.
 
-### Tests Q&A and development hints
+#### Tests Q&A and development hints
 
@@ -119,5 +287,5 @@
 
-#### Doesn't the 100% coverage rule without the Gitaly comparison tests mean writing the same tests twice?
+##### Doesn't the 100% coverage rule without the Gitaly comparison tests mean writing the same tests twice?
 
 In some cases, yes, but it's limited.
 
@@ -141,7 +309,7 @@
 returned sets of branch names. This is a bit less cumbersome, and easier
 to maintain.
 
-### How to reproduce a drop in coverage found by the `compat` CI stage?
+#### How to reproduce a drop in coverage found by the `compat` CI stage?
 
 These are often due to statements being covered by the Gitaly comparison
 tests only, leading to 100% coverage in the `main` stage, but not in the
@@ -160,7 +328,7 @@
 between Mercurial versions. If that happens, there are good chances that an
 actual bug is lurking around.
 
-### How to run the tests with coverage of the Gitaly comparison tests
+#### How to run the tests with coverage of the Gitaly comparison tests
 
 ```
 ./run-all-tests --cov tests_with_gitaly --cov-report html
@@ -181,7 +349,7 @@
 assumed to be available. For these, the coverage would tell us that something
 was broken, preventing the tests to run.
 
-### How to poke into Gitaly protocol?
+#### How to poke into Gitaly protocol?
 
 The Gitaly comparison tests provide exactly a harness for that: take a test,
 modify it as needed, insert a `pdb` breakpoint, and get going.
@@ -193,8 +361,8 @@
 Of course that will raise the question whether it'll be useful to make true
 tests of these experiments.
 
-### When is a Gitaly comparison test required?
+#### When is a Gitaly comparison test required?
 
 Each time there's a need to be sure of what's expected and it can help answer
 that question. It doesn't have to do more than that.
 
@@ -197,8 +365,8 @@
 
 Each time there's a need to be sure of what's expected and it can help answer
 that question. It doesn't have to do more than that.
 
-### When to prefer writing RSpec tests in Heptapod Rails over Gitaly comparison tests in HGitaly?
+#### When to prefer writing RSpec tests in Heptapod Rails over Gitaly comparison tests in HGitaly?
 
 If you need to make sure that Heptapod Rails, as a Gitaly client, sends
 the proper requests, because that can depend on specific dispatch code.
@@ -231,9 +399,9 @@
 
 5. perform necessary `hg add` after close inspection of `hg status`
 
-## Updating the HGitaly specific gRPC protocol
+### Updating the HGitaly specific gRPC protocol
 
 This package defines and implements an additional gRPC protocol, with
 gRPC services and methods that are specific to Mercurial, or more generally
 Heptapod.
 
@@ -235,9 +403,9 @@
 
 This package defines and implements an additional gRPC protocol, with
 gRPC services and methods that are specific to Mercurial, or more generally
 Heptapod.
 
-### Protocol specification
+#### Protocol specification
 
 The sources are `proto` files in the `protos/` directory, same as for the
 Gitaly protocol.
@@ -252,7 +420,7 @@
 provided programming languages have to be regenerated and committed, ideally
 together with the protocol change.
 
-### Python library
+#### Python library
 
 It has a special status, being versioned together with the protocol and the
 server implementation. It is provided as the [hgitaly.stub](hgitaly/stub)
@@ -265,7 +433,7 @@
 ./generate-stubs
 ```
 
-### Ruby library
+#### Ruby library
 
 See [the separate documentation](ruby/README.md)
 
@@ -269,7 +437,7 @@
 
 See [the separate documentation](ruby/README.md)
 
-### Other languages
+#### Other languages
 
 A Go library will probably be necessary quite soon for Workhorse or perhaps
 Heptapod Shell.
diff --git a/hgext3rd/hgitaly/__init__.py b/hgext3rd/hgitaly/__init__.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdleHQzcmQvaGdpdGFseS9fX2luaXRfXy5weQ==..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdleHQzcmQvaGdpdGFseS9fX2luaXRfXy5weQ== 100644
--- a/hgext3rd/hgitaly/__init__.py
+++ b/hgext3rd/hgitaly/__init__.py
@@ -31,6 +31,7 @@
     configitem = registrar.configitem(configtable)
     configitem(b'hgitaly', b'tokei-executable', b'tokei')
     configitem(b'hgitaly', b'license-detector-executable', b'license-detector')
+    configitem(b'hgitaly', b'git-executable', b'git')
 
 
 cmdtable = {}
diff --git a/hgitaly/VERSION b/hgitaly/VERSION
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9WRVJTSU9O..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9WRVJTSU9O 100644
--- a/hgitaly/VERSION
+++ b/hgitaly/VERSION
@@ -1,1 +1,1 @@
-1.0.1dev0
+1.1.1dev0
diff --git a/hgitaly/diff.py b/hgitaly/diff.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9kaWZmLnB5..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9kaWZmLnB5 100644
--- a/hgitaly/diff.py
+++ b/hgitaly/diff.py
@@ -5,6 +5,11 @@
 #
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Utilities for comparison of content between changesets."""
+import attr
+from functools import cached_property
+import re
+import subprocess
+
 from mercurial import (
     copies,
     diffutil,
@@ -8,9 +13,10 @@
 from mercurial import (
     copies,
     diffutil,
+    error,
     match as matchmod,
     patch as patchmod,
 )
 
 from .file_context import git_perms
 from .git import (
@@ -11,8 +17,10 @@
     match as matchmod,
     patch as patchmod,
 )
 
 from .file_context import git_perms
 from .git import (
+    FILECTX_FLAGS_TO_GIT_MODE_BYTES,
+    NULL_BLOB_OID,
     OBJECT_MODE_DOES_NOT_EXIST,
 )
@@ -17,8 +25,11 @@
     OBJECT_MODE_DOES_NOT_EXIST,
 )
+from .oid import (
+    blob_oid,
+)
 
 from .stub.diff_pb2 import (
     ChangedPaths,
     DiffStats,
 )
 
@@ -19,9 +30,17 @@
 
 from .stub.diff_pb2 import (
     ChangedPaths,
     DiffStats,
 )
 
+GIT_PATCH_ID_TIMEOUT_SECONDS = 10
+"""Time given to `git patch-id` to finish after we've streamed the content.
+
+Given that `git patch-id` is a C program (verified assertion), we expect it
+to be much faster than HGitaly feeding it. A timeout of 10 seconds after
+we're done streaming the patch content is therefore enormous.
+"""
+
 Status_Type_Map = dict(
     added=ChangedPaths.Status.ADDED,
     modified=ChangedPaths.Status.MODIFIED,
@@ -32,6 +51,8 @@
 """Mapping status object attributes to ChangedPaths enum."""
 
 COPIED = ChangedPaths.Status.COPIED
+DIFF_HUNKS_START_RX = re.compile(rb'^(--- )|^(Binary file)')
+"""To match the header line right before hunks start getting dumped."""
 
 
 def changed_paths(repo, from_ctx, to_ctx, base_path):
@@ -106,14 +127,6 @@
                            )
 
 
-def chunk_old_new_file_path(header):
-    """Return a tuple of (old, new) file path for a diff chunk header
-    """
-    fname = header.filename()
-    from_path, to_path = fname, fname
-    if len(header.files()) > 1:
-        # file is renamed
-        from_path, to_path = header.files()
-    return from_path, to_path
-
+def chunk_stats(chunks, from_ctx, to_ctx):
+    """Yield the DiffStats messages from the given diff chunks.
 
@@ -119,10 +132,7 @@
 
-def chunk_additions_deletions(header):
-    """Return the pair (addition, deletions) for a diff chunk header."""
-    adds, dels = 0, 0
-    for hunk in header.hunks:
-        add_count, del_count = hunk.countchanges(hunk.hunk)
-        adds += add_count
-        dels += del_count
-    return adds, dels
+    Changectx params are there for uniformity and not really needed as
+    of this writing
+    """
+    for hg_chunk in patchmod.parsepatch(chunks):
+        chunk = Chunk(hg_chunk, from_ctx, to_ctx)
 
@@ -128,8 +138,4 @@
 
-
-def chunk_stats(chunks):
-    """Yield the DiffStats messages from the given diff chunks"""
-    for header in patchmod.parsepatch(chunks):
-        old_path, path = chunk_old_new_file_path(header)
+        old_path, path = chunk.from_to_file_paths
         if old_path == path:
             old_path = b''
@@ -134,6 +140,6 @@
         if old_path == path:
             old_path = b''
-        adds, dels = chunk_additions_deletions(header)
+        adds, dels = chunk.additions_deletions()
         yield DiffStats(
             path=path,
             old_path=old_path,
@@ -145,3 +151,215 @@
 def diff_opts(repo, git=True):
     opts = {b'git': git}
     return diffutil.difffeatureopts(repo.ui, opts=opts, git=git)
+
+
+def _get_filectx(changeset, path):
+    try:
+        return changeset[path]
+    except error.ManifestLookupError:
+        return None
+
+
+@attr.define
+class Chunk:
+    """Wrap a Mercurial chunk with extracted information.
+
+    This notably avoids repeated lookups in manifests.
+
+    Perfomance notes: we assume that looking up in the manifest is the
+    expensive task, not building :class:`filectx` instances, hence we
+    do not make methods to check if a file is in one of the changesets,
+    just use, e.g.,  :meth:`from_filectx` and check for ``None``.
+    """
+
+    hg_chunk = attr.ib()
+    from_ctx = attr.ib()
+    to_ctx = attr.ib()
+
+    @cached_property
+    def from_to_file_paths(self):
+        """Return a tuple of (old, new) file path for a diff chunk header
+
+        Takes case of renames into account
+        """
+        header = self.hg_chunk
+        fname = header.filename()
+        from_path, to_path = fname, fname
+        if len(header.files()) > 1:
+            # file is renamed
+            from_path, to_path = header.files()
+        return from_path, to_path
+
+    @cached_property
+    def from_filectx(self):
+        return _get_filectx(self.from_ctx, self.from_file_path)
+
+    @cached_property
+    def to_filectx(self):
+        return _get_filectx(self.to_ctx, self.to_file_path)
+
+    @property
+    def from_file_path(self):
+        return self.from_to_file_paths[0]
+
+    @property
+    def to_file_path(self):
+        return self.from_to_file_paths[1]
+
+    def from_to_blob_oids(self):
+        from_bid = to_bid = NULL_BLOB_OID
+        from_path, to_path = self.from_to_file_paths
+
+        if self.from_filectx is not None:
+            cid = self.from_ctx.hex().decode('ascii')
+            from_bid = blob_oid(None, cid, from_path)
+        if self.to_filectx is not None:
+            cid = self.to_ctx.hex().decode('ascii')
+            to_bid = blob_oid(None, cid, to_path)
+        return from_bid, to_bid
+
+    def from_to_file_mode(self):
+        from_path, to_path = self.from_to_file_paths
+        from_mode, to_mode = b'0', b'0'
+        from_filectx = self.from_filectx
+        if from_filectx is not None:
+            from_mode = FILECTX_FLAGS_TO_GIT_MODE_BYTES[from_filectx.flags()]
+        to_filectx = self.to_filectx
+        if to_filectx is not None:
+            to_mode = FILECTX_FLAGS_TO_GIT_MODE_BYTES[to_filectx.flags()]
+        return from_mode, to_mode
+
+    def additions_deletions(self):
+        """Return the pair (addition, deletions) for the chunk."""
+        adds, dels = 0, 0
+        for hunk in self.hg_chunk.hunks:
+            add_count, del_count = hunk.countchanges(hunk.hunk)
+            adds += add_count
+            dels += del_count
+        return adds, dels
+
+    def header_with_index_line(self):
+        """Generate header with the index line and binary indication.
+
+        The index line is the expected Git-style one, with file modes etc.
+        The binary indication tells whether there should be a placeholder
+        instead actual Git binary content section. Callers can use it to
+        generate the appropriate placeholder for their needs.
+        """
+        fname = self.hg_chunk.filename()
+        old_bid, new_bid = self.from_to_blob_oids()
+        indexline = ('index %s..%s' % (old_bid, new_bid)).encode('ascii')
+
+        # Note: <mode> is required only when it didn't change between
+        # the two changesets, otherwise it has a separate line
+        if self.to_filectx is not None and self.to_filectx.path() == fname:
+            oldmode, mode = self.from_to_file_mode()
+            if mode == oldmode:
+                indexline += b' ' + mode
+        indexline += b'\n'
+        headerlines = self.hg_chunk.header
+
+        binary = False
+        for index, line in enumerate(headerlines[:]):
+            m = DIFF_HUNKS_START_RX.match(line)
+            if m is None:
+                continue
+
+            binary = not bool(m.group(1))
+            headerlines.insert(index, indexline)
+            break
+        return b''.join(headerlines), binary
+
+
+def write_diff_to_file(fobj, changeset_from, changeset_to, dump_binary=True):
+    """Compute diff and stream it to a file-like object
+
+    The diff includes the expected "index line" as Git does: change of OID
+    and of permissions for each file.
+
+    :param fobj: a file-like object
+    :param dump_binary: if ``True``, the Git binary content is dumped.
+      Otherwise a placeholder is inserted, made of the file node ids. This
+      matches Gitaly's behaviour in ``GetPatchId`` implementation (quoting
+      internal/gitaly/service/diff/patch_id.go as of v16.6)::
+
+        // git-patch-id(1) will ignore binary diffs, and computing binary
+        // diffs would be expensive anyway for large blobs. This means that
+        // we must instead use the pre- and post-image blob IDs that
+        // git-diff(1) prints for binary diffs as input to git-patch-id(1),
+        // but unfortunately this is only honored in Git v2.39.0 and newer.
+        // We have no other choice than to accept this though, so we instead
+        // just ask git-diff(1) to print the full blob IDs for the pre- and
+        // post-image blobs instead of abbreviated ones so that we can avoid
+        // any kind of potential prefix collisions.
+        git.Flag{Name: "--full-index"},
+    """
+    repo = changeset_from.repo()
+    # hg diff --git --no-binary does not include the index line
+    # hg diff --git does include the index line but also dumps binary
+    #   content, which is uselessly expensive in some cases (GetPatchID)
+    # TODO generally avoid actual binary content in DiffService
+    #   when Gitaly does the same.
+    low_level_diff_opts = diff_opts(repo)
+    low_level_diff_opts.nobinary = not dump_binary
+
+    hg_chunks = changeset_to.diff(changeset_from, opts=low_level_diff_opts)
+    for hg_chunk in patchmod.parsepatch(hg_chunks):
+        chunk = Chunk(hg_chunk, changeset_from, changeset_to)
+        from_path, to_path = chunk.from_to_file_paths
+        header, binary_placeholder = chunk.header_with_index_line()
+        fobj.write(header)
+
+        if binary_placeholder:
+            filename = hg_chunk.filename()
+            fobj.write(b'--- a/%s\n' % filename)
+            fobj.write(b'+++ b/%s\n' % filename)
+            fobj.write(b'@@ -1 +1 @@\n')
+            from_bid = to_bid = NULL_BLOB_OID.encode()
+            if chunk.from_filectx is not None:
+                from_bid = chunk.from_filectx.hex()
+            if chunk.to_filectx is not None:
+                to_bid = chunk.to_filectx.hex()
+            fobj.write(b'-%s\n' % from_bid)
+            fobj.write(b'+%s\n' % to_bid)
+        else:
+            for hunk in hg_chunk.hunks:
+                hunk.write(fobj)
+
+
+def run_git_patch_id(git_path, writer):
+    """Call `git patch-id` in a subprocess
+
+    :param writer: a callable writing the diff content to a file-like object
+    """
+    git = subprocess.Popen((git_path, b'patch-id'),
+                           stdin=subprocess.PIPE,
+                           stdout=subprocess.PIPE,
+                           stderr=subprocess.PIPE)
+    writer(git.stdin)
+
+    try:
+        # this also takes care of flushing/closing stdin
+        out, err = git.communicate(timeout=GIT_PATCH_ID_TIMEOUT_SECONDS)
+    except subprocess.TimeoutExpired:
+        # Quoting https://docs.python.org/3/library/subprocess.html:
+        #   The child process is not killed if the timeout expires,
+        #   so in order to cleanup properly a well-behaved application
+        #   should kill the child process and finish communication
+        git.kill()
+        git.communicate()
+        raise
+
+    if git.returncode != 0:
+        raise RuntimeError("git-patch-id returned code %d, stderr=%r" % (
+            git.returncode, err))
+
+    return out.strip().decode('ascii')
+
+
+def git_patch_id(git_path, changeset_from, changeset_to):
+    return run_git_patch_id(
+        git_path,
+        lambda stdin: write_diff_to_file(stdin, changeset_from, changeset_to,
+                                         dump_binary=False)
+    )
diff --git a/hgitaly/git.py b/hgitaly/git.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9naXQucHk=..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9naXQucHk= 100644
--- a/hgitaly/git.py
+++ b/hgitaly/git.py
@@ -29,6 +29,12 @@
 OBJECT_MODE_NON_EXECUTABLE = 0o100644  # for blobs only
 OBJECT_MODE_TREE = 0o40000
 
+FILECTX_FLAGS_TO_GIT_MODE_BYTES = {
+    b'l': b'%o' % OBJECT_MODE_LINK,
+    b'x': b'%o' % OBJECT_MODE_EXECUTABLE,
+    b'': b'%o' % OBJECT_MODE_NON_EXECUTABLE,
+}
+
 
 class GitPathSpec:
     """File path matching as Git pathspecs do.
diff --git a/hgitaly/message.py b/hgitaly/message.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9tZXNzYWdlLnB5..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9tZXNzYWdlLnB5 100644
--- a/hgitaly/message.py
+++ b/hgitaly/message.py
@@ -142,7 +142,8 @@
 
     ctx_from = ctx.p1()
     adds = dels = files = 0
-    for stats in chunk_stats(ctx.diff(ctx_from, opts=diff_opts(ctx.repo()))):
+    chunks = ctx.diff(ctx_from, opts=diff_opts(ctx.repo()))
+    for stats in chunk_stats(chunks, ctx_from, ctx):
         adds += stats.additions
         dels += stats.deletions
         files += 1
diff --git a/hgitaly/service/diff.py b/hgitaly/service/diff.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9zZXJ2aWNlL2RpZmYucHk=..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9zZXJ2aWNlL2RpZmYucHk= 100644
--- a/hgitaly/service/diff.py
+++ b/hgitaly/service/diff.py
@@ -20,4 +20,5 @@
 )
 
 from ..diff import (
+    Chunk,
     changed_paths,
@@ -23,4 +24,3 @@
     changed_paths,
-    chunk_old_new_file_path,
     chunk_stats,
     diff_opts,
@@ -25,3 +25,4 @@
     chunk_stats,
     diff_opts,
+    git_patch_id,
 )
@@ -27,3 +28,2 @@
 )
-from ..errors import not_implemented
 from ..git import (
@@ -29,6 +29,5 @@
 from ..git import (
-    NULL_BLOB_OID,
     EMPTY_TREE_OID,
 )
 from ..logging import LoggerAdapter
 from ..oid import (
@@ -31,8 +30,7 @@
     EMPTY_TREE_OID,
 )
 from ..logging import LoggerAdapter
 from ..oid import (
-    blob_oid,
     split_chgsid_path,
 )
 from ..revision import gitlab_revision_changeset
@@ -305,9 +303,10 @@
             opts=diffopts
         )
         for header in patchmod.parsepatch(diffchunks):
-            from_path, to_path = chunk_old_new_file_path(header)
-            from_id, to_id = old_new_blob_oids(header, ctx_from, ctx_to)
-            old_mode, new_mode = old_new_file_mode(header, ctx_from, ctx_to)
+            chunk = Chunk(header, ctx_from, ctx_to)
+            from_path, to_path = chunk.from_to_file_paths
+            from_id, to_id = chunk.from_to_blob_oids()
+            old_mode, new_mode = chunk.from_to_file_mode()
             # For CommitDiffResponse, modes are returned in decimal form
             old_mode, new_mode = int(old_mode, 8), int(new_mode, 8)
 
@@ -369,10 +368,10 @@
 
         def in_deltas():
             for header in patchmod.parsepatch(diffchunks):
-                from_path, to_path = chunk_old_new_file_path(header)
-                from_id, to_id = old_new_blob_oids(header, ctx_from, ctx_to)
-                old_mode, new_mode = old_new_file_mode(header, ctx_from,
-                                                       ctx_to)
+                chunk = Chunk(header, ctx_from, ctx_to)
+                from_path, to_path = chunk.from_to_file_paths
+                from_id, to_id = chunk.from_to_blob_oids()
+                old_mode, new_mode = chunk.from_to_file_mode()
                 # For CommitDeltaResponse, modes are returned in decimal form
                 old_mode, new_mode = int(old_mode, 8), int(new_mode, 8)
                 # As per Gitaly/Git behavior, if current Delta is a Rename and
@@ -413,6 +412,7 @@
 
         # generator func to yield hunks
         def in_chunks():
-            for chunk in patchmod.parsepatch(diffchunks):
-                header = _insert_blob_index(chunk, ctx_from, ctx_to)
+            for hg_chunk in patchmod.parsepatch(diffchunks):
+                chunk = Chunk(hg_chunk, ctx_from, ctx_to)
+                header, _bin_placeholder = chunk.header_with_index_line()
                 yield header
@@ -418,5 +418,6 @@
                 yield header
-                for hunk in chunk.hunks:
+
+                for hunk in hg_chunk.hunks:
                     with BytesIO() as extracted:
                         hunk.write(extracted)
                         yield extracted.getvalue()
@@ -460,7 +461,7 @@
         diffchunks = ctx_to.diff(ctx_from, opts=diff_opts(repo))
 
         for stats in aggregate_flush_batch(
-                chunk_stats(diffchunks),
+                chunk_stats(diffchunks, ctx_from, ctx_to),
                 lambda x: 1,
                 MAX_NUM_STAT_BATCH_SIZE):
             yield DiffStatsResponse(stats=stats)
@@ -526,7 +527,31 @@
 
     def GetPatchID(self, request: GetPatchIDRequest,
                    context) -> GetPatchIDResponse:
-        not_implemented(context, issue=131)  # pragma no cover
+        repo = self.load_repo(request.repository, context)
+        old_changeset = gitlab_revision_changeset(repo, request.old_revision)
+        if old_changeset is None:
+            context.abort(StatusCode.INTERNAL,
+                          "revision %r not found" % request.old_revision)
+        new_changeset = gitlab_revision_changeset(repo, request.new_revision)
+        if new_changeset is None:
+            context.abort(StatusCode.INTERNAL,
+                          "revision %r not found" % request.new_revision)
+
+        git_path = repo.ui.config(b'hgitaly', b'git-executable')
+
+        try:
+            git_out = git_patch_id(git_path, old_changeset, new_changeset)
+        except FileNotFoundError:
+            context.abort(StatusCode.INTERNAL,
+                          "Expected Git executable not found at %r" % git_path)
+        except PermissionError:
+            context.abort(StatusCode.INTERNAL,
+                          "Expected Git executable found at %r, but it is "
+                          "not executable" % git_path)
+
+        # like Gitaly, ignoring the second hash, which is useful only
+        # in Git tree comparisons (to recall a Git commit id)
+        return GetPatchIDResponse(patch_id=git_out.split()[0])
 
 
 def fcp_resolve_commit(context, repo, revision):
@@ -571,54 +596,6 @@
     return (True, repo, ctx_from, ctx_to)
 
 
-def old_new_blob_oids(header, old_ctx, new_ctx):
-    """Return a tuple of (old, new) blob oids."""
-    old_path, new_path = chunk_old_new_file_path(header)
-    old_bid = new_bid = NULL_BLOB_OID
-    if old_path in old_ctx:
-        cid = pycompat.sysstr(old_ctx.hex())
-        old_bid = blob_oid(None, cid, old_path)
-    if new_path in new_ctx:
-        cid = pycompat.sysstr(new_ctx.hex())
-        new_bid = blob_oid(None, cid, new_path)
-    return old_bid, new_bid
-
-
-def old_new_file_mode(header, old_ctx, new_ctx):
-    """Return a tuple of (old, new) file mode."""
-    old_path, new_path = chunk_old_new_file_path(header)
-    old_mode, new_mode = b'0', b'0'
-    if old_path in old_ctx:
-        old_fctx = old_ctx[old_path]
-        old_mode = gitmode[old_fctx.flags()]
-    if new_path in new_ctx:
-        new_fctx = new_ctx[new_path]
-        new_mode = gitmode[new_fctx.flags()]
-    return old_mode, new_mode
-
-
-def _insert_blob_index(chunk, ctx_from, ctx_to):
-    fname = chunk.filename()
-    old_bid, new_bid = old_new_blob_oids(chunk, ctx_from, ctx_to)
-    indexline = 'index %s..%s' % (old_bid, new_bid)
-    indexline = pycompat.sysbytes(indexline)
-
-    # Note: <mode> is required only when it didn't change between
-    # the two changesets, otherwise it has a separate line
-    if fname in ctx_from and fname in ctx_to:
-        oldmode, mode = old_new_file_mode(chunk, ctx_from, ctx_to)
-        if mode == oldmode:
-            indexline += b' ' + mode
-    indexline += b'\n'
-    headerlines = chunk.header
-
-    for index, line in enumerate(headerlines[:]):
-        if line.startswith(b'--- '):
-            headerlines.insert(index, indexline)
-            break
-    return b''.join(headerlines)
-
-
 def _exportsingle(repo, ctx, fm, seqno, diffopts):
     """Generator method which yields a bytes stream of exporting `ctx` data.
 
diff --git a/hgitaly/service/ref.py b/hgitaly/service/ref.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9zZXJ2aWNlL3JlZi5weQ==..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9zZXJ2aWNlL3JlZi5weQ== 100644
--- a/hgitaly/service/ref.py
+++ b/hgitaly/service/ref.py
@@ -34,6 +34,13 @@
 from ..pagination import (
     extract_limit,
 )
+from ..revision import (
+    CHANGESET_HASH_BYTES_REGEXP,
+    gitlab_revision_hash,
+)
+from ..stream import (
+    chunked_limit,
+)
 from ..stub.shared_pb2 import (
     Branch,
     SortDirection,
@@ -50,6 +57,8 @@
     FindAllBranchesResponse,
     FindAllTagsRequest,
     FindAllTagsResponse,
+    FindRefsByOIDRequest,
+    FindRefsByOIDResponse,
     FindTagError,
     FindTagRequest,
     FindTagResponse,
@@ -71,8 +80,6 @@
     GetTagMessagesResponse,
     ListRefsRequest,
     ListRefsResponse,
-    FindRefsByOIDRequest,
-    FindRefsByOIDResponse,
 )
 from ..stub.ref_pb2_grpc import RefServiceServicer
 
@@ -109,6 +116,16 @@
     FindLocalBranchesRequest.SortBy.UPDATED_DESC: BranchSortBy.UPDATED_DESC
 }
 
+FIND_REFS_BY_OID_SORT_FIELD_TO_LIST_REFS = dict(
+    refname=ListRefsRequest.SortBy.REFNAME,
+    creatordate=ListRefsRequest.SortBy.CREATORDATE,
+    authordate=ListRefsRequest.SortBy.AUTHORDATE,
+)
+"""keys are amenable fields in git-for-each-ref(1)
+
+Not many such keys actually map to a SortBy value for ListRefsRequest.
+"""
+
 
 class RefServicer(RefServiceServicer, HGitalyServicer):
     """RefService implementation.
@@ -396,11 +413,15 @@
         raise NotImplementedError(
             "Not relevant for Mercurial")  # pragma: no cover
 
-    def ListRefs(self, request: ListRefsRequest,
-                 context) -> ListRefsResponse:
+    def iter_refs(self, repo, request: ListRefsRequest, context):
+        """"Generator yielding refernece names and target shas.
+
+        For very direct use in ListRefs and similar.
+        """
+
         # The only options actually in use as of GitLab 14.8
         # are head=true and default sort (client is `gitaly-backup`).
         if (request.sort_by.key != ListRefsRequest.SortBy.Key.REFNAME
                 or request.sort_by.direction != SortDirection.ASCENDING):
             not_implemented(context, issue=97)  # pragma no cover
 
@@ -401,11 +422,9 @@
         # The only options actually in use as of GitLab 14.8
         # are head=true and default sort (client is `gitaly-backup`).
         if (request.sort_by.key != ListRefsRequest.SortBy.Key.REFNAME
                 or request.sort_by.direction != SortDirection.ASCENDING):
             not_implemented(context, issue=97)  # pragma no cover
 
-        Reference = ListRefsResponse.Reference
-        repo = self.load_repo(request.repository, context)
         patterns = request.patterns
 
         refs = []
@@ -470,9 +489,16 @@
         refs.sort()
 
         for chunk in chunked(refs):
+            yield chunk
+
+    def ListRefs(self, request: ListRefsRequest,
+                 context) -> ListRefsResponse:
+        repo = self.load_repo(request.repository, context)
+        Reference = ListRefsResponse.Reference
+        for chunk in self.iter_refs(repo, request, context):
             yield ListRefsResponse(
                 references=(Reference(name=name, target=target)
                             for name, target in chunk))
 
     def FindRefsByOID(self, request: FindRefsByOIDRequest,
                       context) -> FindRefsByOIDResponse:
@@ -473,10 +499,41 @@
             yield ListRefsResponse(
                 references=(Reference(name=name, target=target)
                             for name, target in chunk))
 
     def FindRefsByOID(self, request: FindRefsByOIDRequest,
                       context) -> FindRefsByOIDResponse:
-        not_implemented(context, issue=89)  # pragma no cover
+        repo = self.load_repo(request.repository, context)
+        oid = request.oid.encode('utf8')
+        if not CHANGESET_HASH_BYTES_REGEXP.match(oid):
+            # a bit wider than needed, would probably be more
+            # efficient to query nodemap directly, but we'll consider this
+            # the day we reimplement in RHGitaly
+            oid = gitlab_revision_hash(repo, oid)
+        sort_by = FIND_REFS_BY_OID_SORT_FIELD_TO_LIST_REFS.get(
+            request.sort_field, ListRefsRequest.SortBy.REFNAME)
+
+        patterns = request.ref_patterns
+        if patterns:
+            patterns = [pat.encode('utf8') for pat in patterns]
+        else:
+            patterns = [b'refs/heads/', b'refs/tags/']
+
+        limit = request.limit
+        if limit == 0:  # said to mean no limit in protocol comment
+            limit = None
+
+        refs = []
+        for chunk in chunked_limit(self.iter_refs(
+                repo,
+                ListRefsRequest(
+                    repository=request.repository,
+                    pointing_at_oids=[oid],
+                    patterns=patterns,
+                    sort_by=ListRefsRequest.SortBy(key=sort_by),
+                ),
+                context), limit):
+            refs.extend(name for name, _tgt in chunk)
+        return FindRefsByOIDResponse(refs=refs)
 
 
 def gitlab_tag_from_ref(ref):
diff --git a/hgitaly/service/tests/test_diff.py b/hgitaly/service/tests/test_diff.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9zZXJ2aWNlL3Rlc3RzL3Rlc3RfZGlmZi5weQ==..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9zZXJ2aWNlL3Rlc3RzL3Rlc3RfZGlmZi5weQ== 100644
--- a/hgitaly/service/tests/test_diff.py
+++ b/hgitaly/service/tests/test_diff.py
@@ -21,6 +21,7 @@
     CommitDiffRequest,
     ChangedPaths,
     DiffStatsRequest,
+    GetPatchIDRequest,
     RawDiffRequest,
     RawPatchRequest,
     FindChangedPathsRequest,
@@ -32,6 +33,8 @@
 
 from .fixture import ServiceFixture
 
+StatusCode = grpc.StatusCode
+
 
 class DiffFixture(ServiceFixture):
 
@@ -81,6 +84,13 @@
                                       left_cid, right_cid,
                                       **kwargs)
 
+    def get_patch_id(self, old_revision, new_revision, **kwargs):
+        kwargs.setdefault('repository', self.grpc_repo)
+        return self.stub.GetPatchID(GetPatchIDRequest(
+            old_revision=old_revision,
+            new_revision=new_revision,
+            **kwargs)).patch_id
+
     def commit_delta(self, left_cid, right_cid, **kwargs):
         return self.commit_ids_method(self.stub.CommitDelta,
                                       CommitDeltaRequest,
@@ -674,3 +684,60 @@
     assert diff_fixture.find_changed_paths_tree(sub0_oid, sub1_oid) == {
         b'baz': [(ChangedPaths.Status.COPIED, None)],
     }
+
+
+def test_get_patch_id(diff_fixture):
+    wrapper = diff_fixture.repo_wrapper
+    patch_id = diff_fixture.get_patch_id
+
+    hex0 = wrapper.commit_file('regular', content='foo\n').hex()
+    hex1 = wrapper.commit_file('regular', content='bar\n').hex()
+    hex2 = wrapper.commit_file(
+        'foo.gz',
+        content=b'\x1f\x8b\x08\x08\xd6Q\xc1e\x00\x03foo\x00K'
+        b'\xcb\xcf\xe7\x02\x00\xa8e2~\x04\x00\x00\x00',
+        message='gzipped file is easy binary').hex()
+    hex3 = wrapper.commit_file(
+        'foo.gz',
+        content=b"\x1f\x8b\x08\x088=\xc2e\x00"
+        b"\x03foo\x00K\xcb7\xe2\x02\x00\xb1F'q\x04\x00\x00\x00",
+        message='gzipped file is easy binary').hex()
+
+    patch_id1 = patch_id(hex0, hex1)
+    patch_id2 = patch_id(hex1, hex2)
+    patch_id3 = patch_id(hex2, hex3)
+
+    # symbolic revisions work
+    assert patch_id(hex2, b'branch/default') == patch_id3
+
+    assert len(set((patch_id1, patch_id3, patch_id2))) == 3
+
+    #
+    # error cases
+    #
+
+    # Git not found
+    wrapper.write_hgrc(dict(hgitaly={'git-executable': '/does/not/exist'}))
+    with pytest.raises(grpc.RpcError) as exc_info:
+        patch_id(hex0, hex1)
+    exc = exc_info.value
+    assert exc.code() == StatusCode.INTERNAL
+    assert 'not found' in exc.details()
+
+    # Git found, but not executable
+    wrapper.write_hgrc(dict(
+        hgitaly={'git-executable': str(wrapper.path / 'regular')}))
+    with pytest.raises(grpc.RpcError) as exc_info:
+        patch_id(hex0, hex1)
+    exc = exc_info.value
+    assert exc.code() == StatusCode.INTERNAL
+    assert 'not executable' in exc.details()
+
+    # Unknown revisions
+    with pytest.raises(grpc.RpcError) as exc_info:
+        patch_id(b'unknown', hex1)
+    assert exc_info.value.code() == StatusCode.INTERNAL
+
+    with pytest.raises(grpc.RpcError) as exc_info:
+        patch_id(hex0, b'unknown')
+    assert exc_info.value.code() == StatusCode.INTERNAL
diff --git a/hgitaly/service/tests/test_ref.py b/hgitaly/service/tests/test_ref.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9zZXJ2aWNlL3Rlc3RzL3Rlc3RfcmVmLnB5..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9zZXJ2aWNlL3Rlc3RzL3Rlc3RfcmVmLnB5 100644
--- a/hgitaly/service/tests/test_ref.py
+++ b/hgitaly/service/tests/test_ref.py
@@ -50,6 +50,7 @@
     ListRefsRequest,
     ListTagNamesContainingCommitRequest,
     GetTagMessagesRequest,
+    FindRefsByOIDRequest,
     FindTagError,
     FindTagRequest,
 )
@@ -89,6 +90,10 @@
                                         **kw))
                 for ref in resp.references]
 
+    def find_refs_by_oid(self, **kw):
+        kw.setdefault('repository', self.grpc_repo)
+        return self.stub.FindRefsByOID(FindRefsByOIDRequest(**kw)).refs
+
 
 @pytest.fixture
 def ref_fixture(grpc_channel, server_repos_root):
@@ -571,3 +576,21 @@
         (b'HEAD', sha1),
         (b'refs/environments/17', sha2)
     ]
+
+    # reusing the fixture for the FindRefsByOID sibling method
+    sha1 = ctx1.hex().decode()
+    for oid in (sha1, sha1[:10]):
+        assert fixture.find_refs_by_oid(oid=oid) == [
+            'refs/heads/branch/default',
+            'refs/tags/v1.4'
+        ]
+    assert fixture.find_refs_by_oid(oid=sha1, limit=1) == [
+        'refs/heads/branch/default',
+    ]
+    assert fixture.find_refs_by_oid(oid=sha1, ref_patterns=['refs/tags/']) == [
+        'refs/tags/v1.4',
+    ]
+    # TODO test 'creatordate' when implemented in ListRefs
+    assert fixture.find_refs_by_oid(
+        oid=sha1, sort_field='refname', limit=1
+    ) == ['refs/heads/branch/default']
diff --git a/hgitaly/stream.py b/hgitaly/stream.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS9zdHJlYW0ucHk=..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS9zdHJlYW0ucHk= 100644
--- a/hgitaly/stream.py
+++ b/hgitaly/stream.py
@@ -88,6 +88,24 @@
     yield batch
 
 
+def chunked_limit(itr, limit):
+    """Wrap an iterator of lists to limit the total number of yielded items.
+
+    The iterator is not called at all once the limit has been reached.
+    This can be a performance advantage.
+
+    :param limit: if ``None``, no limiting occurs, otherwise the total
+      number of wished items
+    """
+    for chunk in itr:
+        if limit is not None:
+            if not limit:
+                break
+            chunk = chunk[:limit]
+            limit = limit - len(chunk)
+        yield chunk
+
+
 def parse_int(s):
     """Parse integer string representations, as Golangs `strconf.ParseInt`
 
diff --git a/hgitaly/tests/test_diff.py b/hgitaly/tests/test_diff.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS90ZXN0cy90ZXN0X2RpZmYucHk=..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS90ZXN0cy90ZXN0X2RpZmYucHk= 100644
--- a/hgitaly/tests/test_diff.py
+++ b/hgitaly/tests/test_diff.py
@@ -5,6 +5,25 @@
 #
 # SPDX-License-Identifier: GPL-2.0-or-later
 
+from io import BytesIO
+import os
+import subprocess
+
+import pytest
+
+from heptapod.testhelpers import (
+    LocalRepoWrapper,
+)
+
+from .. import diff as hgitaly_diff
+from ..diff import (
+    git_patch_id,
+    run_git_patch_id,
+    write_diff_to_file,
+)
+from ..tests.common import (
+    MINIMAL_HG_CONFIG,
+)
 from hgitaly.service.diff import (
     CurrDiff,
     Limits,
@@ -15,3 +34,82 @@
 def test_parser_corner_cases():
     parser = Parser(Limits(), CurrDiff())
     parser.parse([b""])
+
+
+def diff_lines(cs_from, cs_to, **kw):
+    out = BytesIO()
+    write_diff_to_file(out, cs_from, cs_to, **kw)
+    return out.getvalue().splitlines()
+
+
+def test_git_patch_id(tmpdir):
+    wrapper = LocalRepoWrapper.init(tmpdir, config=MINIMAL_HG_CONFIG)
+
+    wrapper.commit_file('regular', content='foo\n')
+    regular = (tmpdir / 'regular')
+    regular.write('bar\n')
+
+    script = tmpdir / 'script'
+    script.write('#!/usr/bin/env python2\n'
+                 'print "Hello, world"\n')
+    script.chmod(0o755)
+    cs1 = wrapper.commit([], add_remove=True)
+    cs2 = wrapper.commit_file(
+        'foo.gz',
+        content=b'\x1f\x8b\x08\x08\xd6Q\xc1e\x00\x03foo\x00K'
+        b'\xcb\xcf\xe7\x02\x00\xa8e2~\x04\x00\x00\x00',
+        message='gzipped file is easy binary')
+
+    assert diff_lines(cs1, cs2, dump_binary=False) == [
+        b'diff --git a/foo.gz b/foo.gz',
+        b'new file mode 100644',
+        (b'index 0000000000000000000000000000000000000000..'
+         b'%s_Zm9vLmd6' % cs2.hex()),
+        b'Binary file foo.gz has changed',
+        b'--- a/foo.gz',
+        b'+++ b/foo.gz',
+        b'@@ -1 +1 @@',
+        b'-0000000000000000000000000000000000000000',
+        b'+' + cs2[b'foo.gz'].hex(),
+    ]
+
+    patch_id2 = git_patch_id('git', cs1, cs2)
+
+    cs3 = wrapper.commit_file(
+        'foo.gz',
+        content=b"\x1f\x8b\x08\x088=\xc2e\x00"
+        b"\x03foo\x00K\xcb7\xe2\x02\x00\xb1F'q\x04\x00\x00\x00",
+        message='gzipped file is easy binary')
+    patch_id3 = git_patch_id('git', cs2, cs3)
+    assert patch_id3 != patch_id2
+
+    cs4 = wrapper.commit_file(
+        'foo.gz',
+        content=b"\x1f\x8b\x08\x08\xf2=\xc2e\x00"
+        b"\x03foo\x00K\xcb7\xe6\x02\x00\xf0w<h\x04\x00\x00\x00",
+        message='gzipped file is easy binary')
+    patch_id4 = git_patch_id('git', cs3, cs4)
+    assert len(set((patch_id4, patch_id3, patch_id2))) == 3
+
+
+def test_run_git_patch_id_errors(tmpdir, monkeypatch):
+    fake_git = tmpdir / 'git'
+    fake_git_path = os.fsencode(str(fake_git))
+    fake_git.write_text('\n'.join(('#!/usr/bin/env python3',
+                                   'import sys',
+                                   'import time',
+                                   'timeout = sys.stdin.read()',
+                                   'if not timeout:'
+                                   '  sys.exit(21)',
+                                   'time.sleep(float(timeout))',
+                                   )),
+                        encoding='ascii')
+    fake_git.chmod(0o755)
+    with pytest.raises(RuntimeError) as exc_info:
+        run_git_patch_id(fake_git_path, lambda stdin: None)
+    assert 'code 2' in exc_info.value.args[0]
+
+    monkeypatch.setattr(hgitaly_diff, 'GIT_PATCH_ID_TIMEOUT_SECONDS', 0.001)
+
+    with pytest.raises(subprocess.TimeoutExpired) as exc_info:
+        run_git_patch_id(fake_git_path, lambda stdin: stdin.write(b'0.01'))
diff --git a/hgitaly/tests/test_stream.py b/hgitaly/tests/test_stream.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_aGdpdGFseS90ZXN0cy90ZXN0X3N0cmVhbS5weQ==..9333633b4fb4fc6e84cb0ee30b22fed537d15879_aGdpdGFseS90ZXN0cy90ZXN0X3N0cmVhbS5weQ== 100644
--- a/hgitaly/tests/test_stream.py
+++ b/hgitaly/tests/test_stream.py
@@ -56,3 +56,13 @@
     data = stream.split_batches(in_data, max_size)
     data = list(data)
     assert data == [b'AA', b'BB', b'CC', b'DD']
+
+
+def test_chunked_limit():
+    chunks = ['AA', 'BB', 'CC']
+    limit = stream.chunked_limit
+    assert list(limit(chunks, 0)) == []
+    assert list(limit(chunks, None)) == chunks
+    assert list(limit(chunks, 1)) == ['A']
+    assert list(limit(chunks, 2)) == ['AA']
+    assert list(limit(chunks, 3)) == ['AA', 'B']
diff --git a/rust/mercurial.rev b/rust/mercurial.rev
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_cnVzdC9tZXJjdXJpYWwucmV2..9333633b4fb4fc6e84cb0ee30b22fed537d15879_cnVzdC9tZXJjdXJpYWwucmV2 100644
--- a/rust/mercurial.rev
+++ b/rust/mercurial.rev
@@ -1,1 +1,1 @@
-71bd09bebbe36a09569cbfb388f371433360056b
+3fd1efb3ad124e6686c0fb66e6943cd8aeea5681
diff --git a/tests_with_gitaly/test_diff.py b/tests_with_gitaly/test_diff.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_dGVzdHNfd2l0aF9naXRhbHkvdGVzdF9kaWZmLnB5..9333633b4fb4fc6e84cb0ee30b22fed537d15879_dGVzdHNfd2l0aF9naXRhbHkvdGVzdF9kaWZmLnB5 100644
--- a/tests_with_gitaly/test_diff.py
+++ b/tests_with_gitaly/test_diff.py
@@ -21,6 +21,7 @@
     CommitDiffRequest,
     DiffStatsRequest,
     FindChangedPathsRequest,
+    GetPatchIDRequest,
     RawDiffRequest,
     RawPatchRequest,
 )
@@ -692,3 +693,35 @@
             ==
             do_rpc_commits('hg', [hg_sha4], compare_to=[ctx0.hex()])
             )
+
+
+def test_compare_get_patch_id(gitaly_comparison):
+    fixture = gitaly_comparison
+    wrapper = fixture.hg_repo_wrapper
+    rpc_helper = fixture.rpc_helper(
+        stub_cls=DiffServiceStub,
+        method_name='GetPatchID',
+        request_cls=GetPatchIDRequest,
+        request_sha_attrs=['old_revision', 'new_revision'],
+    )
+    assert_compare = rpc_helper.assert_compare
+    assert_compare_errors = rpc_helper.assert_compare_errors
+
+    hex0 = wrapper.commit_file('regular', content='foo\n').hex()
+    hex1 = wrapper.commit_file('regular', content='bar\n', topic='bar').hex()
+
+    gl_branch = b'branch/default'
+    gl_topic = b'topic/default/bar'
+
+    # regular operation with symbolic or sha revisions
+    assert_compare(old_revision=gl_branch, new_revision=gl_topic)
+    assert_compare(old_revision=hex0, new_revision=gl_topic)
+    assert_compare(old_revision=gl_branch, new_revision=hex1)
+
+    # errors on unknown revisions (for now INTERNAL with Gitaly, highly
+    # probable that it will become something else and that details really
+    # do not matter)
+    assert_compare_errors(old_revision=gl_branch, new_revision=b'unknown',
+                          same_details=False)
+    assert_compare_errors(old_revision=b'unknown', new_revision=gl_branch,
+                          same_details=False)
diff --git a/tests_with_gitaly/test_ref.py b/tests_with_gitaly/test_ref.py
index 4f4e599f10678bf801a4e08eee4a232869e97ba1_dGVzdHNfd2l0aF9naXRhbHkvdGVzdF9yZWYucHk=..9333633b4fb4fc6e84cb0ee30b22fed537d15879_dGVzdHNfd2l0aF9naXRhbHkvdGVzdF9yZWYucHk= 100644
--- a/tests_with_gitaly/test_ref.py
+++ b/tests_with_gitaly/test_ref.py
@@ -18,6 +18,7 @@
 from hgitaly.stub.ref_pb2 import (
     FindBranchRequest,
     FindDefaultBranchNameRequest,
+    FindRefsByOIDRequest,
     FindTagError,
     FindTagRequest,
     FindLocalBranchesRequest,
@@ -423,3 +424,35 @@
             rpc_helper.assert_compare(pointing_at_oids=[hg_shas[i]],
                                       patterns=[b'refs/tags/'],
                                       head=head)
+
+    # FindRefsByOID is almost a subset of ListRefs, the only stated
+    # thing that ListRefs would not do is accepting oids by prefix.
+    # So we'll use the same setup
+
+    rpc_helper = fixture.rpc_helper(
+        stub_cls=RefServiceStub,
+        method_name='FindRefsByOID',
+        request_cls=FindRefsByOIDRequest,
+        request_defaults=dict(ref_patterns=["refs/"]),
+        request_sha_attrs=['oid'],
+    )
+
+    hg_short_shas = [sha[:12] for sha in hg_shas]
+    git_short_shas = [rpc_helper.hg2git(sha)[:12] for sha in hg_shas]
+    fixture.hg_git._map_hg.update(zip(hg_short_shas, git_short_shas))
+
+    # no need to proceed further if this fails:
+    assert rpc_helper.hg2git(hg_short_shas[1]) == git_short_shas[1]
+
+    for hg_sha in hg_shas + hg_short_shas:
+        for patterns in (
+                ['refs/tags/'],
+                ['refs/heads/'],
+                [],
+                ):
+            rpc_helper.assert_compare(oid=hg_sha.decode(),
+                                      ref_patterns=patterns)
+
+    # hg_shas[1] has two refs (a branch and a tag)
+    for limit in (0, 1, 2):
+        rpc_helper.assert_compare(oid=hg_shas[1].decode(), limit=limit)