test_lfs.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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 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 LFS functionality between dulwich and git-lfs."""
  22. import os
  23. import subprocess
  24. import tempfile
  25. from unittest import skipUnless
  26. from dulwich import porcelain
  27. from dulwich.lfs import LFSPointer
  28. from dulwich.porcelain import lfs_clean, lfs_init, lfs_smudge, lfs_track
  29. from .utils import CompatTestCase, rmtree_ro, run_git_or_fail
  30. def git_lfs_version():
  31. """Get git-lfs version tuple."""
  32. try:
  33. output = run_git_or_fail(["lfs", "version"])
  34. # Example output: "git-lfs/3.0.2 (GitHub; linux amd64; go 1.17.2)"
  35. version_str = output.split(b"/")[1].split()[0]
  36. return tuple(map(int, version_str.decode().split(".")))
  37. except (OSError, subprocess.CalledProcessError, AssertionError):
  38. return None
  39. class LFSCompatTestCase(CompatTestCase):
  40. """Base class for LFS compatibility tests."""
  41. min_git_version = (2, 0, 0) # git-lfs requires git 2.0+
  42. def setUp(self):
  43. super().setUp()
  44. if git_lfs_version() is None:
  45. self.skipTest("git-lfs not available")
  46. def assertPointerEquals(self, pointer1, pointer2):
  47. """Assert two LFS pointers are equivalent."""
  48. self.assertEqual(pointer1.oid, pointer2.oid)
  49. self.assertEqual(pointer1.size, pointer2.size)
  50. def make_temp_dir(self):
  51. """Create a temporary directory that will be cleaned up."""
  52. temp_dir = tempfile.mkdtemp()
  53. self.addCleanup(rmtree_ro, temp_dir)
  54. return temp_dir
  55. class LFSInitCompatTest(LFSCompatTestCase):
  56. """Tests for LFS initialization compatibility."""
  57. def test_lfs_init_dulwich(self):
  58. """Test that dulwich lfs_init is compatible with git-lfs."""
  59. # Initialize with dulwich
  60. repo_dir = self.make_temp_dir()
  61. run_git_or_fail(["init"], cwd=repo_dir)
  62. lfs_init(repo_dir)
  63. # Verify with git-lfs
  64. output = run_git_or_fail(["lfs", "env"], cwd=repo_dir)
  65. self.assertIn(b"git config filter.lfs.clean", output)
  66. self.assertIn(b"git config filter.lfs.smudge", output)
  67. def test_lfs_init_git(self):
  68. """Test that git-lfs init is compatible with dulwich."""
  69. # Initialize with git-lfs
  70. repo_dir = self.make_temp_dir()
  71. run_git_or_fail(["init"], cwd=repo_dir)
  72. run_git_or_fail(["lfs", "install", "--local"], cwd=repo_dir)
  73. # Verify with dulwich
  74. repo = porcelain.open_repo(repo_dir)
  75. self.addCleanup(repo.close)
  76. config = repo.get_config_stack()
  77. self.assertEqual(
  78. config.get(("filter", "lfs"), "clean").decode(), "git-lfs clean -- %f"
  79. )
  80. self.assertEqual(
  81. config.get(("filter", "lfs"), "smudge").decode(), "git-lfs smudge -- %f"
  82. )
  83. class LFSTrackCompatTest(LFSCompatTestCase):
  84. """Tests for LFS tracking compatibility."""
  85. def test_track_dulwich(self):
  86. """Test that dulwich lfs_track is compatible with git-lfs."""
  87. repo_dir = self.make_temp_dir()
  88. run_git_or_fail(["init"], cwd=repo_dir)
  89. lfs_init(repo_dir)
  90. # Track with dulwich
  91. lfs_track(repo_dir, ["*.bin", "*.dat"])
  92. # Verify with git-lfs
  93. output = run_git_or_fail(["lfs", "track"], cwd=repo_dir)
  94. self.assertIn(b"*.bin", output)
  95. self.assertIn(b"*.dat", output)
  96. def test_track_git(self):
  97. """Test that git-lfs track is compatible with dulwich."""
  98. repo_dir = self.make_temp_dir()
  99. run_git_or_fail(["init"], cwd=repo_dir)
  100. run_git_or_fail(["lfs", "install", "--local"], cwd=repo_dir)
  101. # Track with git-lfs
  102. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  103. run_git_or_fail(["lfs", "track", "*.dat"], cwd=repo_dir)
  104. # Verify with dulwich
  105. gitattributes_path = os.path.join(repo_dir, ".gitattributes")
  106. with open(gitattributes_path, "rb") as f:
  107. content = f.read().decode()
  108. self.assertIn("*.bin filter=lfs", content)
  109. self.assertIn("*.dat filter=lfs", content)
  110. class LFSFileOperationsCompatTest(LFSCompatTestCase):
  111. """Tests for LFS file operations compatibility."""
  112. def test_add_commit_dulwich(self):
  113. """Test adding and committing LFS files with dulwich."""
  114. repo_dir = self.make_temp_dir()
  115. run_git_or_fail(["init"], cwd=repo_dir)
  116. lfs_init(repo_dir)
  117. lfs_track(repo_dir, ["*.bin"])
  118. # Create and add a large file
  119. test_file = os.path.join(repo_dir, "test.bin")
  120. test_content = b"x" * 1024 * 1024 # 1MB
  121. with open(test_file, "wb") as f:
  122. f.write(test_content)
  123. # Add with dulwich
  124. porcelain.add(repo_dir, [test_file])
  125. porcelain.commit(repo_dir, message=b"Add LFS file")
  126. # Verify with git-lfs
  127. output = run_git_or_fail(["lfs", "ls-files"], cwd=repo_dir)
  128. self.assertIn(b"test.bin", output)
  129. # Check pointer file in git
  130. output = run_git_or_fail(["show", "HEAD:test.bin"], cwd=repo_dir)
  131. self.assertIn(b"version https://git-lfs.github.com/spec/v1", output)
  132. self.assertIn(b"oid sha256:", output)
  133. self.assertIn(b"size 1048576", output)
  134. def test_add_commit_git(self):
  135. """Test adding and committing LFS files with git-lfs."""
  136. repo_dir = self.make_temp_dir()
  137. run_git_or_fail(["init"], cwd=repo_dir)
  138. run_git_or_fail(["lfs", "install", "--local"], cwd=repo_dir)
  139. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  140. run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
  141. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
  142. # Create and add a large file
  143. test_file = os.path.join(repo_dir, "test.bin")
  144. test_content = b"y" * 1024 * 1024 # 1MB
  145. with open(test_file, "wb") as f:
  146. f.write(test_content)
  147. # Add with git-lfs
  148. run_git_or_fail(["add", "test.bin"], cwd=repo_dir)
  149. run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=repo_dir)
  150. # Verify with dulwich
  151. repo = porcelain.open_repo(repo_dir)
  152. self.addCleanup(repo.close)
  153. tree = repo[repo.head()].tree
  154. _mode, sha = repo.object_store[tree][b"test.bin"]
  155. blob = repo.object_store[sha]
  156. pointer = LFSPointer.from_bytes(blob.data)
  157. self.assertEqual(pointer.size, 1048576)
  158. def test_checkout_dulwich(self):
  159. """Test checking out LFS files with dulwich."""
  160. # Create repo with git-lfs
  161. repo_dir = self.make_temp_dir()
  162. run_git_or_fail(["init"], cwd=repo_dir)
  163. run_git_or_fail(["lfs", "install", "--local"], cwd=repo_dir)
  164. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  165. run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
  166. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
  167. # Add LFS file
  168. test_file = os.path.join(repo_dir, "test.bin")
  169. test_content = b"z" * 1024 * 1024 # 1MB
  170. with open(test_file, "wb") as f:
  171. f.write(test_content)
  172. run_git_or_fail(["add", "test.bin"], cwd=repo_dir)
  173. run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=repo_dir)
  174. # Remove working copy
  175. os.remove(test_file)
  176. # Checkout with dulwich
  177. porcelain.reset(repo_dir, mode="hard")
  178. # Verify file contents
  179. with open(test_file, "rb") as f:
  180. content = f.read()
  181. self.assertEqual(content, test_content)
  182. class LFSPointerCompatTest(LFSCompatTestCase):
  183. """Tests for LFS pointer file compatibility."""
  184. def test_pointer_format_dulwich(self):
  185. """Test that dulwich creates git-lfs compatible pointers."""
  186. repo_dir = self.make_temp_dir()
  187. run_git_or_fail(["init"], cwd=repo_dir)
  188. lfs_init(repo_dir)
  189. test_content = b"test content for LFS"
  190. test_file = os.path.join(repo_dir, "test.txt")
  191. with open(test_file, "wb") as f:
  192. f.write(test_content)
  193. # Create pointer with dulwich
  194. pointer_data = lfs_clean(repo_dir, "test.txt")
  195. # Parse with git-lfs (create a file and check)
  196. test_file = os.path.join(repo_dir, "test_pointer")
  197. with open(test_file, "wb") as f:
  198. f.write(pointer_data)
  199. # Verify pointer format
  200. with open(test_file, "rb") as f:
  201. lines = f.read().decode().strip().split("\n")
  202. self.assertEqual(lines[0], "version https://git-lfs.github.com/spec/v1")
  203. self.assertTrue(lines[1].startswith("oid sha256:"))
  204. self.assertTrue(lines[2].startswith("size "))
  205. def test_pointer_format_git(self):
  206. """Test that dulwich can parse git-lfs pointers."""
  207. # Create a git-lfs pointer manually
  208. oid = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  209. size = 12345
  210. pointer_content = f"version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n"
  211. # Parse with dulwich
  212. pointer = LFSPointer.from_bytes(pointer_content.encode())
  213. self.assertEqual(pointer.oid, oid)
  214. self.assertEqual(pointer.size, size)
  215. class LFSFilterCompatTest(LFSCompatTestCase):
  216. """Tests for LFS filter operations compatibility."""
  217. def test_clean_filter_compat(self):
  218. """Test clean filter compatibility between dulwich and git-lfs."""
  219. repo_dir = self.make_temp_dir()
  220. run_git_or_fail(["init"], cwd=repo_dir)
  221. lfs_init(repo_dir)
  222. test_content = b"x" * 1000
  223. test_file = os.path.join(repo_dir, "test.txt")
  224. with open(test_file, "wb") as f:
  225. f.write(test_content)
  226. # Clean with dulwich
  227. dulwich_pointer = lfs_clean(repo_dir, "test.txt")
  228. # Clean with git-lfs (simulate)
  229. # Since we can't easily invoke git-lfs clean directly,
  230. # we'll test that the pointer format is correct
  231. self.assertIn(b"version https://git-lfs.github.com/spec/v1", dulwich_pointer)
  232. self.assertIn(b"oid sha256:", dulwich_pointer)
  233. self.assertIn(b"size 1000", dulwich_pointer)
  234. def test_smudge_filter_compat(self):
  235. """Test smudge filter compatibility between dulwich and git-lfs."""
  236. # Create a test repo with LFS
  237. repo_dir = self.make_temp_dir()
  238. run_git_or_fail(["init"], cwd=repo_dir)
  239. lfs_init(repo_dir)
  240. # Create test content
  241. test_content = b"test data for smudge filter"
  242. test_file = os.path.join(repo_dir, "test.txt")
  243. with open(test_file, "wb") as f:
  244. f.write(test_content)
  245. pointer_data = lfs_clean(repo_dir, "test.txt")
  246. # Store object in LFS
  247. lfs_dir = os.path.join(repo_dir, ".git", "lfs")
  248. os.makedirs(lfs_dir, exist_ok=True)
  249. # Parse pointer to get oid
  250. pointer = LFSPointer.from_bytes(pointer_data)
  251. # Store object
  252. obj_dir = os.path.join(lfs_dir, "objects", pointer.oid[:2], pointer.oid[2:4])
  253. os.makedirs(obj_dir, exist_ok=True)
  254. obj_path = os.path.join(obj_dir, pointer.oid)
  255. with open(obj_path, "wb") as f:
  256. f.write(test_content)
  257. # Test smudge
  258. smudged = lfs_smudge(repo_dir, pointer_data)
  259. self.assertEqual(smudged, test_content)
  260. class LFSStatusCompatTest(LFSCompatTestCase):
  261. """Tests for git status with LFS files (issue #1889)."""
  262. def test_status_with_lfs_files(self):
  263. """Test git status works correctly with LFS files.
  264. This reproduces issue #1889 where git status with LFS files
  265. would fail due to incorrect handling of the two-phase filter
  266. protocol response.
  267. """
  268. repo_dir = self.make_temp_dir()
  269. run_git_or_fail(["init"], cwd=repo_dir)
  270. # Disable autocrlf to avoid line ending issues on Windows
  271. run_git_or_fail(["config", "core.autocrlf", "false"], cwd=repo_dir)
  272. run_git_or_fail(["lfs", "install", "--local"], cwd=repo_dir)
  273. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  274. run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
  275. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
  276. # Add an LFS file
  277. test_file = os.path.join(repo_dir, "test.bin")
  278. test_content = b"x" * 1024 * 1024 # 1MB
  279. with open(test_file, "wb") as f:
  280. f.write(test_content)
  281. run_git_or_fail(["add", "test.bin"], cwd=repo_dir)
  282. run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=repo_dir)
  283. # Now check status with dulwich - this should not raise FilterError
  284. # This should work without raising FilterError
  285. # Before the fix, this would fail with:
  286. # dulwich.filters.FilterError: Process filter smudge failed: error
  287. status = porcelain.status(repo_dir, untracked_files="no")
  288. # Verify status shows clean working tree
  289. self.assertEqual(status.staged["add"], [])
  290. self.assertEqual(status.staged["delete"], [])
  291. self.assertEqual(status.staged["modify"], [])
  292. self.assertEqual(status.unstaged, [])
  293. def test_status_with_modified_lfs_file(self):
  294. """Test git status with modified LFS files."""
  295. repo_dir = self.make_temp_dir()
  296. run_git_or_fail(["init"], cwd=repo_dir)
  297. # Disable autocrlf to avoid line ending issues on Windows
  298. run_git_or_fail(["config", "core.autocrlf", "false"], cwd=repo_dir)
  299. run_git_or_fail(["lfs", "install", "--local"], cwd=repo_dir)
  300. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  301. run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
  302. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
  303. # Add an LFS file
  304. test_file = os.path.join(repo_dir, "test.bin")
  305. with open(test_file, "wb") as f:
  306. f.write(b"original content\n")
  307. run_git_or_fail(["add", "test.bin"], cwd=repo_dir)
  308. run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=repo_dir)
  309. # Modify the file
  310. with open(test_file, "wb") as f:
  311. f.write(b"slightly modified content\n")
  312. # Check status - should show file as modified
  313. status = porcelain.status(repo_dir, untracked_files="no")
  314. # File should be in unstaged changes
  315. self.assertIn(b"test.bin", status.unstaged)
  316. def test_status_with_multiple_lfs_files(self):
  317. """Test git status with multiple LFS files."""
  318. repo_dir = self.make_temp_dir()
  319. run_git_or_fail(["init"], cwd=repo_dir)
  320. # Disable autocrlf to avoid line ending issues on Windows
  321. run_git_or_fail(["config", "core.autocrlf", "false"], cwd=repo_dir)
  322. run_git_or_fail(["lfs", "install", "--local"], cwd=repo_dir)
  323. run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
  324. run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
  325. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
  326. # Add multiple LFS files
  327. for i in range(3):
  328. test_file = os.path.join(repo_dir, f"test{i}.bin")
  329. with open(test_file, "wb") as f:
  330. f.write(b"content" * 1000)
  331. run_git_or_fail(["add", "*.bin"], cwd=repo_dir)
  332. run_git_or_fail(["commit", "-m", "Add LFS files"], cwd=repo_dir)
  333. # Check status - should handle multiple files correctly
  334. status = porcelain.status(repo_dir, untracked_files="no")
  335. # All files should be clean
  336. self.assertEqual(status.staged["add"], [])
  337. self.assertEqual(status.staged["delete"], [])
  338. self.assertEqual(status.staged["modify"], [])
  339. self.assertEqual(status.unstaged, [])
  340. class LFSCloneCompatTest(LFSCompatTestCase):
  341. """Tests for cloning repositories with LFS files."""
  342. @skipUnless(
  343. git_lfs_version() and git_lfs_version() >= (2, 0, 0),
  344. "git-lfs 2.0+ required for clone tests",
  345. )
  346. def test_clone_with_lfs(self):
  347. """Test cloning a repository with LFS files."""
  348. # Create source repo with LFS
  349. source_dir = self.make_temp_dir()
  350. run_git_or_fail(["init"], cwd=source_dir)
  351. run_git_or_fail(["lfs", "install", "--local"], cwd=source_dir)
  352. run_git_or_fail(["lfs", "track", "*.bin"], cwd=source_dir)
  353. run_git_or_fail(["add", ".gitattributes"], cwd=source_dir)
  354. run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=source_dir)
  355. # Add LFS file
  356. test_file = os.path.join(source_dir, "test.bin")
  357. test_content = b"w" * 1024 * 1024 # 1MB
  358. with open(test_file, "wb") as f:
  359. f.write(test_content)
  360. run_git_or_fail(["add", "test.bin"], cwd=source_dir)
  361. run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=source_dir)
  362. # Clone with dulwich
  363. target_dir = self.make_temp_dir()
  364. cloned_repo = porcelain.clone(source_dir, target_dir)
  365. self.addCleanup(cloned_repo.close)
  366. # Verify LFS file exists
  367. cloned_file = os.path.join(target_dir, "test.bin")
  368. with open(cloned_file, "rb") as f:
  369. content = f.read()
  370. # Check if filter.lfs.smudge is configured
  371. cloned_config = cloned_repo.get_config()
  372. try:
  373. lfs_smudge = cloned_config.get((b"filter", b"lfs"), b"smudge")
  374. has_lfs_config = bool(lfs_smudge)
  375. except KeyError:
  376. has_lfs_config = False
  377. if has_lfs_config:
  378. # git-lfs smudge filter should have converted it
  379. self.assertEqual(content, test_content)
  380. else:
  381. # No git-lfs config (uses built-in filter), should be a pointer
  382. self.assertIn(b"version https://git-lfs.github.com/spec/v1", content)
  383. if __name__ == "__main__":
  384. import unittest
  385. unittest.main()