test_dumb.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. # test_dumb.py -- Compatibility tests for dumb HTTP git repositories
  2. # Copyright (C) 2025 Dulwich contributors
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as published by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Compatibility tests for dumb HTTP git repositories."""
  22. import io
  23. import os
  24. import sys
  25. import tempfile
  26. import threading
  27. from http.server import HTTPServer, SimpleHTTPRequestHandler
  28. from unittest import skipUnless
  29. from dulwich.client import HttpGitClient
  30. from dulwich.porcelain import clone
  31. from dulwich.repo import Repo
  32. from tests.compat.utils import (
  33. CompatTestCase,
  34. rmtree_ro,
  35. run_git_or_fail,
  36. )
  37. def no_op_progress(msg):
  38. """Progress callback that does nothing."""
  39. class DumbHTTPRequestHandler(SimpleHTTPRequestHandler):
  40. """HTTP request handler for dumb git protocol."""
  41. def __init__(self, *args, directory=None, **kwargs):
  42. self.directory = directory
  43. super().__init__(*args, directory=directory, **kwargs)
  44. def log_message(self, format, *args):
  45. # Suppress logging during tests
  46. pass
  47. class DumbHTTPGitServer:
  48. """Simple HTTP server for serving git repositories."""
  49. def __init__(self, root_path, port=0):
  50. self.root_path = root_path
  51. def handler(*args, **kwargs):
  52. return DumbHTTPRequestHandler(*args, directory=root_path, **kwargs)
  53. self.server = HTTPServer(("127.0.0.1", port), handler)
  54. self.server.allow_reuse_address = True
  55. self.port = self.server.server_port
  56. self.thread = None
  57. def start(self):
  58. """Start the HTTP server in a background thread."""
  59. self.thread = threading.Thread(target=self.server.serve_forever)
  60. self.thread.daemon = True
  61. self.thread.start()
  62. # Give the server a moment to start and verify it's listening
  63. import socket
  64. import time
  65. for i in range(50): # Try for up to 5 seconds
  66. try:
  67. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  68. sock.settimeout(0.1)
  69. result = sock.connect_ex(("127.0.0.1", self.port))
  70. sock.close()
  71. if result == 0:
  72. return # Server is ready
  73. except OSError:
  74. pass
  75. time.sleep(0.1)
  76. # If we get here, server failed to start
  77. raise RuntimeError(f"HTTP server failed to start on port {self.port}")
  78. def stop(self):
  79. """Stop the HTTP server."""
  80. self.server.shutdown()
  81. self.server.server_close()
  82. if self.thread:
  83. self.thread.join()
  84. @property
  85. def url(self):
  86. """Get the base URL for this server."""
  87. return f"http://127.0.0.1:{self.port}"
  88. class DumbHTTPClientNoPackTests(CompatTestCase):
  89. """Tests for dumb HTTP client against real git repositories."""
  90. with_pack = False
  91. with_missing_remote_head = False
  92. def setUp(self):
  93. super().setUp()
  94. # Create a temporary directory for test repos
  95. self.temp_dir = tempfile.mkdtemp()
  96. self.addCleanup(rmtree_ro, self.temp_dir)
  97. # Create origin repository
  98. self.origin_path = os.path.join(self.temp_dir, "origin.git")
  99. os.mkdir(self.origin_path)
  100. run_git_or_fail(["init", "--bare"], cwd=self.origin_path)
  101. # Create a working repository to push from
  102. self.work_path = os.path.join(self.temp_dir, "work")
  103. os.mkdir(self.work_path)
  104. run_git_or_fail(["init"], cwd=self.work_path)
  105. run_git_or_fail(
  106. ["config", "user.email", "test@example.com"], cwd=self.work_path
  107. )
  108. run_git_or_fail(["config", "user.name", "Test User"], cwd=self.work_path)
  109. nb_files = 10
  110. if self.with_pack:
  111. # adding more files will create a pack file in the repository
  112. nb_files = 50
  113. for i in range(nb_files):
  114. test_file = os.path.join(self.work_path, f"test{i}.txt")
  115. with open(test_file, "w") as f:
  116. f.write(f"Hello, world {i}!\n")
  117. run_git_or_fail(["add", f"test{i}.txt"], cwd=self.work_path)
  118. run_git_or_fail(["commit", "-m", f"Commit {i}"], cwd=self.work_path)
  119. # Push to origin
  120. run_git_or_fail(
  121. ["remote", "add", "origin", self.origin_path], cwd=self.work_path
  122. )
  123. run_git_or_fail(["push", "origin", "master"], cwd=self.work_path)
  124. # Update server info for dumb HTTP
  125. run_git_or_fail(["update-server-info"], cwd=self.origin_path)
  126. if self.with_missing_remote_head:
  127. os.remove(os.path.join(self.origin_path, "HEAD"))
  128. # Start HTTP server
  129. self.server = DumbHTTPGitServer(self.origin_path)
  130. self.server.start()
  131. self.addCleanup(self.server.stop)
  132. pack_dir = os.path.join(self.origin_path, "objects", "pack")
  133. if self.with_pack:
  134. assert os.listdir(pack_dir)
  135. else:
  136. assert not os.listdir(pack_dir)
  137. @skipUnless(
  138. sys.platform != "win32", "git clone from Python HTTPServer fails on Windows"
  139. )
  140. def test_clone_dumb(self):
  141. dest_path = os.path.join(self.temp_dir, "cloned")
  142. # Use a dummy errstream to suppress progress output
  143. repo = clone(self.server.url, dest_path, errstream=io.BytesIO())
  144. self.addCleanup(repo.close)
  145. assert b"HEAD" in repo
  146. def test_clone_from_dumb_http(self):
  147. """Test cloning from a dumb HTTP server."""
  148. dest_path = os.path.join(self.temp_dir, "cloned")
  149. # Use dulwich to clone via dumb HTTP
  150. client = HttpGitClient(self.server.url)
  151. # Create destination repo
  152. dest_repo = Repo.init(dest_path, mkdir=True)
  153. try:
  154. # Fetch from dumb HTTP
  155. def determine_wants(refs, depth=None):
  156. return [
  157. sha for ref, sha in refs.items() if ref.startswith(b"refs/heads/")
  158. ]
  159. result = client.fetch(
  160. "/", dest_repo, determine_wants=determine_wants, progress=no_op_progress
  161. )
  162. # Update refs
  163. for ref, sha in result.refs.items():
  164. if ref.startswith(b"refs/heads/"):
  165. dest_repo.refs[ref] = sha
  166. # Checkout files
  167. dest_repo.get_worktree().reset_index()
  168. # Verify the clone
  169. test_file = os.path.join(dest_path, "test0.txt")
  170. self.assertTrue(os.path.exists(test_file))
  171. with open(test_file) as f:
  172. self.assertEqual("Hello, world 0!\n", f.read())
  173. finally:
  174. # Ensure repo is closed before cleanup
  175. dest_repo.close()
  176. @skipUnless(
  177. sys.platform != "win32", "git clone from Python HTTPServer fails on Windows"
  178. )
  179. def test_fetch_new_commit_from_dumb_http(self):
  180. """Test fetching new commits from a dumb HTTP server."""
  181. # First clone the repository
  182. dest_path = os.path.join(self.temp_dir, "cloned")
  183. run_git_or_fail(["clone", self.server.url, dest_path])
  184. # Make a new commit in the origin
  185. test_file2 = os.path.join(self.work_path, "test2.txt")
  186. with open(test_file2, "w") as f:
  187. f.write("Second file\n")
  188. run_git_or_fail(["add", "test2.txt"], cwd=self.work_path)
  189. run_git_or_fail(["commit", "-m", "Second commit"], cwd=self.work_path)
  190. run_git_or_fail(["push", "origin", "master"], cwd=self.work_path)
  191. # Update server info again
  192. run_git_or_fail(["update-server-info"], cwd=self.origin_path)
  193. # Fetch with dulwich client
  194. client = HttpGitClient(self.server.url)
  195. dest_repo = Repo(dest_path)
  196. try:
  197. old_refs = dest_repo.get_refs()
  198. def determine_wants(refs, depth=None):
  199. wants = []
  200. for ref, sha in refs.items():
  201. if ref.startswith(b"refs/heads/") and sha != old_refs.get(ref):
  202. wants.append(sha)
  203. return wants
  204. result = client.fetch(
  205. "/", dest_repo, determine_wants=determine_wants, progress=no_op_progress
  206. )
  207. # Update refs
  208. for ref, sha in result.refs.items():
  209. if ref.startswith(b"refs/heads/"):
  210. dest_repo.refs[ref] = sha
  211. # Reset to new commit
  212. dest_repo.get_worktree().reset_index()
  213. # Verify the new file exists
  214. test_file2_dest = os.path.join(dest_path, "test2.txt")
  215. self.assertTrue(os.path.exists(test_file2_dest))
  216. with open(test_file2_dest) as f:
  217. self.assertEqual("Second file\n", f.read())
  218. finally:
  219. # Ensure repo is closed before cleanup
  220. dest_repo.close()
  221. @skipUnless(
  222. os.name == "posix", "Skipping on non-POSIX systems due to permission handling"
  223. )
  224. def test_fetch_from_dumb_http_with_tags(self):
  225. """Test fetching tags from a dumb HTTP server."""
  226. # Create a tag in origin
  227. run_git_or_fail(["tag", "-a", "v1.0", "-m", "Version 1.0"], cwd=self.work_path)
  228. run_git_or_fail(["push", "origin", "v1.0"], cwd=self.work_path)
  229. # Update server info
  230. run_git_or_fail(["update-server-info"], cwd=self.origin_path)
  231. # Clone with dulwich
  232. dest_path = os.path.join(self.temp_dir, "cloned_with_tags")
  233. dest_repo = Repo.init(dest_path, mkdir=True)
  234. try:
  235. client = HttpGitClient(self.server.url)
  236. def determine_wants(refs, depth=None):
  237. return [
  238. sha
  239. for ref, sha in refs.items()
  240. if ref.startswith((b"refs/heads/", b"refs/tags/"))
  241. ]
  242. result = client.fetch(
  243. "/", dest_repo, determine_wants=determine_wants, progress=no_op_progress
  244. )
  245. # Update refs
  246. for ref, sha in result.refs.items():
  247. dest_repo.refs[ref] = sha
  248. # Check that the tag exists
  249. self.assertIn(b"refs/tags/v1.0", dest_repo.refs)
  250. # Verify tag points to the right commit
  251. tag_sha = dest_repo.refs[b"refs/tags/v1.0"]
  252. tag_obj = dest_repo[tag_sha]
  253. self.assertEqual(b"tag", tag_obj.type_name)
  254. finally:
  255. # Ensure repo is closed before cleanup
  256. dest_repo.close()
  257. class DumbHTTPClientWithPackTests(DumbHTTPClientNoPackTests):
  258. with_pack = True
  259. class DumbHTTPClientWithMissingRemoteHEAD(DumbHTTPClientNoPackTests):
  260. with_missing_remote_head = True
  261. # we only want to test clone operation as removing the HEAD file
  262. # prevents any push operation used in tests below
  263. def test_fetch_from_dumb_http_with_tags(self):
  264. pass
  265. def test_fetch_new_commit_from_dumb_http(self):
  266. pass