hgitaly 18.2.0__tar.gz → 18.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. {hgitaly-18.2.0/hgitaly.egg-info → hgitaly-18.2.1}/PKG-INFO +1 -1
  2. hgitaly-18.2.1/hgitaly/VERSION +1 -0
  3. hgitaly-18.2.1/hgitaly/service/operations.py +653 -0
  4. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/fixture.py +7 -3
  5. hgitaly-18.2.1/hgitaly/service/tests/test_operations.py +799 -0
  6. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/servicer.py +5 -0
  7. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/workdir.py +3 -0
  8. {hgitaly-18.2.0 → hgitaly-18.2.1/hgitaly.egg-info}/PKG-INFO +1 -1
  9. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/comparison.py +24 -2
  10. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_operations.py +221 -0
  11. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_repository_service.py +26 -3
  12. hgitaly-18.2.0/hgitaly/VERSION +0 -1
  13. hgitaly-18.2.0/hgitaly/service/operations.py +0 -263
  14. hgitaly-18.2.0/hgitaly/service/tests/test_operations.py +0 -275
  15. {hgitaly-18.2.0 → hgitaly-18.2.1}/LICENSE +0 -0
  16. {hgitaly-18.2.0 → hgitaly-18.2.1}/MANIFEST.in +0 -0
  17. {hgitaly-18.2.0 → hgitaly-18.2.1}/README.md +0 -0
  18. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgext3rd/__init__.py +0 -0
  19. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgext3rd/hgitaly/__init__.py +0 -0
  20. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgext3rd/hgitaly/revset.py +0 -0
  21. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgext3rd/hgitaly/tests/__init__.py +0 -0
  22. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgext3rd/hgitaly/tests/test_revset.py +0 -0
  23. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgext3rd/hgitaly/tests/test_serve.py +0 -0
  24. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/__init__.py +0 -0
  25. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/branch.py +0 -0
  26. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/changelog.py +0 -0
  27. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/diff.py +0 -0
  28. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/errors.py +0 -0
  29. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/feature.py +0 -0
  30. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/file_content.py +0 -0
  31. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/file_context.py +0 -0
  32. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/git.py +0 -0
  33. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/gitlab_ref.py +0 -0
  34. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/identification.py +0 -0
  35. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/logging.py +0 -0
  36. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/manifest.py +0 -0
  37. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/message.py +0 -0
  38. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/oid.py +0 -0
  39. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/pagination.py +0 -0
  40. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/path.py +0 -0
  41. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/peer.py +0 -0
  42. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/procutil.py +0 -0
  43. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/repository.py +0 -0
  44. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/revision.py +0 -0
  45. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/revset.py +0 -0
  46. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/scripts.py +0 -0
  47. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/__init__.py +0 -0
  48. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/address.py +0 -0
  49. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/mono.py +0 -0
  50. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/prefork.py +0 -0
  51. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/tests/__init__.py +0 -0
  52. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/tests/test_address.py +0 -0
  53. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/tests/test_mono.py +0 -0
  54. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/tests/test_prefork.py +0 -0
  55. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/tests/test_worker.py +0 -0
  56. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/server/worker.py +0 -0
  57. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/__init__.py +0 -0
  58. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/analysis.py +0 -0
  59. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/blob.py +0 -0
  60. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/commit.py +0 -0
  61. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/diff.py +0 -0
  62. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/interceptors.py +0 -0
  63. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/mercurial_changeset.py +0 -0
  64. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/mercurial_operations.py +0 -0
  65. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/mercurial_repository.py +0 -0
  66. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/ref.py +0 -0
  67. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/repository.py +0 -0
  68. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/server.py +0 -0
  69. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/__init__.py +0 -0
  70. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_analysis.py +0 -0
  71. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_blob.py +0 -0
  72. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_commit.py +0 -0
  73. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_default_branch.py +0 -0
  74. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_diff.py +0 -0
  75. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_mercurial_changeset.py +0 -0
  76. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_mercurial_operations.py +0 -0
  77. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_mercurial_repository.py +0 -0
  78. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_ref.py +0 -0
  79. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_repository_service.py +0 -0
  80. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/service/tests/test_server.py +0 -0
  81. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/ssh.py +0 -0
  82. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stream.py +0 -0
  83. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/__init__.py +0 -0
  84. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/analysis_pb2.py +0 -0
  85. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/analysis_pb2_grpc.py +0 -0
  86. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/blob_pb2.py +0 -0
  87. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/blob_pb2_grpc.py +0 -0
  88. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/commit_pb2.py +0 -0
  89. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/commit_pb2_grpc.py +0 -0
  90. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/diff_pb2.py +0 -0
  91. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/diff_pb2_grpc.py +0 -0
  92. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/errors_pb2.py +0 -0
  93. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/errors_pb2_grpc.py +0 -0
  94. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/lint_pb2.py +0 -0
  95. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/lint_pb2_grpc.py +0 -0
  96. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/mercurial_aux_git_pb2.py +0 -0
  97. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/mercurial_aux_git_pb2_grpc.py +0 -0
  98. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/mercurial_changeset_pb2.py +0 -0
  99. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/mercurial_changeset_pb2_grpc.py +0 -0
  100. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/mercurial_operations_pb2.py +0 -0
  101. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/mercurial_operations_pb2_grpc.py +0 -0
  102. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/mercurial_repository_pb2.py +0 -0
  103. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/mercurial_repository_pb2_grpc.py +0 -0
  104. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/operations_pb2.py +0 -0
  105. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/operations_pb2_grpc.py +0 -0
  106. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/ref_pb2.py +0 -0
  107. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/ref_pb2_grpc.py +0 -0
  108. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/remote_pb2.py +0 -0
  109. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/remote_pb2_grpc.py +0 -0
  110. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/repository_pb2.py +0 -0
  111. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/repository_pb2_grpc.py +0 -0
  112. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/server_pb2.py +0 -0
  113. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/server_pb2_grpc.py +0 -0
  114. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/shared_pb2.py +0 -0
  115. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/stub/shared_pb2_grpc.py +0 -0
  116. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tag.py +0 -0
  117. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/__init__.py +0 -0
  118. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/bundle.py +0 -0
  119. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/context.py +0 -0
  120. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/grpc.py +0 -0
  121. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/multiprocessing.py +0 -0
  122. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/ssh.py +0 -0
  123. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/sshd.py +0 -0
  124. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/storage.py +0 -0
  125. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/tests/__init__.py +0 -0
  126. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/testing/tests/test_sshd.py +0 -0
  127. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/__init__.py +0 -0
  128. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/common.py +0 -0
  129. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_branch.py +0 -0
  130. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_diff.py +0 -0
  131. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_errors.py +0 -0
  132. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_feature.py +0 -0
  133. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_file_context.py +0 -0
  134. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_gitlab_ref.py +0 -0
  135. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_identification.py +0 -0
  136. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_manifest.py +0 -0
  137. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_messages.py +0 -0
  138. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_oid.py +0 -0
  139. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_peer.py +0 -0
  140. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_repository.py +0 -0
  141. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_revision.py +0 -0
  142. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_revset.py +0 -0
  143. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_servicer.py +0 -0
  144. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_stream.py +0 -0
  145. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_tag.py +0 -0
  146. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/tests/test_workdir.py +0 -0
  147. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly/util.py +0 -0
  148. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly.egg-info/SOURCES.txt +0 -0
  149. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly.egg-info/dependency_links.txt +0 -0
  150. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly.egg-info/entry_points.txt +0 -0
  151. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly.egg-info/requires.txt +0 -0
  152. {hgitaly-18.2.0 → hgitaly-18.2.1}/hgitaly.egg-info/top_level.txt +0 -0
  153. {hgitaly-18.2.0 → hgitaly-18.2.1}/install-requirements.txt +0 -0
  154. {hgitaly-18.2.0 → hgitaly-18.2.1}/setup.cfg +0 -0
  155. {hgitaly-18.2.0 → hgitaly-18.2.1}/setup.py +0 -0
  156. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/__init__.py +0 -0
  157. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/conftest.py +0 -0
  158. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/gitaly.py +0 -0
  159. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/hgitaly_rhgitaly_comparison.py +0 -0
  160. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/rhgitaly.py +0 -0
  161. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_blob_tree.py +0 -0
  162. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_commit.py +0 -0
  163. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_comparison.py +0 -0
  164. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_diff.py +0 -0
  165. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_gitaly_server.py +0 -0
  166. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_mercurial_aux_git.py +0 -0
  167. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_mercurial_operations.py +0 -0
  168. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_mercurial_repository.py +0 -0
  169. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_ref.py +0 -0
  170. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_remote.py +0 -0
  171. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_rhgitaly_server.py +0 -0
  172. {hgitaly-18.2.0 → hgitaly-18.2.1}/tests_with_gitaly/test_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: hgitaly
3
- Version: 18.2.0
3
+ Version: 18.2.1
4
4
  Summary: Server-side implementation of Gitaly protocol for Mercurial
5
5
  Home-page: https://foss.heptapod.net/heptapod/hgitaly
6
6
  Author: Georges Racinet
@@ -0,0 +1 @@
1
+ 18.2.1
@@ -0,0 +1,653 @@
1
+ # Copyright 2023 Georges Racinet <georges.racinet@octobus.net>
2
+ #
3
+ # This software may be used and distributed according to the terms of the
4
+ # GNU General Public License version 2 or any later version.
5
+ #
6
+ # SPDX-License-Identifier: GPL-2.0-or-later
7
+ from base64 import b64decode
8
+ import logging
9
+ import os
10
+ import time
11
+
12
+ from grpc import StatusCode
13
+
14
+ from mercurial import (
15
+ cmdutil,
16
+ commands,
17
+ node,
18
+ scmutil,
19
+ )
20
+
21
+ from heptapod import (
22
+ obsutil,
23
+ )
24
+
25
+ from heptapod.gitlab.branch import (
26
+ NAMED_BRANCH_PREFIX,
27
+ parse_gitlab_branch,
28
+ )
29
+
30
+ from ..branch import (
31
+ gitlab_branch_head
32
+ )
33
+ from ..changelog import (
34
+ ancestor,
35
+ merge_content,
36
+ )
37
+ from ..errors import (
38
+ operation_error_treatment,
39
+ structured_abort,
40
+ )
41
+ from ..logging import LoggerAdapter
42
+ from ..revision import (
43
+ changeset_by_commit_id_abort,
44
+ gitlab_revision_changeset,
45
+ validate_oid,
46
+ )
47
+ from ..servicer import HGitalyServicer
48
+
49
+ from ..stub.operations_pb2 import (
50
+ OperationBranchUpdate,
51
+ UserCommitFilesActionHeader,
52
+ UserCommitFilesError,
53
+ UserCommitFilesRequest,
54
+ UserCommitFilesResponse,
55
+ UserFFBranchError,
56
+ UserFFBranchRequest,
57
+ UserFFBranchResponse,
58
+ UserSquashRequest,
59
+ UserSquashResponse,
60
+ UserSquashError,
61
+ )
62
+ from ..stub.errors_pb2 import (
63
+ IndexError,
64
+ ReferenceUpdateError,
65
+ ResolveRevisionError,
66
+ )
67
+ from ..stub.operations_pb2_grpc import OperationServiceServicer
68
+
69
+
70
+ base_logger = logging.getLogger(__name__)
71
+ ActionType = UserCommitFilesActionHeader.ActionType
72
+
73
+
74
+ DOUBLE_SLASH = b'//'
75
+ DOUBLE_PARSEP = (os.path.sep * 2).encode() # in case it's not double slash
76
+ DIRECTORY_CLIMB_UP = (os.path.pardir + os.path.sep).encode()
77
+
78
+ FORBIDDEN_IN_PATHS = tuple(set((DOUBLE_SLASH,
79
+ DOUBLE_PARSEP,
80
+ DIRECTORY_CLIMB_UP,
81
+ )))
82
+
83
+
84
+ def index_error(context, status_code, error_type, msg, path=b''):
85
+ """Abort with IndexError structued error.
86
+
87
+ :param str status_code: name of the gRPC status code
88
+ :param str error_type: name of the IndexError type enum
89
+ """
90
+ structured_abort(
91
+ context,
92
+ getattr(StatusCode, status_code),
93
+ msg,
94
+ UserCommitFilesError(
95
+ index_update=IndexError(
96
+ error_type=getattr(IndexError.ErrorType, error_type),
97
+ path=path
98
+ )
99
+ )
100
+ )
101
+
102
+
103
+ class OperationServicer(OperationServiceServicer, HGitalyServicer):
104
+ """OperationsServiceService implementation.
105
+ """
106
+
107
+ def UserSquash(self,
108
+ request: UserSquashRequest,
109
+ context) -> UserSquashResponse:
110
+ logger = LoggerAdapter(base_logger, context)
111
+ repo = self.load_repo(request.repository, context,
112
+ for_mutation_by=request.user)
113
+ with_hg_git = not repo.ui.configbool(b'heptapod', b'no-git')
114
+ # Gitaly's squash is merge-centric, start_sha is actually the
115
+ # merge target, whereas end_sha is the head of the MR being accepted
116
+ # TODO check that there are no public changesets for a nicer
117
+ # user feedback.
118
+ start_sha, end_sha = request.start_sha, request.end_sha
119
+ if not start_sha:
120
+ context.abort(StatusCode.INVALID_ARGUMENT, "empty StartSha")
121
+ if not end_sha:
122
+ context.abort(StatusCode.INVALID_ARGUMENT, "empty EndSha")
123
+ start_rev, end_rev = start_sha.encode('ascii'), end_sha.encode('ascii')
124
+ start_ctx = gitlab_revision_changeset(repo, start_rev)
125
+ end_ctx = gitlab_revision_changeset(repo, end_rev)
126
+
127
+ for (ctx, rev, error_label) in (
128
+ (start_ctx, start_rev, 'start'), (end_ctx, end_rev, 'end')
129
+ ):
130
+ if ctx is None:
131
+ structured_abort(
132
+ context,
133
+ StatusCode.INVALID_ARGUMENT,
134
+ f'resolving {error_label} revision: reference not found',
135
+ UserSquashError(resolve_revision=ResolveRevisionError(
136
+ revision=rev))
137
+ )
138
+
139
+ revset = (f"ancestor({start_sha}, {end_sha})::{end_sha}"
140
+ f"- ancestor({start_sha}, {end_sha})").encode('ascii')
141
+ end_ctx = gitlab_revision_changeset(repo, end_sha.encode('ascii'))
142
+ logger.info("Folding revset %s, mirroring to Git=%r",
143
+ revset, with_hg_git)
144
+ # TODO add the hg_git flag or maybe let servicer do it.
145
+ message = request.commit_message
146
+ if not message:
147
+ context.abort(StatusCode.INVALID_ARGUMENT, "empty CommitMessage")
148
+
149
+ # Mercurial does not distinguish between author (actual author of
150
+ # the work) and committer (could be just someone with commit rights
151
+ # relaying the original work). In case an author is provided, it
152
+ # feels right to derive the Mercurial author from it, as preserving
153
+ # the actual work metadata (and copyright) should have priority.
154
+ # This is what `HgGitRepository` used to do on the Rails side.
155
+ author = request.author
156
+ if not author.name:
157
+ context.abort(StatusCode.INVALID_ARGUMENT, "empty Author")
158
+
159
+ # timestamp is supposed to be for the committer, but Mercurial does
160
+ # not have such a distinction, hence it will become the commit date.
161
+ if not request.HasField('timestamp'):
162
+ unix_ts = int(time.time())
163
+ else:
164
+ unix_ts = request.timestamp.seconds
165
+
166
+ opts = {'from': False, # cannot be used as regular kwarg
167
+ 'exact': True,
168
+ 'rev': [revset],
169
+ 'message': message,
170
+ 'user': b'%s <%s>' % (author.name, author.email),
171
+ 'date': b'%d 0' % unix_ts,
172
+ }
173
+ # Comment taken from `hg_git_repository.rb`:
174
+ # Note that `hg fold --exact` will fail unless the revset is
175
+ # "an unbroken linear chain". That fits the idea of a Merge Request
176
+ # neatly, and should be unsuprising to users: it's natural to expect
177
+ # squashes to stay simple.
178
+ # In particular, if there's a merge of a side topic, it will be
179
+ # unsquashable.
180
+ # Not 100% sure we need a workdir, but I don't see
181
+ # an explicit "inmemory" option as there is for `hg rebase`. What
182
+ # I do see is user (status) messages as in updates, so…
183
+ # If we update the workdir to "end" changeset, then the fold will
184
+ # look like an extra head and be rejected (probably because it is
185
+ # kept active by being the workdir parent).
186
+ # On the other hand, the "start" changeset is by design of the
187
+ # method not to be folded and is probably close enough that we
188
+ # get a reasonable average efficiency.
189
+ with self.working_dir(gl_repo=request.repository,
190
+ repo=repo,
191
+ changeset=start_ctx,
192
+ context=context) as wd:
193
+ # `allowunstable=no` protects us against all instabilities,
194
+ # in particular against orphaning dependent topics.
195
+ # TODO this setting should probably be set in all mutations
196
+ wd.repo.ui.setconfig(b'experimental.evolution', b'allowunstable',
197
+ False)
198
+ with operation_error_treatment(context, UserSquashError,
199
+ logger=logger):
200
+ retcode = self.repo_command(wd.repo, context, 'fold', **opts)
201
+ if retcode == 1:
202
+ revs = wd.repo.revs(revset)
203
+ if len(revs) == 1:
204
+ rev = next(iter(revs))
205
+ self.repo_command(wd.repo, context, 'update', rev)
206
+ self.repo_command(
207
+ wd.repo, context, 'amend',
208
+ message=message,
209
+ note=(b"Description changed for squash request "
210
+ b"for a single changeset"),
211
+ )
212
+ else: # pragma no cover
213
+ # This block is currently unreachable from tests
214
+ # and is here in case of unexpected behaviour change
215
+ # in the `fold` command.
216
+ context.abort(
217
+ StatusCode.INTERNAL,
218
+ "Internal return code 1, but zero or more than "
219
+ "one changeset for revset %r" % revset
220
+ )
221
+ self.repo_command(wd.repo, context, 'update', start_ctx.hex())
222
+ # The workdir repo does not have to be reloaded, whereas the
223
+ # main repo would. We just need to regrab the end changeset
224
+ # (now obsolete)
225
+ end_ctx = wd.repo.unfiltered()[end_ctx.rev()]
226
+ folded = obsutil.latest_unique_successor(end_ctx)
227
+
228
+ return UserSquashResponse(squash_sha=folded.hex().decode('ascii'))
229
+
230
+ def UserFFBranch(self,
231
+ request: UserFFBranchRequest,
232
+ context) -> UserFFBranchResponse:
233
+ logger = LoggerAdapter(base_logger, context)
234
+ repo = self.load_repo(request.repository, context,
235
+ for_mutation_by=request.user)
236
+ with_hg_git = not repo.ui.configbool(b'heptapod', b'no-git')
237
+
238
+ to_publish = changeset_by_commit_id_abort(
239
+ repo, request.commit_id, context)
240
+ if to_publish is None:
241
+ context.abort(
242
+ StatusCode.INTERNAL,
243
+ f'checking for ancestry: invalid commit: "{request.commit_id}"'
244
+ )
245
+
246
+ old_id = request.expected_old_oid
247
+ if old_id and not validate_oid(old_id):
248
+ context.abort(StatusCode.INVALID_ARGUMENT,
249
+ f'cannot parse commit ID: "{old_id}"')
250
+
251
+ if not request.branch:
252
+ context.abort(StatusCode.INVALID_ARGUMENT, "empty branch name")
253
+
254
+ if not request.branch.startswith(NAMED_BRANCH_PREFIX):
255
+ context.abort(StatusCode.FAILED_PRECONDITION,
256
+ "Heptapod fast forwards are currently "
257
+ "for named branches only (no topics nor bookmarks)")
258
+
259
+ current_head = gitlab_branch_head(repo, request.branch)
260
+ if to_publish.branch() != current_head.branch():
261
+ context.abort(StatusCode.FAILED_PRECONDITION,
262
+ "not a fast-forward (Mercurial branch differs)")
263
+
264
+ fail = False
265
+ for cs in merge_content(to_publish, current_head):
266
+ if cs.obsolete():
267
+ fail = True
268
+ fail_msg = "is obsolete"
269
+ if cs.isunstable():
270
+ fail = True
271
+ fail_msg = "is unstable"
272
+ if fail:
273
+ context.abort(StatusCode.FAILED_PRECONDITION,
274
+ f"not a fast-forward (changeset "
275
+ f"{cs.hex().decode('ascii')} {fail_msg})")
276
+
277
+ if old_id and old_id != current_head.hex().decode('ascii'):
278
+ # We did not need to resolve before this, but now we do because
279
+ # Gitaly has a specific error if resolution fails
280
+ if changeset_by_commit_id_abort(repo, old_id, context) is None:
281
+ context.abort(StatusCode.INVALID_ARGUMENT,
282
+ "cannot resolve expected old object ID: "
283
+ "reference not found")
284
+ # no point trying to match the Gitaly error details: we
285
+ # have the much better structured error
286
+ structured_abort(context,
287
+ StatusCode.FAILED_PRECONDITION,
288
+ "expected_old_oid mismatch",
289
+ UserFFBranchError(
290
+ reference_update=ReferenceUpdateError(
291
+ # Gitaly doesn't fill in `reference_name`
292
+ old_oid=old_id,
293
+ new_oid=to_publish.hex().decode('ascii'),
294
+ )))
295
+
296
+ if ancestor(to_publish, current_head) != current_head.rev():
297
+ context.abort(StatusCode.FAILED_PRECONDITION,
298
+ "not fast forward")
299
+
300
+ # TODO use phases.advanceboundary directly? Check cache invalidations
301
+ # carefully. ('phases' command is a pain to call and does lots of
302
+ # unnecessary stuff).
303
+ logger.info("All checks passed, now publishing %r, "
304
+ "mirroring to Git=%r", to_publish, with_hg_git)
305
+ self.publish(to_publish, context)
306
+ return UserFFBranchResponse(branch_update=OperationBranchUpdate(
307
+ commit_id=to_publish.hex().decode('ascii'),
308
+ ))
309
+
310
+ def UserCommitFiles(self,
311
+ request: UserCommitFilesRequest,
312
+ context) -> UserCommitFilesResponse:
313
+ logger = LoggerAdapter(base_logger, context)
314
+ first_req = next(request)
315
+ if not first_req.HasField('header'):
316
+ context.abort(
317
+ StatusCode.INVALID_ARGUMENT,
318
+ "empty UserCommitFilesRequestHeader"
319
+ )
320
+ commit_header = first_req.header
321
+ grpc_repo = commit_header.repository
322
+ repo = self.load_repo(grpc_repo, context,
323
+ for_mutation_by=commit_header.user)
324
+ repo_created = branch_created = False
325
+
326
+ gl_branch = commit_header.branch_name
327
+ if not gl_branch:
328
+ context.abort(StatusCode.INVALID_ARGUMENT,
329
+ "empty BranchName")
330
+
331
+ start_sha = commit_header.start_sha
332
+ if start_sha:
333
+ start_rev = start_sha.encode('ascii')
334
+ elif commit_header.start_branch_name:
335
+ start_rev = commit_header.start_branch_name
336
+ else:
337
+ start_rev = gl_branch
338
+
339
+ parsed = parse_gitlab_branch(gl_branch)
340
+ if parsed is None:
341
+ context.abort(StatusCode.INVALID_ARGUMENT,
342
+ "Only named branches and topics are supported "
343
+ "(not bookmarks in particular)")
344
+ hg_branch, topic = parsed
345
+ to_publish = (topic is None
346
+ and repo.ui.configbool(b'experimental',
347
+ b'topic.publish-bare-branch'))
348
+
349
+ start_ctx = gitlab_revision_changeset(repo, start_rev)
350
+ if start_ctx is None:
351
+ if len(repo) > 0:
352
+ context.abort(StatusCode.INTERNAL,
353
+ "Unresolvable start ref or commit")
354
+ else:
355
+ repo_created = branch_created = True
356
+ if commit_header.branch_name:
357
+ old_oid = commit_header.expected_old_oid.encode('ascii')
358
+ if old_oid and start_ctx.hex() != old_oid:
359
+ context.abort(StatusCode.INVALID_ARGUMENT,
360
+ "wrong old oid")
361
+
362
+ if to_publish and self.heptapod_permission != 'publish':
363
+ context.abort(
364
+ StatusCode.PERMISSION_DENIED,
365
+ "insufficient permissions for publication",
366
+ )
367
+
368
+ topic_cmd = cmdutil.findcmd(b'topics', commands.table)[1][0]
369
+ with self.working_dir(gl_repo=grpc_repo,
370
+ repo=repo,
371
+ changeset=start_ctx,
372
+ context=context) as wd:
373
+ ui = wd.repo.ui
374
+ # TODO should be set for all mutations
375
+ ui.setconfig(b'experimental.evolution', b'allowunstable', False)
376
+
377
+ logger.info("Preparing commit in working directory %s", wd.path)
378
+ if start_ctx is None or start_ctx.branch() != hg_branch:
379
+ logger.debug("Setting Mercurial branch to %r", hg_branch)
380
+ wd.repo.dirstate.setbranch(hg_branch, None)
381
+ branch_created = True
382
+ if topic is not None and (start_ctx is None
383
+ or start_ctx.topic() != topic):
384
+ logger.debug("Setting topic to %r", topic)
385
+ topic_cmd(ui, wd.repo, topic)
386
+ branch_created = True
387
+
388
+ content_handler = None
389
+ changed_files = set()
390
+ for req in request:
391
+ if not req.HasField('action'):
392
+ context.abort(
393
+ StatusCode.INTERNAL, # See Comparison Test
394
+ "unhandled action payload type: <nil>"
395
+ )
396
+ action = req.action
397
+ if action.HasField('content'):
398
+ if content_handler is None:
399
+ context.abort(
400
+ StatusCode.INTERNAL, # See Comparison Test
401
+ "content sent before action"
402
+ )
403
+ else:
404
+ logger.debug("UserCommitFiles content=%r",
405
+ action.content)
406
+ content_handler.write(action.content)
407
+ elif action.HasField('header'):
408
+ logger.info("UserCommitFiles action header: %r",
409
+ action.header)
410
+ if content_handler is not None:
411
+ content_handler.close()
412
+ content_handler = None
413
+ header = action.header
414
+ UserCommitFilesAction(
415
+ context=context,
416
+ header=header,
417
+ working_dir=wd,
418
+ changed_files=changed_files,
419
+ )()
420
+
421
+ if header.action in (ActionType.CREATE,
422
+ ActionType.UPDATE):
423
+ content_handler = UserCommitFilesContent(wd, header)
424
+ else:
425
+ content_handler = None
426
+
427
+ if content_handler is not None:
428
+ content_handler.close()
429
+
430
+ def do_commit(ui, repo, message, match, opts):
431
+ commit_user = b'%s <%s>' % (
432
+ commit_header.commit_author_name,
433
+ commit_header.commit_author_email
434
+ )
435
+ return repo.commit(message,
436
+ commit_user,
437
+ (time.time(), 0),
438
+ match=match,
439
+ editor=False,
440
+ extra=None,
441
+ )
442
+
443
+ logger.info("Performing commit")
444
+ with wd.repo.wlock(), wd.repo.lock():
445
+ try:
446
+ new_node = cmdutil.commit(
447
+ ui, wd.repo, do_commit,
448
+ changed_files,
449
+ {b'addremove': True,
450
+ b'message': commit_header.commit_message}
451
+ )
452
+ if to_publish and new_node is not None:
453
+ logger.info("Commit done, now publishing (no topic)")
454
+ self.publish(wd.repo[new_node], context)
455
+ except Exception as exc:
456
+ msg = str(exc)
457
+ code = StatusCode.INTERNAL
458
+ if 'multiple heads' in msg: # shame but not much better
459
+ code = StatusCode.INVALID_ARGUMENT
460
+ context.abort(code, msg)
461
+
462
+ if new_node is None:
463
+ return UserCommitFilesResponse()
464
+
465
+ return UserCommitFilesResponse(
466
+ branch_update=OperationBranchUpdate(
467
+ commit_id=node.hex(new_node).decode('ascii'),
468
+ repo_created=repo_created,
469
+ branch_created=branch_created,
470
+ )
471
+ )
472
+
473
+
474
+ class UserCommitFilesAction:
475
+
476
+ def __init__(self, context, header, working_dir, changed_files):
477
+ self.context = context
478
+ self.header = header
479
+ self.changed_files = changed_files
480
+
481
+ wd = self.working_dir = working_dir
482
+ relpath = self.relpath = header.file_path
483
+ self.validate(relpath)
484
+ self.abspath = wd.file_path(relpath)
485
+
486
+ def __call__(self):
487
+ action = self.header.action
488
+ if action == ActionType.CREATE:
489
+ return self.create()
490
+ elif action == ActionType.CREATE_DIR:
491
+ return self.create_dir()
492
+ elif action == ActionType.UPDATE:
493
+ return self.update()
494
+ elif action == ActionType.MOVE:
495
+ return self.move()
496
+ elif action == ActionType.DELETE:
497
+ return self.delete()
498
+ elif action == ActionType.CHMOD:
499
+ return self.chmod()
500
+
501
+ def validate(self, relpath):
502
+ if os.path.isabs(relpath):
503
+ index_error(
504
+ self.context,
505
+ status_code='INVALID_ARGUMENT',
506
+ error_type='ERROR_TYPE_INVALID_PATH',
507
+ msg='absolute',
508
+ path=relpath,
509
+ )
510
+
511
+ for p in FORBIDDEN_IN_PATHS:
512
+ if p in relpath:
513
+ relpath_str = relpath.decode('utf-8', 'surrogateescape')
514
+
515
+ if p == DIRECTORY_CLIMB_UP:
516
+ # ill-named by upstream
517
+ error_type = 'ERROR_TYPE_DIRECTORY_TRAVERSAL'
518
+ msg = 'Path cannot include directory traversal'
519
+ else:
520
+ error_type = 'ERROR_TYPE_INVALID_PATH'
521
+ msg = f'invalid path: "{relpath_str}"'
522
+
523
+ index_error(
524
+ self.context,
525
+ status_code='INVALID_ARGUMENT',
526
+ error_type=error_type,
527
+ msg=msg,
528
+ path=relpath,
529
+ )
530
+
531
+ def create(self):
532
+ if os.path.exists(self.abspath):
533
+ index_error(
534
+ self.context,
535
+ status_code='ALREADY_EXISTS',
536
+ error_type='ERROR_TYPE_FILE_EXISTS',
537
+ msg="A file with this name already exists",
538
+ path=self.relpath,
539
+ )
540
+ # Gitaly does this as well
541
+ os.makedirs(os.path.dirname(self.abspath), exist_ok=True)
542
+ self.changed_files.add(self.abspath)
543
+ # actual creation to be done by content handler opening the file
544
+
545
+ def require_file_existence(self, abspath=None, relpath=None):
546
+ if abspath is None:
547
+ abspath = self.abspath
548
+ relpath = self.relpath
549
+
550
+ if not os.path.exists(abspath):
551
+ index_error(
552
+ self.context,
553
+ status_code='NOT_FOUND',
554
+ error_type='ERROR_TYPE_FILE_NOT_FOUND',
555
+ msg="A file with this name doesn't exist",
556
+ path=relpath
557
+ )
558
+
559
+ def require_file_absence(self, abspath=None, relpath=None):
560
+ if abspath is None:
561
+ abspath = self.abspath
562
+ relpath = self.relpath
563
+
564
+ if os.path.exists(abspath):
565
+ if os.path.isdir(abspath):
566
+ error_type = 'ERROR_TYPE_DIRECTORY_EXISTS'
567
+ kind = "directory"
568
+ else:
569
+ error_type = 'ERROR_TYPE_FILE_EXISTS'
570
+ kind = "file"
571
+ index_error(
572
+ self.context,
573
+ status_code='ALREADY_EXISTS',
574
+ error_type=error_type,
575
+ msg=f"A {kind} with this name already exists",
576
+ path=relpath,
577
+ )
578
+
579
+ def update(self):
580
+ self.require_file_existence()
581
+ self.changed_files.add(self.abspath)
582
+ # writing to be done by content handler
583
+
584
+ def create_dir(self):
585
+ """Create a directory by putting an empty file in it."""
586
+ self.require_file_absence()
587
+ abspath = self.abspath
588
+
589
+ os.makedirs(abspath, exist_ok=False)
590
+ keep = os.path.join(abspath, b'.hgkeep')
591
+ open(keep, 'ab').close()
592
+ self.changed_files.add(keep)
593
+
594
+ def move(self):
595
+ self.require_file_absence()
596
+ abspath = self.abspath
597
+ prev_relpath = self.header.previous_path
598
+ self.validate(prev_relpath)
599
+ prev_abspath = self.working_dir.file_path(prev_relpath)
600
+ self.require_file_existence(abspath=prev_abspath,
601
+ relpath=prev_relpath)
602
+
603
+ repo = self.working_dir.repo
604
+ with repo.wlock(), repo.dirstate.changing_files(repo):
605
+ cmdutil.copy(repo.ui, repo, [prev_abspath, abspath],
606
+ opts={}, rename=True)
607
+ self.changed_files.add(prev_abspath)
608
+ self.changed_files.add(abspath)
609
+
610
+ def delete(self):
611
+ self.require_file_existence()
612
+
613
+ repo = self.working_dir.repo
614
+ with repo.wlock(), repo.dirstate.changing_files(repo):
615
+ m = scmutil.match(repo[None], [self.abspath], {})
616
+ uipathfn = scmutil.getuipathfn(repo,
617
+ legacyrelativevalue=True)
618
+ cmdutil.remove(repo.ui, repo, m,
619
+ prefix=b"", uipathfn=uipathfn,
620
+ after=False, force=False,
621
+ subrepos=None, dryrun=False,
622
+ )
623
+ self.changed_files.add(self.abspath)
624
+
625
+ def chmod(self):
626
+ self.require_file_existence()
627
+ set_hg_executable(self.abspath, self.header.execute_filemode)
628
+ self.changed_files.add(self.abspath)
629
+
630
+
631
+ class UserCommitFilesContent:
632
+
633
+ def __init__(self, workdir, header):
634
+ self.file_path = workdir.file_path(header.file_path)
635
+ self.fobj = open(self.file_path, 'wb')
636
+ self.make_hg_executable = (header.action == ActionType.CREATE
637
+ and header.execute_filemode)
638
+ self.base64 = header.base64_content
639
+
640
+ def close(self):
641
+ self.fobj.close()
642
+ if self.make_hg_executable:
643
+ set_hg_executable(self.file_path, True)
644
+
645
+ def write(self, content):
646
+ if self.base64:
647
+ content = b64decode(content)
648
+ self.fobj.write(content)
649
+
650
+
651
+ def set_hg_executable(path, executable):
652
+ # only the executable bit is actually tracked by Mercurial
653
+ os.chmod(path, 0o700 if executable else 0o600)
@@ -11,6 +11,7 @@ import shutil
11
11
  from hgitaly import feature
12
12
  from hgitaly.logging import CORRELATION_ID_MD_KEY
13
13
  from hgitaly.servicer import (
14
+ HEPTAPOD_PERMISSION_KEY,
14
15
  NATIVE_PROJECT_MD_KEY,
15
16
  PY_HEPTAPOD_SKIP_HOOKS,
16
17
  SKIP_HOOKS_MD_KEY,
@@ -180,9 +181,6 @@ class ServiceFixture:
180
181
 
181
182
  def grpc_metadata(self):
182
183
  """Bake method call metadata.
183
-
184
- Takes care of feature flags only as of this writing, but could also
185
- be useful with other metadata (correlation id etc.)
186
184
  """
187
185
  metadata = feature.as_grpc_metadata(self.feature_flags)
188
186
  corr_id = self.correlation_id
@@ -200,6 +198,9 @@ class MutationServiceFixture(ServiceFixture):
200
198
  It has a default :attr:`user`, generates the appropriate gRPC metadata,
201
199
  etc.
202
200
  """
201
+
202
+ heptapod_permission = None
203
+
203
204
  def __enter__(self):
204
205
  super(MutationServiceFixture, self).__enter__()
205
206
  self.ref_stub = RefServiceStub(self.grpc_channel)
@@ -215,6 +216,9 @@ class MutationServiceFixture(ServiceFixture):
215
216
  native = getattr(self, 'hg_native', True)
216
217
  mds.append((NATIVE_PROJECT_MD_KEY, str(native)))
217
218
  mds.append((SKIP_HOOKS_MD_KEY, 'true'))
219
+ perm = self.heptapod_permission
220
+ if perm is not None:
221
+ mds.append((HEPTAPOD_PERMISSION_KEY, perm))
218
222
  return mds
219
223
 
220
224
  def list_refs(self, repo=None):