test_dumb.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  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 public 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 os
  23. import sys
  24. import tempfile
  25. import threading
  26. from http.server import HTTPServer, SimpleHTTPRequestHandler
  27. from unittest import skipUnless
  28. from dulwich.client import HttpGitClient
  29. from dulwich.porcelain import clone
  30. from dulwich.repo import Repo
  31. from tests.compat.utils import (
  32. CompatTestCase,
  33. rmtree_ro,
  34. run_git_or_fail,
  35. )
  36. class DumbHTTPRequestHandler(SimpleHTTPRequestHandler):
  37. """HTTP request handler for dumb git protocol."""
  38. def __init__(self, *args, directory=None, **kwargs):
  39. self.directory = directory
  40. super().__init__(*args, directory=directory, **kwargs)
  41. def log_message(self, format, *args):
  42. # Suppress logging during tests
  43. pass
  44. class DumbHTTPGitServer:
  45. """Simple HTTP server for serving git repositories."""
  46. def __init__(self, root_path, port=0):
  47. self.root_path = root_path
  48. def handler(*args, **kwargs):
  49. return DumbHTTPRequestHandler(*args, directory=root_path, **kwargs)
  50. self.server = HTTPServer(("127.0.0.1", port), handler)
  51. self.server.allow_reuse_address = True
  52. self.port = self.server.server_port
  53. self.thread = None
  54. def start(self):
  55. """Start the HTTP server in a background thread."""
  56. self.thread = threading.Thread(target=self.server.serve_forever)
  57. self.thread.daemon = True
  58. self.thread.start()
  59. # Give the server a moment to start and verify it's listening
  60. import socket
  61. import time
  62. for i in range(50): # Try for up to 5 seconds
  63. try:
  64. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  65. sock.settimeout(0.1)
  66. result = sock.connect_ex(("127.0.0.1", self.port))
  67. sock.close()
  68. if result == 0:
  69. return # Server is ready
  70. except OSError:
  71. pass
  72. time.sleep(0.1)
  73. # If we get here, server failed to start
  74. raise RuntimeError(f"HTTP server failed to start on port {self.port}")
  75. def stop(self):
  76. """Stop the HTTP server."""
  77. self.server.shutdown()
  78. if self.thread:
  79. self.thread.join()
  80. @property
  81. def url(self):
  82. """Get the base URL for this server."""
  83. return f"http://127.0.0.1:{self.port}"
  84. class DumbHTTPClientTests(CompatTestCase):
  85. """Tests for dumb HTTP client against real git repositories."""
  86. def setUp(self):
  87. super().setUp()
  88. # Create a temporary directory for test repos
  89. self.temp_dir = tempfile.mkdtemp()
  90. self.addCleanup(rmtree_ro, self.temp_dir)
  91. # Create origin repository
  92. self.origin_path = os.path.join(self.temp_dir, "origin.git")
  93. os.mkdir(self.origin_path)
  94. run_git_or_fail(["init", "--bare"], cwd=self.origin_path)
  95. # Create a working repository to push from
  96. self.work_path = os.path.join(self.temp_dir, "work")
  97. os.mkdir(self.work_path)
  98. run_git_or_fail(["init"], cwd=self.work_path)
  99. run_git_or_fail(
  100. ["config", "user.email", "test@example.com"], cwd=self.work_path
  101. )
  102. run_git_or_fail(["config", "user.name", "Test User"], cwd=self.work_path)
  103. # Create initial commit
  104. test_file = os.path.join(self.work_path, "test.txt")
  105. with open(test_file, "w") as f:
  106. f.write("Hello, world!\n")
  107. run_git_or_fail(["add", "test.txt"], cwd=self.work_path)
  108. run_git_or_fail(["commit", "-m", "Initial commit"], cwd=self.work_path)
  109. # Push to origin
  110. run_git_or_fail(
  111. ["remote", "add", "origin", self.origin_path], cwd=self.work_path
  112. )
  113. run_git_or_fail(["push", "origin", "master"], cwd=self.work_path)
  114. # Update server info for dumb HTTP
  115. run_git_or_fail(["update-server-info"], cwd=self.origin_path)
  116. # Start HTTP server
  117. self.server = DumbHTTPGitServer(self.origin_path)
  118. self.server.start()
  119. self.addCleanup(self.server.stop)
  120. @skipUnless(
  121. sys.platform != "win32", "git clone from Python HTTPServer fails on Windows"
  122. )
  123. def test_clone_dumb(self):
  124. dest_path = os.path.join(self.temp_dir, "cloned")
  125. repo = clone(self.server.url, dest_path)
  126. assert b"HEAD" in repo
  127. def test_clone_from_dumb_http(self):
  128. """Test cloning from a dumb HTTP server."""
  129. dest_path = os.path.join(self.temp_dir, "cloned")
  130. # Use dulwich to clone via dumb HTTP
  131. client = HttpGitClient(self.server.url)
  132. # Create destination repo
  133. dest_repo = Repo.init(dest_path, mkdir=True)
  134. try:
  135. # Fetch from dumb HTTP
  136. def determine_wants(refs):
  137. return [
  138. sha for ref, sha in refs.items() if ref.startswith(b"refs/heads/")
  139. ]
  140. result = client.fetch("/", dest_repo, determine_wants=determine_wants)
  141. # Update refs
  142. for ref, sha in result.refs.items():
  143. if ref.startswith(b"refs/heads/"):
  144. dest_repo.refs[ref] = sha
  145. # Checkout files
  146. dest_repo.reset_index()
  147. # Verify the clone
  148. test_file = os.path.join(dest_path, "test.txt")
  149. self.assertTrue(os.path.exists(test_file))
  150. with open(test_file) as f:
  151. self.assertEqual("Hello, world!\n", f.read())
  152. finally:
  153. # Ensure repo is closed before cleanup
  154. dest_repo.close()
  155. @skipUnless(
  156. sys.platform != "win32", "git clone from Python HTTPServer fails on Windows"
  157. )
  158. def test_fetch_new_commit_from_dumb_http(self):
  159. """Test fetching new commits from a dumb HTTP server."""
  160. # First clone the repository
  161. dest_path = os.path.join(self.temp_dir, "cloned")
  162. run_git_or_fail(["clone", self.server.url, dest_path])
  163. # Make a new commit in the origin
  164. test_file2 = os.path.join(self.work_path, "test2.txt")
  165. with open(test_file2, "w") as f:
  166. f.write("Second file\n")
  167. run_git_or_fail(["add", "test2.txt"], cwd=self.work_path)
  168. run_git_or_fail(["commit", "-m", "Second commit"], cwd=self.work_path)
  169. run_git_or_fail(["push", "origin", "master"], cwd=self.work_path)
  170. # Update server info again
  171. run_git_or_fail(["update-server-info"], cwd=self.origin_path)
  172. # Fetch with dulwich client
  173. client = HttpGitClient(self.server.url)
  174. dest_repo = Repo(dest_path)
  175. try:
  176. old_refs = dest_repo.get_refs()
  177. def determine_wants(refs):
  178. wants = []
  179. for ref, sha in refs.items():
  180. if ref.startswith(b"refs/heads/") and sha != old_refs.get(ref):
  181. wants.append(sha)
  182. return wants
  183. result = client.fetch("/", dest_repo, determine_wants=determine_wants)
  184. # Update refs
  185. for ref, sha in result.refs.items():
  186. if ref.startswith(b"refs/heads/"):
  187. dest_repo.refs[ref] = sha
  188. # Reset to new commit
  189. dest_repo.reset_index()
  190. # Verify the new file exists
  191. test_file2_dest = os.path.join(dest_path, "test2.txt")
  192. self.assertTrue(os.path.exists(test_file2_dest))
  193. with open(test_file2_dest) as f:
  194. self.assertEqual("Second file\n", f.read())
  195. finally:
  196. # Ensure repo is closed before cleanup
  197. dest_repo.close()
  198. @skipUnless(
  199. os.name == "posix", "Skipping on non-POSIX systems due to permission handling"
  200. )
  201. def test_fetch_from_dumb_http_with_tags(self):
  202. """Test fetching tags from a dumb HTTP server."""
  203. # Create a tag in origin
  204. run_git_or_fail(["tag", "-a", "v1.0", "-m", "Version 1.0"], cwd=self.work_path)
  205. run_git_or_fail(["push", "origin", "v1.0"], cwd=self.work_path)
  206. # Update server info
  207. run_git_or_fail(["update-server-info"], cwd=self.origin_path)
  208. # Clone with dulwich
  209. dest_path = os.path.join(self.temp_dir, "cloned_with_tags")
  210. dest_repo = Repo.init(dest_path, mkdir=True)
  211. try:
  212. client = HttpGitClient(self.server.url)
  213. def determine_wants(refs):
  214. return [
  215. sha
  216. for ref, sha in refs.items()
  217. if ref.startswith((b"refs/heads/", b"refs/tags/"))
  218. ]
  219. result = client.fetch("/", dest_repo, determine_wants=determine_wants)
  220. # Update refs
  221. for ref, sha in result.refs.items():
  222. dest_repo.refs[ref] = sha
  223. # Check that the tag exists
  224. self.assertIn(b"refs/tags/v1.0", dest_repo.refs)
  225. # Verify tag points to the right commit
  226. tag_sha = dest_repo.refs[b"refs/tags/v1.0"]
  227. tag_obj = dest_repo[tag_sha]
  228. self.assertEqual(b"tag", tag_obj.type_name)
  229. finally:
  230. # Ensure repo is closed before cleanup
  231. dest_repo.close()