test_lfs.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. #!/usr/bin/python
  2. # test_lfs.py -- Compatibility tests for LFS.
  3. # Copyright (C) 2025 Dulwich contributors
  4. #
  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 LFS functionality between dulwich and git-lfs."""
  22. import os
  23. import shutil
  24. import subprocess
  25. import tempfile
  26. from unittest import skipUnless
  27. from dulwich import porcelain
  28. from dulwich.lfs import LFSPointer
  29. from dulwich.porcelain import lfs_clean, lfs_init, lfs_smudge, lfs_track
  30. from .utils import CompatTestCase, run_git_or_fail
  31. def git_lfs_version():
  32. """Get git-lfs version tuple."""
  33. try:
  34. output = run_git_or_fail(["lfs", "version"])
  35. # Example output: "git-lfs/3.0.2 (GitHub; linux amd64; go 1.17.2)"
  36. version_str = output.split(b"/")[1].split()[0]
  37. return tuple(map(int, version_str.decode().split(".")))
  38. except (OSError, subprocess.CalledProcessError, AssertionError):
  39. return None
  40. class LFSCompatTestCase(CompatTestCase):
  41. """Base class for LFS compatibility tests."""
  42. min_git_version = (2, 0, 0) # git-lfs requires git 2.0+
  43. def setUp(self):
  44. super().setUp()
  45. if git_lfs_version() is None:
  46. self.skipTest("git-lfs not available")
  47. def assertPointerEquals(self, pointer1, pointer2):
  48. """Assert two LFS pointers are equivalent."""
  49. self.assertEqual(pointer1.oid, pointer2.oid)
  50. self.assertEqual(pointer1.size, pointer2.size)
  51. def make_temp_dir(self):
  52. """Create a temporary directory that will be cleaned up."""
  53. temp_dir = tempfile.mkdtemp()
  54. self.addCleanup(shutil.rmtree, temp_dir)
  55. return temp_dir
  56. class LFSInitCompatTest(LFSCompatTestCase):
  57. """Tests for LFS initialization compatibility."""
  58. def test_lfs_init_dulwich(self):
  59. """Test that dulwich lfs_init is compatible with git-lfs."""
  60. # Initialize with dulwich
  61. repo_dir = self.make_temp_dir()
  62. run_git_or_fail(["init"], cwd=repo_dir)
  63. lfs_init(repo_dir)
  64. # Verify with git-lfs
  65. output = run_git_or_fail(["lfs", "env"], cwd=repo_dir)
  66. self.assertIn("git config filter.lfs.clean", output)
  67. self.assertIn("git config filter.lfs.smudge", output)
  68. def test_lfs_init_git(self):
  69. """Test that git-lfs init is compatible with dulwich."""
  70. # Initialize with git-lfs
  71. repo_dir = self.make_temp_dir()
  72. run_git_or_fail(["init"], cwd=repo_dir)
  73. run_git_or_fail(["lfs", "install"], cwd=repo_dir)
  74. # Verify with dulwich
  75. config = porcelain.get_config_stack(repo_dir)
  76. self.assertEqual(
  77. config.get(("filter", "lfs"), "clean").decode(), "git-lfs clean -- %f"
  78. )
  79. self.assertEqual(
  80. config.get(("filter", "lfs"), "smudge").decode(), "git-lfs smudge -- %f"
  81. )
  82. class LFSTrackCompatTest(LFSCompatTestCase):
  83. """Tests for LFS tracking compatibility."""
  84. def test_track_dulwich(self):
  85. """Test that dulwich lfs_track is compatible with git-lfs."""
  86. repo_dir = self.make_temp_dir()
  87. run_git_or_fail(["init"], cwd=repo_dir)
  88. lfs_init(repo_dir)
  89. # Track with dulwich
  90. lfs_track(repo_dir, ["*.bin", "*.dat"])
  91. # Verify with git-lfs
  92. output = run_git_or_fail(["lfs", "track"], cwd=repo_dir)
  93. self.assertIn("*.bin", output)
  94. self.assertIn("*.dat", output)
  95. def test_track_git(self):
  96. """Test that git-lfs track is compatible with dulwich."""
  97. repo_dir = self.make_temp_dir()
  98. run_git_or_fail(["init"], cwd=repo_dir)
  99. run_git_or_fail(["lfs", "install"], cwd=repo_dir)
  100. # Track with git-lfs
  101. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  102. run_git_or_fail(["lfs", "track", "*.dat"], cwd=repo_dir)
  103. # Verify with dulwich
  104. gitattributes_path = os.path.join(repo_dir, ".gitattributes")
  105. with open(gitattributes_path, "rb") as f:
  106. content = f.read().decode()
  107. self.assertIn("*.bin filter=lfs", content)
  108. self.assertIn("*.dat filter=lfs", content)
  109. class LFSFileOperationsCompatTest(LFSCompatTestCase):
  110. """Tests for LFS file operations compatibility."""
  111. def test_add_commit_dulwich(self):
  112. """Test adding and committing LFS files with dulwich."""
  113. repo_dir = self.make_temp_dir()
  114. run_git_or_fail(["init"], cwd=repo_dir)
  115. lfs_init(repo_dir)
  116. lfs_track(repo_dir, ["*.bin"])
  117. # Create and add a large file
  118. test_file = os.path.join(repo_dir, "test.bin")
  119. test_content = b"x" * 1024 * 1024 # 1MB
  120. with open(test_file, "wb") as f:
  121. f.write(test_content)
  122. # Add with dulwich
  123. porcelain.add(repo_dir, [test_file])
  124. porcelain.commit(repo_dir, message=b"Add LFS file")
  125. # Verify with git-lfs
  126. output = run_git_or_fail(["lfs", "ls-files"], cwd=repo_dir)
  127. self.assertIn("test.bin", output)
  128. # Check pointer file in git
  129. output = run_git_or_fail(["show", "HEAD:test.bin"], cwd=repo_dir)
  130. self.assertIn("version https://git-lfs.github.com/spec/v1", output)
  131. self.assertIn("oid sha256:", output)
  132. self.assertIn("size 1048576", output)
  133. def test_add_commit_git(self):
  134. """Test adding and committing LFS files with git-lfs."""
  135. repo_dir = self.make_temp_dir()
  136. run_git_or_fail(["init"], cwd=repo_dir)
  137. run_git_or_fail(["lfs", "install"], cwd=repo_dir)
  138. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  139. run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
  140. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
  141. # Create and add a large file
  142. test_file = os.path.join(repo_dir, "test.bin")
  143. test_content = b"y" * 1024 * 1024 # 1MB
  144. with open(test_file, "wb") as f:
  145. f.write(test_content)
  146. # Add with git-lfs
  147. run_git_or_fail(["add", "test.bin"], cwd=repo_dir)
  148. run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=repo_dir)
  149. # Verify with dulwich
  150. repo = porcelain.open_repo(repo_dir)
  151. tree = repo[repo.head()].tree
  152. entry = repo.object_store[tree][b"test.bin"]
  153. blob = repo.object_store[entry.binsha]
  154. pointer = LFSPointer.from_bytes(blob.data)
  155. self.assertEqual(pointer.size, 1048576)
  156. def test_checkout_dulwich(self):
  157. """Test checking out LFS files with dulwich."""
  158. # Create repo with git-lfs
  159. repo_dir = self.make_temp_dir()
  160. run_git_or_fail(["init"], cwd=repo_dir)
  161. run_git_or_fail(["lfs", "install"], cwd=repo_dir)
  162. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  163. run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
  164. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
  165. # Add LFS file
  166. test_file = os.path.join(repo_dir, "test.bin")
  167. test_content = b"z" * 1024 * 1024 # 1MB
  168. with open(test_file, "wb") as f:
  169. f.write(test_content)
  170. run_git_or_fail(["add", "test.bin"], cwd=repo_dir)
  171. run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=repo_dir)
  172. # Remove working copy
  173. os.remove(test_file)
  174. # Checkout with dulwich
  175. porcelain.reset(repo_dir, mode="hard")
  176. # Verify file contents
  177. with open(test_file, "rb") as f:
  178. content = f.read()
  179. self.assertEqual(content, test_content)
  180. class LFSPointerCompatTest(LFSCompatTestCase):
  181. """Tests for LFS pointer file compatibility."""
  182. def test_pointer_format_dulwich(self):
  183. """Test that dulwich creates git-lfs compatible pointers."""
  184. repo_dir = self.make_temp_dir()
  185. test_content = b"test content for LFS"
  186. # Create pointer with dulwich
  187. pointer_data = lfs_clean(test_content)
  188. # Parse with git-lfs (create a file and check)
  189. test_file = os.path.join(repo_dir, "test_pointer")
  190. with open(test_file, "wb") as f:
  191. f.write(pointer_data)
  192. # Verify pointer format
  193. with open(test_file, "rb") as f:
  194. lines = f.read().decode().strip().split("\n")
  195. self.assertEqual(lines[0], "version https://git-lfs.github.com/spec/v1")
  196. self.assertTrue(lines[1].startswith("oid sha256:"))
  197. self.assertTrue(lines[2].startswith("size "))
  198. def test_pointer_format_git(self):
  199. """Test that dulwich can parse git-lfs pointers."""
  200. # Create a git-lfs pointer manually
  201. oid = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  202. size = 12345
  203. pointer_content = f"version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n"
  204. # Parse with dulwich
  205. pointer = LFSPointer.from_bytes(pointer_content.encode())
  206. self.assertEqual(pointer.oid, oid)
  207. self.assertEqual(pointer.size, size)
  208. class LFSFilterCompatTest(LFSCompatTestCase):
  209. """Tests for LFS filter operations compatibility."""
  210. def test_clean_filter_compat(self):
  211. """Test clean filter compatibility between dulwich and git-lfs."""
  212. test_content = b"x" * 1000
  213. # Clean with dulwich
  214. dulwich_pointer = lfs_clean(test_content)
  215. # Clean with git-lfs (simulate)
  216. # Since we can't easily invoke git-lfs clean directly,
  217. # we'll test that the pointer format is correct
  218. self.assertIn(b"version https://git-lfs.github.com/spec/v1", dulwich_pointer)
  219. self.assertIn(b"oid sha256:", dulwich_pointer)
  220. self.assertIn(b"size 1000", dulwich_pointer)
  221. def test_smudge_filter_compat(self):
  222. """Test smudge filter compatibility between dulwich and git-lfs."""
  223. # Create a test repo with LFS
  224. repo_dir = self.make_temp_dir()
  225. run_git_or_fail(["init"], cwd=repo_dir)
  226. lfs_init(repo_dir)
  227. # Create test content
  228. test_content = b"test data for smudge filter"
  229. pointer_data = lfs_clean(test_content)
  230. # Store object in LFS
  231. lfs_dir = os.path.join(repo_dir, ".git", "lfs")
  232. os.makedirs(lfs_dir, exist_ok=True)
  233. # Parse pointer to get oid
  234. pointer = LFSPointer.from_bytes(pointer_data)
  235. # Store object
  236. obj_dir = os.path.join(lfs_dir, "objects", pointer.oid[:2], pointer.oid[2:4])
  237. os.makedirs(obj_dir, exist_ok=True)
  238. obj_path = os.path.join(obj_dir, pointer.oid)
  239. with open(obj_path, "wb") as f:
  240. f.write(test_content)
  241. # Test smudge
  242. smudged = lfs_smudge(repo_dir, pointer_data)
  243. self.assertEqual(smudged, test_content)
  244. class LFSCloneCompatTest(LFSCompatTestCase):
  245. """Tests for cloning repositories with LFS files."""
  246. @skipUnless(
  247. git_lfs_version() and git_lfs_version() >= (2, 0, 0),
  248. "git-lfs 2.0+ required for clone tests",
  249. )
  250. def test_clone_with_lfs(self):
  251. """Test cloning a repository with LFS files."""
  252. # Create source repo with LFS
  253. source_dir = self.make_temp_dir()
  254. run_git_or_fail(["init"], cwd=source_dir)
  255. run_git_or_fail(["lfs", "install"], cwd=source_dir)
  256. run_git_or_fail(["lfs", "track", "*.bin"], cwd=source_dir)
  257. run_git_or_fail(["add", ".gitattributes"], cwd=source_dir)
  258. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=source_dir)
  259. # Add LFS file
  260. test_file = os.path.join(source_dir, "test.bin")
  261. test_content = b"w" * 1024 * 1024 # 1MB
  262. with open(test_file, "wb") as f:
  263. f.write(test_content)
  264. run_git_or_fail(["add", "test.bin"], cwd=source_dir)
  265. run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=source_dir)
  266. # Clone with dulwich
  267. target_dir = self.make_temp_dir()
  268. porcelain.clone(source_dir, target_dir)
  269. # Verify LFS file exists as pointer
  270. cloned_file = os.path.join(target_dir, "test.bin")
  271. with open(cloned_file, "rb") as f:
  272. content = f.read()
  273. # Should be a pointer, not the full content
  274. self.assertLess(len(content), 1000) # Pointer is much smaller
  275. self.assertIn(b"version https://git-lfs.github.com/spec/v1", content)
  276. if __name__ == "__main__":
  277. import unittest
  278. unittest.main()