diff --git a/hgitaly/service/mercurial_operations.py b/hgitaly/service/mercurial_operations.py
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_aGdpdGFseS9zZXJ2aWNlL21lcmN1cmlhbF9vcGVyYXRpb25zLnB5..20e3404f3141b9ff06a8ace0b039dee6beea96ec_aGdpdGFseS9zZXJ2aWNlL21lcmN1cmlhbF9vcGVyYXRpb25zLnB5 100644
--- a/hgitaly/service/mercurial_operations.py
+++ b/hgitaly/service/mercurial_operations.py
@@ -6,6 +6,7 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 from grpc import StatusCode
 import logging
+import os
 import time
 
 from mercurial.merge import merge
@@ -39,6 +40,11 @@
     gitlab_revision_changeset,
     validate_oid,
 )
+from ..workdir import (
+    ClientMismatch,
+    reserve_prepare_workdir,
+    release_workdir_by_id,
+)
 from ..stub.errors_pb2 import (
     MergeConflictError,
     ReferenceUpdateError,
@@ -56,6 +62,10 @@
     PublishChangesetError,
     PublishChangesetRequest,
     PublishChangesetResponse,
+    ReleaseWorkingDirectoryRequest,
+    ReleaseWorkingDirectoryResponse,
+    GetWorkingDirectoryRequest,
+    GetWorkingDirectoryResponse,
 )
 from ..stub.mercurial_operations_pb2_grpc import (
     MercurialOperationsServiceServicer,
@@ -85,6 +95,45 @@
     The ordering of methods in this source file is the same as in the proto
     file.
     """
+    def GetWorkingDirectory(self,
+                            request: GetWorkingDirectoryRequest,
+                            context) -> GetWorkingDirectoryResponse:
+        gl_repo = request.repository
+        rev = request.revision
+        repo = self.load_repo(gl_repo, context)
+        workdirs_root = self.repo_workdirs_root(gl_repo, context)
+        if rev:
+            changeset = gitlab_revision_changeset(repo, rev)
+            if changeset is None:
+                context.abort(StatusCode.NOT_FOUND, "Revision not found")
+        else:
+            changeset = None
+
+        wd = reserve_prepare_workdir(workdirs_root, repo,
+                                     client_id=request.client_id,
+                                     incarnation_id=request.incarnation_id,
+                                     changeset=changeset)
+        repos_root = os.fsdecode(self.storages[gl_repo.storage_name])
+        wd_rpath = str(wd.path.relative_to(repos_root))
+
+        return GetWorkingDirectoryResponse(working_directory_id=wd.id,
+                                           relative_path=wd_rpath)
+
+    def ReleaseWorkingDirectory(self,
+                                request: ReleaseWorkingDirectoryRequest,
+                                context) -> ReleaseWorkingDirectoryResponse:
+        gl_repo = request.repository
+        repo = self.load_repo(gl_repo, context)
+        wd_id = request.working_directory_id
+        try:
+            release_workdir_by_id(repo, wd_id, request.client_id)
+        except ClientMismatch:
+            context.abort(
+                StatusCode.PERMISSION_DENIED,
+                f"Not the owner of the lease on working directory {wd_id}"
+            )
+        return ReleaseWorkingDirectoryResponse()
+
     def MergeAnalysis(self,
                       request: MergeAnalysisRequest,
                       context) -> MergeAnalysisResponse:
diff --git a/hgitaly/service/tests/test_mercurial_operations.py b/hgitaly/service/tests/test_mercurial_operations.py
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_aGdpdGFseS9zZXJ2aWNlL3Rlc3RzL3Rlc3RfbWVyY3VyaWFsX29wZXJhdGlvbnMucHk=..20e3404f3141b9ff06a8ace0b039dee6beea96ec_aGdpdGFseS9zZXJ2aWNlL3Rlc3RzL3Rlc3RfbWVyY3VyaWFsX29wZXJhdGlvbnMucHk= 100644
--- a/hgitaly/service/tests/test_mercurial_operations.py
+++ b/hgitaly/service/tests/test_mercurial_operations.py
@@ -9,8 +9,9 @@
     StatusCode,
 )
 import pytest
+import uuid
 
 from mercurial import (
     error as hg_error,
     phases,
 )
@@ -12,8 +13,9 @@
 
 from mercurial import (
     error as hg_error,
     phases,
 )
+from mercurial_testhelpers import RepoWrapper
 
 from google.protobuf.timestamp_pb2 import Timestamp
 
@@ -25,6 +27,10 @@
 from hgitaly.errors import (
     parse_assert_structured_error,
 )
+from hgitaly.identification import INCARNATION_ID
+from hgitaly.servicer import (
+    PY_HEPTAPOD_SKIP_HOOKS,
+)
 from hgitaly.stub.errors_pb2 import (
     ReferenceUpdateError,
 )
@@ -37,6 +43,8 @@
     PublishChangesetRequest,
     MergeAnalysisRequest,
     MergeAnalysisResponse,
+    GetWorkingDirectoryRequest,
+    ReleaseWorkingDirectoryRequest,
 )
 from hgitaly.stub.mercurial_operations_pb2_grpc import (
     MercurialOperationsServiceStub,
@@ -54,6 +62,8 @@
 
     stub_cls = MercurialOperationsServiceStub
 
+    client_id = str(uuid.uuid4())
+
     def merge_analysis(self, **kw):
         return self.stub.MergeAnalysis(MergeAnalysisRequest(
             repository=self.grpc_repo, **kw))
@@ -76,6 +86,19 @@
         return self.stub.MergeBranch(MergeBranchRequest(**kw),
                                      metadata=self.grpc_metadata())
 
+    def get_workdir(self, **kw):
+        kw.setdefault('client_id', self.client_id)
+        kw.setdefault('incarnation_id', INCARNATION_ID)
+        kw.setdefault('repository', self.grpc_repo)
+        return self.stub.GetWorkingDirectory(
+            GetWorkingDirectoryRequest(**kw))
+
+    def release_workdir(self, **kw):
+        kw.setdefault('client_id', self.client_id)
+        kw.setdefault('repository', self.grpc_repo)
+        return self.stub.ReleaseWorkingDirectory(
+            ReleaseWorkingDirectoryRequest(**kw))
+
 
 @pytest.fixture
 def operations_fixture(grpc_channel, server_repos_root):
@@ -603,3 +626,47 @@
     assert exc.code() == StatusCode.INVALID_ARGUMENT
     assert 'changeset' in exc.details().lower()
     assert 'not found' in exc.details().lower()
+
+
+def test_working_directories(operations_fixture, server_repos_root):
+    fixture = operations_fixture
+    gl_rev = b'branch/default'
+    wrapper = fixture.repo_wrapper
+    cs0 = wrapper.commit_file('foo')
+
+    resp = fixture.get_workdir(revision=gl_rev)
+    wd_id = resp.working_directory_id
+    wd_path = server_repos_root / 'default' / resp.relative_path
+
+    # let us check that the working directory works by committing a file
+    # in there and retrieve it from the main repo
+    wd_wrapper = RepoWrapper.load(wd_path)
+    wd_wrapper.repo.ui.environ[PY_HEPTAPOD_SKIP_HOOKS] = b'yes'
+    cs_done_in_wd = wd_wrapper.commit_file('bar', "done in wd")
+    wrapper.reload()
+    cs_in_main = wrapper.repo[cs_done_in_wd.hex()]
+    assert cs_in_main.description() == b'done in wd'
+    assert cs_in_main.p1() == cs0
+
+    # releasing without being the correct client
+    with pytest.raises(RpcError) as exc_info:
+        fixture.release_workdir(working_directory_id=wd_id,
+                                client_id='some other client')
+    exc = exc_info.value
+    assert exc.code() == StatusCode.PERMISSION_DENIED
+
+    wd_id2 = fixture.get_workdir(revision=gl_rev).working_directory_id
+    assert wd_id2 != wd_id
+
+    # releasing for good
+    fixture.release_workdir(working_directory_id=wd_id)
+
+    # reuse of first workdir (also tests that no revison ends up selecting
+    # the same Mercurial branch as `branch/default`, hence `default`
+    assert fixture.get_workdir().working_directory_id == wd_id
+
+    # unknown revision
+    with pytest.raises(RpcError) as exc_info:
+        fixture.get_workdir(revision=b'ca34fe12')
+    exc = exc_info.value
+    assert exc.code() == StatusCode.NOT_FOUND
diff --git a/hgitaly/stub/mercurial_operations_pb2.py b/hgitaly/stub/mercurial_operations_pb2.py
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_aGdpdGFseS9zdHViL21lcmN1cmlhbF9vcGVyYXRpb25zX3BiMi5weQ==..20e3404f3141b9ff06a8ace0b039dee6beea96ec_aGdpdGFseS9zdHViL21lcmN1cmlhbF9vcGVyYXRpb25zX3BiMi5weQ== 100644
--- a/hgitaly/stub/mercurial_operations_pb2.py
+++ b/hgitaly/stub/mercurial_operations_pb2.py
@@ -19,7 +19,7 @@
 from . import operations_pb2 as operations__pb2
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1amercurial-operations.proto\x12\x07hgitaly\x1a\x0c\x65rrors.proto\x1a\nlint.proto\x1a\x0cshared.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x10operations.proto\"\x94\x01\n\x14MergeAnalysisRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x17\n\x0fsource_revision\x18\x02 \x01(\x0c\x12\x17\n\x0ftarget_revision\x18\x03 \x01(\x0c\x12\x1c\n\x14skip_conflicts_check\x18\x04 \x01(\x08\"\xad\x02\n\x15MergeAnalysisResponse\x12\x17\n\x0fis_fast_forward\x18\x01 \x01(\x08\x12\x1f\n\x17has_obsolete_changesets\x18\x02 \x01(\x08\x12\x1f\n\x17has_unstable_changesets\x18\x03 \x01(\x08\x12\x15\n\rhas_conflicts\x18\x04 \x01(\x08\x12\x18\n\x10target_is_public\x18\x05 \x01(\x08\x12\x16\n\x0etarget_node_id\x18\x06 \x01(\t\x12\x15\n\rtarget_branch\x18\x07 \x01(\x0c\x12\x14\n\x0ctarget_topic\x18\x08 \x01(\x0c\x12\x16\n\x0esource_node_id\x18\t \x01(\t\x12\x15\n\rsource_branch\x18\n \x01(\x0c\x12\x14\n\x0csource_topic\x18\x0b \x01(\x0c\"\xad\x01\n\x17PublishChangesetRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x1a\n\x04user\x18\x02 \x01(\x0b\x32\x0c.gitaly.User\x12/\n\x08hg_perms\x18\x03 \x01(\x0e\x32\x1d.hgitaly.MercurialPermissions\x12\x17\n\x0fgitlab_revision\x18\x04 \x01(\x0c\"\x1a\n\x18PublishChangesetResponse\"P\n\x15PublishChangesetError\x12.\n\x0bgitlab_hook\x18\x01 \x01(\x0b\x32\x17.gitaly.CustomHookErrorH\x00\x42\x07\n\x05\x65rror\"\xa1\x02\n\x12MergeBranchRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x1a\n\x04user\x18\x02 \x01(\x0b\x32\x0c.gitaly.User\x12/\n\x08hg_perms\x18\x03 \x01(\x0e\x32\x1d.hgitaly.MercurialPermissions\x12\x11\n\tcommit_id\x18\x04 \x01(\t\x12\x0e\n\x06\x62ranch\x18\x05 \x01(\x0c\x12\x0f\n\x07message\x18\x06 \x01(\x0c\x12-\n\ttimestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x18\n\x10\x65xpected_old_oid\x18\x08 \x01(\t\x12\x13\n\x0bsemi_linear\x18\t \x01(\x08\"K\n\x13MergeBranchResponse\x12\x34\n\rbranch_update\x18\x01 \x01(\x0b\x32\x1d.gitaly.OperationBranchUpdate\"\xe7\x01\n\x10MergeBranchError\x12.\n\x0bgitlab_hook\x18\x01 \x01(\x0b\x32\x17.gitaly.CustomHookErrorH\x00\x12.\n\x08\x63onflict\x18\x02 \x01(\x0b\x32\x1a.gitaly.MergeConflictErrorH\x00\x12\x37\n\x0freference_check\x18\x03 \x01(\x0b\x32\x1c.gitaly.ReferenceUpdateErrorH\x00\x12\x31\n\tpre_check\x18\x04 \x01(\x0e\x32\x1c.hgitaly.PreCheckUpdateErrorH\x00\x42\x07\n\x05\x65rror\"\x9a\x01\n\rCensorRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x1a\n\x04user\x18\x02 \x01(\x0b\x32\x0c.gitaly.User\x12\x19\n\x11\x63hangeset_node_id\x18\x03 \x01(\t\x12\x11\n\tfile_path\x18\x04 \x01(\x0c\x12\x11\n\ttombstone\x18\x05 \x01(\x0c\"\x10\n\x0e\x43\x65nsorResponse*8\n\x14MercurialPermissions\x12\x08\n\x04READ\x10\x00\x12\t\n\x05WRITE\x10\x01\x12\x0b\n\x07PUBLISH\x10\x02*k\n\x13PreCheckUpdateError\x12\x0e\n\nNO_PROBLEM\x10\x00\x12\x14\n\x10NOT_FAST_FORWARD\x10\x01\x12\x16\n\x12OBSOLETE_CHANGESET\x10\x02\x12\x16\n\x12UNSTABLE_CHANGESET\x10\x03\x32\xea\x02\n\x1aMercurialOperationsService\x12V\n\rMergeAnalysis\x12\x1d.hgitaly.MergeAnalysisRequest\x1a\x1e.hgitaly.MergeAnalysisResponse\"\x06\xfa\x97(\x02\x08\x02\x12_\n\x10PublishChangeset\x12 .hgitaly.PublishChangesetRequest\x1a!.hgitaly.PublishChangesetResponse\"\x06\xfa\x97(\x02\x08\x01\x12\x41\n\x06\x43\x65nsor\x12\x16.hgitaly.CensorRequest\x1a\x17.hgitaly.CensorResponse\"\x06\xfa\x97(\x02\x08\x01\x12P\n\x0bMergeBranch\x12\x1b.hgitaly.MergeBranchRequest\x1a\x1c.hgitaly.MergeBranchResponse\"\x06\xfa\x97(\x02\x08\x01\x62\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1amercurial-operations.proto\x12\x07hgitaly\x1a\x0c\x65rrors.proto\x1a\nlint.proto\x1a\x0cshared.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x10operations.proto\"\x94\x01\n\x14MergeAnalysisRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x17\n\x0fsource_revision\x18\x02 \x01(\x0c\x12\x17\n\x0ftarget_revision\x18\x03 \x01(\x0c\x12\x1c\n\x14skip_conflicts_check\x18\x04 \x01(\x08\"\xad\x02\n\x15MergeAnalysisResponse\x12\x17\n\x0fis_fast_forward\x18\x01 \x01(\x08\x12\x1f\n\x17has_obsolete_changesets\x18\x02 \x01(\x08\x12\x1f\n\x17has_unstable_changesets\x18\x03 \x01(\x08\x12\x15\n\rhas_conflicts\x18\x04 \x01(\x08\x12\x18\n\x10target_is_public\x18\x05 \x01(\x08\x12\x16\n\x0etarget_node_id\x18\x06 \x01(\t\x12\x15\n\rtarget_branch\x18\x07 \x01(\x0c\x12\x14\n\x0ctarget_topic\x18\x08 \x01(\x0c\x12\x16\n\x0esource_node_id\x18\t \x01(\t\x12\x15\n\rsource_branch\x18\n \x01(\x0c\x12\x14\n\x0csource_topic\x18\x0b \x01(\x0c\"\xad\x01\n\x17PublishChangesetRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x1a\n\x04user\x18\x02 \x01(\x0b\x32\x0c.gitaly.User\x12/\n\x08hg_perms\x18\x03 \x01(\x0e\x32\x1d.hgitaly.MercurialPermissions\x12\x17\n\x0fgitlab_revision\x18\x04 \x01(\x0c\"\x1a\n\x18PublishChangesetResponse\"P\n\x15PublishChangesetError\x12.\n\x0bgitlab_hook\x18\x01 \x01(\x0b\x32\x17.gitaly.CustomHookErrorH\x00\x42\x07\n\x05\x65rror\"\xa1\x02\n\x12MergeBranchRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x1a\n\x04user\x18\x02 \x01(\x0b\x32\x0c.gitaly.User\x12/\n\x08hg_perms\x18\x03 \x01(\x0e\x32\x1d.hgitaly.MercurialPermissions\x12\x11\n\tcommit_id\x18\x04 \x01(\t\x12\x0e\n\x06\x62ranch\x18\x05 \x01(\x0c\x12\x0f\n\x07message\x18\x06 \x01(\x0c\x12-\n\ttimestamp\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x18\n\x10\x65xpected_old_oid\x18\x08 \x01(\t\x12\x13\n\x0bsemi_linear\x18\t \x01(\x08\"K\n\x13MergeBranchResponse\x12\x34\n\rbranch_update\x18\x01 \x01(\x0b\x32\x1d.gitaly.OperationBranchUpdate\"\xe7\x01\n\x10MergeBranchError\x12.\n\x0bgitlab_hook\x18\x01 \x01(\x0b\x32\x17.gitaly.CustomHookErrorH\x00\x12.\n\x08\x63onflict\x18\x02 \x01(\x0b\x32\x1a.gitaly.MergeConflictErrorH\x00\x12\x37\n\x0freference_check\x18\x03 \x01(\x0b\x32\x1c.gitaly.ReferenceUpdateErrorH\x00\x12\x31\n\tpre_check\x18\x04 \x01(\x0e\x32\x1c.hgitaly.PreCheckUpdateErrorH\x00\x42\x07\n\x05\x65rror\"\x9a\x01\n\rCensorRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x1a\n\x04user\x18\x02 \x01(\x0b\x32\x0c.gitaly.User\x12\x19\n\x11\x63hangeset_node_id\x18\x03 \x01(\t\x12\x11\n\tfile_path\x18\x04 \x01(\x0c\x12\x11\n\ttombstone\x18\x05 \x01(\x0c\"\x10\n\x0e\x43\x65nsorResponse\"\x87\x01\n\x1aGetWorkingDirectoryRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x16\n\x0eincarnation_id\x18\x03 \x01(\t\x12\x10\n\x08revision\x18\x04 \x01(\x0c\"R\n\x1bGetWorkingDirectoryResponse\x12\x1c\n\x14working_directory_id\x18\x01 \x01(\r\x12\x15\n\rrelative_path\x18\x02 \x01(\t\"\x97\x01\n\x1eReleaseWorkingDirectoryRequest\x12,\n\nrepository\x18\x01 \x01(\x0b\x32\x12.gitaly.RepositoryB\x04\x98\xc6,\x01\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x16\n\x0eincarnation_id\x18\x03 \x01(\t\x12\x1c\n\x14working_directory_id\x18\x04 \x01(\r\"!\n\x1fReleaseWorkingDirectoryResponse*8\n\x14MercurialPermissions\x12\x08\n\x04READ\x10\x00\x12\t\n\x05WRITE\x10\x01\x12\x0b\n\x07PUBLISH\x10\x02*k\n\x13PreCheckUpdateError\x12\x0e\n\nNO_PROBLEM\x10\x00\x12\x14\n\x10NOT_FAST_FORWARD\x10\x01\x12\x16\n\x12OBSOLETE_CHANGESET\x10\x02\x12\x16\n\x12UNSTABLE_CHANGESET\x10\x03\x32\xca\x04\n\x1aMercurialOperationsService\x12V\n\rMergeAnalysis\x12\x1d.hgitaly.MergeAnalysisRequest\x1a\x1e.hgitaly.MergeAnalysisResponse\"\x06\xfa\x97(\x02\x08\x02\x12_\n\x10PublishChangeset\x12 .hgitaly.PublishChangesetRequest\x1a!.hgitaly.PublishChangesetResponse\"\x06\xfa\x97(\x02\x08\x01\x12\x41\n\x06\x43\x65nsor\x12\x16.hgitaly.CensorRequest\x1a\x17.hgitaly.CensorResponse\"\x06\xfa\x97(\x02\x08\x01\x12P\n\x0bMergeBranch\x12\x1b.hgitaly.MergeBranchRequest\x1a\x1c.hgitaly.MergeBranchResponse\"\x06\xfa\x97(\x02\x08\x01\x12h\n\x13GetWorkingDirectory\x12#.hgitaly.GetWorkingDirectoryRequest\x1a$.hgitaly.GetWorkingDirectoryResponse\"\x06\xfa\x97(\x02\x08\x01\x12t\n\x17ReleaseWorkingDirectory\x12\'.hgitaly.ReleaseWorkingDirectoryRequest\x1a(.hgitaly.ReleaseWorkingDirectoryResponse\"\x06\xfa\x97(\x02\x08\x01\x62\x06proto3')
 
 _globals = globals()
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -34,6 +34,10 @@
   _globals['_MERGEBRANCHREQUEST'].fields_by_name['repository']._serialized_options = b'\230\306,\001'
   _globals['_CENSORREQUEST'].fields_by_name['repository']._loaded_options = None
   _globals['_CENSORREQUEST'].fields_by_name['repository']._serialized_options = b'\230\306,\001'
+  _globals['_GETWORKINGDIRECTORYREQUEST'].fields_by_name['repository']._loaded_options = None
+  _globals['_GETWORKINGDIRECTORYREQUEST'].fields_by_name['repository']._serialized_options = b'\230\306,\001'
+  _globals['_RELEASEWORKINGDIRECTORYREQUEST'].fields_by_name['repository']._loaded_options = None
+  _globals['_RELEASEWORKINGDIRECTORYREQUEST'].fields_by_name['repository']._serialized_options = b'\230\306,\001'
   _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['MergeAnalysis']._loaded_options = None
   _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['MergeAnalysis']._serialized_options = b'\372\227(\002\010\002'
   _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['PublishChangeset']._loaded_options = None
@@ -42,10 +46,14 @@
   _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['Censor']._serialized_options = b'\372\227(\002\010\001'
   _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['MergeBranch']._loaded_options = None
   _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['MergeBranch']._serialized_options = b'\372\227(\002\010\001'
-  _globals['_MERCURIALPERMISSIONS']._serialized_start=1649
-  _globals['_MERCURIALPERMISSIONS']._serialized_end=1705
-  _globals['_PRECHECKUPDATEERROR']._serialized_start=1707
-  _globals['_PRECHECKUPDATEERROR']._serialized_end=1814
+  _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['GetWorkingDirectory']._loaded_options = None
+  _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['GetWorkingDirectory']._serialized_options = b'\372\227(\002\010\001'
+  _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['ReleaseWorkingDirectory']._loaded_options = None
+  _globals['_MERCURIALOPERATIONSSERVICE'].methods_by_name['ReleaseWorkingDirectory']._serialized_options = b'\372\227(\002\010\001'
+  _globals['_MERCURIALPERMISSIONS']._serialized_start=2060
+  _globals['_MERCURIALPERMISSIONS']._serialized_end=2116
+  _globals['_PRECHECKUPDATEERROR']._serialized_start=2118
+  _globals['_PRECHECKUPDATEERROR']._serialized_end=2225
   _globals['_MERGEANALYSISREQUEST']._serialized_start=131
   _globals['_MERGEANALYSISREQUEST']._serialized_end=279
   _globals['_MERGEANALYSISRESPONSE']._serialized_start=282
@@ -66,6 +74,14 @@
   _globals['_CENSORREQUEST']._serialized_end=1629
   _globals['_CENSORRESPONSE']._serialized_start=1631
   _globals['_CENSORRESPONSE']._serialized_end=1647
-  _globals['_MERCURIALOPERATIONSSERVICE']._serialized_start=1817
-  _globals['_MERCURIALOPERATIONSSERVICE']._serialized_end=2179
+  _globals['_GETWORKINGDIRECTORYREQUEST']._serialized_start=1650
+  _globals['_GETWORKINGDIRECTORYREQUEST']._serialized_end=1785
+  _globals['_GETWORKINGDIRECTORYRESPONSE']._serialized_start=1787
+  _globals['_GETWORKINGDIRECTORYRESPONSE']._serialized_end=1869
+  _globals['_RELEASEWORKINGDIRECTORYREQUEST']._serialized_start=1872
+  _globals['_RELEASEWORKINGDIRECTORYREQUEST']._serialized_end=2023
+  _globals['_RELEASEWORKINGDIRECTORYRESPONSE']._serialized_start=2025
+  _globals['_RELEASEWORKINGDIRECTORYRESPONSE']._serialized_end=2058
+  _globals['_MERCURIALOPERATIONSSERVICE']._serialized_start=2228
+  _globals['_MERCURIALOPERATIONSSERVICE']._serialized_end=2814
 # @@protoc_insertion_point(module_scope)
diff --git a/hgitaly/stub/mercurial_operations_pb2_grpc.py b/hgitaly/stub/mercurial_operations_pb2_grpc.py
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_aGdpdGFseS9zdHViL21lcmN1cmlhbF9vcGVyYXRpb25zX3BiMl9ncnBjLnB5..20e3404f3141b9ff06a8ace0b039dee6beea96ec_aGdpdGFseS9zdHViL21lcmN1cmlhbF9vcGVyYXRpb25zX3BiMl9ncnBjLnB5 100644
--- a/hgitaly/stub/mercurial_operations_pb2_grpc.py
+++ b/hgitaly/stub/mercurial_operations_pb2_grpc.py
@@ -59,6 +59,16 @@
                 request_serializer=mercurial__operations__pb2.MergeBranchRequest.SerializeToString,
                 response_deserializer=mercurial__operations__pb2.MergeBranchResponse.FromString,
                 _registered_method=True)
+        self.GetWorkingDirectory = channel.unary_unary(
+                '/hgitaly.MercurialOperationsService/GetWorkingDirectory',
+                request_serializer=mercurial__operations__pb2.GetWorkingDirectoryRequest.SerializeToString,
+                response_deserializer=mercurial__operations__pb2.GetWorkingDirectoryResponse.FromString,
+                _registered_method=True)
+        self.ReleaseWorkingDirectory = channel.unary_unary(
+                '/hgitaly.MercurialOperationsService/ReleaseWorkingDirectory',
+                request_serializer=mercurial__operations__pb2.ReleaseWorkingDirectoryRequest.SerializeToString,
+                response_deserializer=mercurial__operations__pb2.ReleaseWorkingDirectoryResponse.FromString,
+                _registered_method=True)
 
 
 class MercurialOperationsServiceServicer(object):
@@ -102,6 +112,26 @@
         context.set_details('Method not implemented!')
         raise NotImplementedError('Method not implemented!')
 
+    def GetWorkingDirectory(self, request, context):
+        """GetWorkingDirectory hands over a working directory, checked out to
+        the wished changeset.
+
+        The server manages a pool of working directories for efficient update.
+        The client has exclusivity over the working directory it has been handed,
+        and is expected to release it.
+        """
+        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+        context.set_details('Method not implemented!')
+        raise NotImplementedError('Method not implemented!')
+
+    def ReleaseWorkingDirectory(self, request, context):
+        """ReleaseWorkingDirectory allows the server to put a working directoy back
+        in the pool for later reuse.
+        """
+        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+        context.set_details('Method not implemented!')
+        raise NotImplementedError('Method not implemented!')
+
 
 def add_MercurialOperationsServiceServicer_to_server(servicer, server):
     rpc_method_handlers = {
@@ -125,6 +155,16 @@
                     request_deserializer=mercurial__operations__pb2.MergeBranchRequest.FromString,
                     response_serializer=mercurial__operations__pb2.MergeBranchResponse.SerializeToString,
             ),
+            'GetWorkingDirectory': grpc.unary_unary_rpc_method_handler(
+                    servicer.GetWorkingDirectory,
+                    request_deserializer=mercurial__operations__pb2.GetWorkingDirectoryRequest.FromString,
+                    response_serializer=mercurial__operations__pb2.GetWorkingDirectoryResponse.SerializeToString,
+            ),
+            'ReleaseWorkingDirectory': grpc.unary_unary_rpc_method_handler(
+                    servicer.ReleaseWorkingDirectory,
+                    request_deserializer=mercurial__operations__pb2.ReleaseWorkingDirectoryRequest.FromString,
+                    response_serializer=mercurial__operations__pb2.ReleaseWorkingDirectoryResponse.SerializeToString,
+            ),
     }
     generic_handler = grpc.method_handlers_generic_handler(
             'hgitaly.MercurialOperationsService', rpc_method_handlers)
@@ -242,3 +282,57 @@
             timeout,
             metadata,
             _registered_method=True)
+
+    @staticmethod
+    def GetWorkingDirectory(request,
+            target,
+            options=(),
+            channel_credentials=None,
+            call_credentials=None,
+            insecure=False,
+            compression=None,
+            wait_for_ready=None,
+            timeout=None,
+            metadata=None):
+        return grpc.experimental.unary_unary(
+            request,
+            target,
+            '/hgitaly.MercurialOperationsService/GetWorkingDirectory',
+            mercurial__operations__pb2.GetWorkingDirectoryRequest.SerializeToString,
+            mercurial__operations__pb2.GetWorkingDirectoryResponse.FromString,
+            options,
+            channel_credentials,
+            insecure,
+            call_credentials,
+            compression,
+            wait_for_ready,
+            timeout,
+            metadata,
+            _registered_method=True)
+
+    @staticmethod
+    def ReleaseWorkingDirectory(request,
+            target,
+            options=(),
+            channel_credentials=None,
+            call_credentials=None,
+            insecure=False,
+            compression=None,
+            wait_for_ready=None,
+            timeout=None,
+            metadata=None):
+        return grpc.experimental.unary_unary(
+            request,
+            target,
+            '/hgitaly.MercurialOperationsService/ReleaseWorkingDirectory',
+            mercurial__operations__pb2.ReleaseWorkingDirectoryRequest.SerializeToString,
+            mercurial__operations__pb2.ReleaseWorkingDirectoryResponse.FromString,
+            options,
+            channel_credentials,
+            insecure,
+            call_credentials,
+            compression,
+            wait_for_ready,
+            timeout,
+            metadata,
+            _registered_method=True)
diff --git a/hgitaly/workdir.py b/hgitaly/workdir.py
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_aGdpdGFseS93b3JrZGlyLnB5..20e3404f3141b9ff06a8ace0b039dee6beea96ec_aGdpdGFseS93b3JrZGlyLnB5 100644
--- a/hgitaly/workdir.py
+++ b/hgitaly/workdir.py
@@ -123,8 +123,7 @@
 
 
 @contextmanager
-def working_directory(workdirs_root, repo, client_id, incarnation_id,
-                      changeset=None):
+def working_directory(workdirs_root, repo, *args, **kwargs):
     """Context manager for temporary working directories
 
     Entering this context manager, (typically with the ``with`` statement
@@ -151,6 +150,17 @@
       the ``default`` branch. This should be used only in the case of
       empty repositories.
     """
+
+    wd = reserve_prepare_workdir(workdirs_root, repo, *args, **kwargs)
+    try:
+        yield wd
+    finally:
+        release_workdir(repo, wd)
+
+
+def reserve_prepare_workdir(workdirs_root, repo,
+                            client_id, incarnation_id,
+                            changeset=None):
     branch = b'default' if changeset is None else changeset.branch()
     wd = reserve_workdir(workdirs_root, repo.ui, repo, branch,
                          client_id=client_id,
@@ -159,11 +169,7 @@
     wd.purge()
     if changeset is not None:
         wd.clean_update(changeset.hex())
-
-    try:
-        yield wd
-    finally:
-        release_workdir(repo, wd)
+    return wd
 
 
 class rosterlock(lock.lock):
@@ -364,4 +370,26 @@
     return wd
 
 
+class ClientMismatch(ValueError):
+    """Indicates that the Client ID was not as expected"""
+
+
+def release_workdir_by_id(repo, wd_id, client_id):
+    """Release a working directory specified by ID.
+
+    For sanity / authorization, the provided Client ID is checked against
+    the one in the roster file.
+    """
+    with locked_roster(repo) as (rosterf, new_rosterf):
+        for (read_wd_id, client, _ts, branch), line in roster_iter(rosterf):
+            if read_wd_id == wd_id:
+                holder_id = client[0]
+                if holder_id != client_id:
+                    raise ClientMismatch(holder_id, client_id)
+
+                wd = WorkingDirectory(id=wd_id, branch=branch, path=None)
+                line = wd.roster_line(client_id=None, incarnation_id=None)
+            new_rosterf.write(line)
+
+
 def release_workdir(repo, wd):
@@ -367,4 +395,9 @@
 def release_workdir(repo, wd):
+    """Release a working directory object.
+
+    No Client ID check is needed in this case, as the `WorkingDirectory`
+    object is considered proof enough.
+    """
     with locked_roster(repo) as (rosterf, new_rosterf):
         for parsed, line in roster_iter(rosterf):
             if parsed[0] == wd.id:
diff --git a/protos/mercurial-operations.proto b/protos/mercurial-operations.proto
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_cHJvdG9zL21lcmN1cmlhbC1vcGVyYXRpb25zLnByb3Rv..20e3404f3141b9ff06a8ace0b039dee6beea96ec_cHJvdG9zL21lcmN1cmlhbC1vcGVyYXRpb25zLnByb3Rv 100644
--- a/protos/mercurial-operations.proto
+++ b/protos/mercurial-operations.proto
@@ -48,6 +48,26 @@
       op: MUTATOR
     };
   }
+
+  // GetWorkingDirectory hands over a working directory, checked out to
+  // the wished changeset.
+  //
+  // The server manages a pool of working directories for efficient update.
+  // The client has exclusivity over the working directory it has been handed,
+  // and is expected to release it.
+  rpc GetWorkingDirectory(GetWorkingDirectoryRequest) returns (GetWorkingDirectoryResponse) {
+    option (.gitaly.op_type) = {
+      op: MUTATOR // because of roster file
+    };
+  }
+
+  // ReleaseWorkingDirectory allows the server to put a working directoy back
+  // in the pool for later reuse.
+  rpc ReleaseWorkingDirectory(ReleaseWorkingDirectoryRequest) returns (ReleaseWorkingDirectoryResponse) {
+    option (.gitaly.op_type) = {
+      op: MUTATOR // because of roster file
+    };
+  }
 }
 
 // Permissions enforced directly within Mercurial
@@ -161,3 +181,33 @@
 
 message CensorResponse {
 }
+
+message GetWorkingDirectoryRequest {
+  .gitaly.Repository repository = 1 [(.gitaly.target_repository)=true];
+  string client_id = 2;  // persistent identifier
+  // incarnation_id is a volatile identifier whose change means that the
+  // client has restarted, hence that any lingering working directory
+  // reservation should be ignored (typically the client was killed abruptly).
+  string incarnation_id = 3;
+  bytes revision = 4;
+}
+
+message GetWorkingDirectoryResponse {
+  uint32 working_directory_id = 1;
+  // path relative to the repository root. In some cases, it can
+  // be used by clients instead of the (main) repository relative path
+  string relative_path = 2;
+}
+
+message ReleaseWorkingDirectoryRequest {
+  .gitaly.Repository repository = 1 [(.gitaly.target_repository)=true];
+  string client_id = 2;  // persistent identifier
+  // incarnation_id is a volatile identifier whose change means that the
+  // client has restarted, hence that any lingering working directory
+  // reservation should be ignored (typically the client was killed abruptly).
+  string incarnation_id = 3;
+  uint32 working_directory_id = 4;
+}
+
+message ReleaseWorkingDirectoryResponse {
+}
diff --git a/ruby/lib/hgitaly/mercurial-operations_pb.rb b/ruby/lib/hgitaly/mercurial-operations_pb.rb
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_cnVieS9saWIvaGdpdGFseS9tZXJjdXJpYWwtb3BlcmF0aW9uc19wYi5yYg==..20e3404f3141b9ff06a8ace0b039dee6beea96ec_cnVieS9saWIvaGdpdGFseS9tZXJjdXJpYWwtb3BlcmF0aW9uc19wYi5yYg== 100644
--- a/ruby/lib/hgitaly/mercurial-operations_pb.rb
+++ b/ruby/lib/hgitaly/mercurial-operations_pb.rb
@@ -73,6 +73,24 @@
     end
     add_message "hgitaly.CensorResponse" do
     end
+    add_message "hgitaly.GetWorkingDirectoryRequest" do
+      optional :repository, :message, 1, "gitaly.Repository"
+      optional :client_id, :string, 2
+      optional :incarnation_id, :string, 3
+      optional :revision, :bytes, 4
+    end
+    add_message "hgitaly.GetWorkingDirectoryResponse" do
+      optional :working_directory_id, :uint32, 1
+      optional :relative_path, :string, 2
+    end
+    add_message "hgitaly.ReleaseWorkingDirectoryRequest" do
+      optional :repository, :message, 1, "gitaly.Repository"
+      optional :client_id, :string, 2
+      optional :incarnation_id, :string, 3
+      optional :working_directory_id, :uint32, 4
+    end
+    add_message "hgitaly.ReleaseWorkingDirectoryResponse" do
+    end
     add_enum "hgitaly.MercurialPermissions" do
       value :READ, 0
       value :WRITE, 1
@@ -98,6 +116,10 @@
   MergeBranchError = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.MergeBranchError").msgclass
   CensorRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.CensorRequest").msgclass
   CensorResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.CensorResponse").msgclass
+  GetWorkingDirectoryRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.GetWorkingDirectoryRequest").msgclass
+  GetWorkingDirectoryResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.GetWorkingDirectoryResponse").msgclass
+  ReleaseWorkingDirectoryRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.ReleaseWorkingDirectoryRequest").msgclass
+  ReleaseWorkingDirectoryResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.ReleaseWorkingDirectoryResponse").msgclass
   MercurialPermissions = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.MercurialPermissions").enummodule
   PreCheckUpdateError = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("hgitaly.PreCheckUpdateError").enummodule
 end
diff --git a/ruby/lib/hgitaly/mercurial-operations_services_pb.rb b/ruby/lib/hgitaly/mercurial-operations_services_pb.rb
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_cnVieS9saWIvaGdpdGFseS9tZXJjdXJpYWwtb3BlcmF0aW9uc19zZXJ2aWNlc19wYi5yYg==..20e3404f3141b9ff06a8ace0b039dee6beea96ec_cnVieS9saWIvaGdpdGFseS9tZXJjdXJpYWwtb3BlcmF0aW9uc19zZXJ2aWNlc19wYi5yYg== 100644
--- a/ruby/lib/hgitaly/mercurial-operations_services_pb.rb
+++ b/ruby/lib/hgitaly/mercurial-operations_services_pb.rb
@@ -32,6 +32,16 @@
       # - It is not a 2-phase operation, hence not streaming
       # - The Request message has a slighly different set of options
       rpc :MergeBranch, ::Hgitaly::MergeBranchRequest, ::Hgitaly::MergeBranchResponse
+      # GetWorkingDirectory hands over a working directory, checked out to
+      # the wished changeset.
+      #
+      # The server manages a pool of working directories for efficient update.
+      # The client has exclusivity over the working directory it has been handed,
+      # and is expected to release it.
+      rpc :GetWorkingDirectory, ::Hgitaly::GetWorkingDirectoryRequest, ::Hgitaly::GetWorkingDirectoryResponse
+      # ReleaseWorkingDirectory allows the server to put a working directoy back
+      # in the pool for later reuse.
+      rpc :ReleaseWorkingDirectory, ::Hgitaly::ReleaseWorkingDirectoryRequest, ::Hgitaly::ReleaseWorkingDirectoryResponse
     end
 
     Stub = Service.rpc_stub_class
diff --git a/ruby/lib/hgitaly/version.rb b/ruby/lib/hgitaly/version.rb
index 79d794cab80c3f7e0660ec088ba9f8ad3b002850_cnVieS9saWIvaGdpdGFseS92ZXJzaW9uLnJi..20e3404f3141b9ff06a8ace0b039dee6beea96ec_cnVieS9saWIvaGdpdGFseS92ZXJzaW9uLnJi 100644
--- a/ruby/lib/hgitaly/version.rb
+++ b/ruby/lib/hgitaly/version.rb
@@ -1,4 +1,4 @@
 # This file is generated by generate-grpc-lib. Do not edit.
 module Hgitaly
-  VERSION = '17.9.0'
+  VERSION = '17.9.1dev0'
 end