test_porcelain_lfs.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. # test_porcelain_lfs.py -- Tests for LFS porcelain functions
  2. # Copyright (C) 2024 Jelmer Vernooij
  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. """Tests for LFS porcelain functions."""
  22. import os
  23. import tempfile
  24. import unittest
  25. from dulwich import porcelain
  26. from dulwich.lfs import LFSPointer, LFSStore
  27. from dulwich.repo import Repo
  28. from tests import TestCase
  29. class LFSPorcelainTestCase(TestCase):
  30. """Test case for LFS porcelain functions."""
  31. def setUp(self):
  32. super().setUp()
  33. self.test_dir = tempfile.mkdtemp()
  34. self.addCleanup(self._cleanup_test_dir)
  35. self.repo = Repo.init(self.test_dir)
  36. self.addCleanup(self.repo.close)
  37. def _cleanup_test_dir(self):
  38. """Clean up test directory recursively."""
  39. import shutil
  40. shutil.rmtree(self.test_dir, ignore_errors=True)
  41. def test_lfs_init(self):
  42. """Test LFS initialization."""
  43. porcelain.lfs_init(self.repo)
  44. # Check that LFS store was created
  45. lfs_dir = os.path.join(self.repo.controldir(), "lfs")
  46. self.assertTrue(os.path.exists(lfs_dir))
  47. self.assertTrue(os.path.exists(os.path.join(lfs_dir, "objects")))
  48. self.assertTrue(os.path.exists(os.path.join(lfs_dir, "tmp")))
  49. # Check that config was set
  50. config = self.repo.get_config()
  51. self.assertEqual(
  52. config.get((b"filter", b"lfs"), b"process"), b"git-lfs filter-process"
  53. )
  54. self.assertEqual(config.get((b"filter", b"lfs"), b"required"), b"true")
  55. def test_lfs_track(self):
  56. """Test tracking patterns with LFS."""
  57. # Track some patterns
  58. patterns = ["*.bin", "*.pdf"]
  59. tracked = porcelain.lfs_track(self.repo, patterns)
  60. self.assertEqual(set(tracked), set(patterns))
  61. # Check .gitattributes was created
  62. gitattributes_path = os.path.join(self.repo.path, ".gitattributes")
  63. self.assertTrue(os.path.exists(gitattributes_path))
  64. # Read and verify content
  65. with open(gitattributes_path, "rb") as f:
  66. content = f.read()
  67. self.assertIn(b"*.bin diff=lfs filter=lfs merge=lfs -text", content)
  68. self.assertIn(b"*.pdf diff=lfs filter=lfs merge=lfs -text", content)
  69. # Test listing tracked patterns
  70. tracked = porcelain.lfs_track(self.repo)
  71. self.assertEqual(set(tracked), set(patterns))
  72. def test_lfs_untrack(self):
  73. """Test untracking patterns from LFS."""
  74. # First track some patterns
  75. patterns = ["*.bin", "*.pdf", "*.zip"]
  76. porcelain.lfs_track(self.repo, patterns)
  77. # Untrack one pattern
  78. remaining = porcelain.lfs_untrack(self.repo, ["*.pdf"])
  79. self.assertEqual(set(remaining), {"*.bin", "*.zip"})
  80. # Verify .gitattributes
  81. with open(os.path.join(self.repo.path, ".gitattributes"), "rb") as f:
  82. content = f.read()
  83. self.assertIn(b"*.bin diff=lfs filter=lfs merge=lfs -text", content)
  84. self.assertNotIn(b"*.pdf diff=lfs filter=lfs merge=lfs -text", content)
  85. self.assertIn(b"*.zip diff=lfs filter=lfs merge=lfs -text", content)
  86. def test_lfs_clean(self):
  87. """Test cleaning a file to LFS pointer."""
  88. # Initialize LFS
  89. porcelain.lfs_init(self.repo)
  90. # Create a test file
  91. test_content = b"This is test content for LFS"
  92. test_file = os.path.join(self.repo.path, "test.bin")
  93. with open(test_file, "wb") as f:
  94. f.write(test_content)
  95. # Clean the file
  96. pointer_content = porcelain.lfs_clean(self.repo, "test.bin")
  97. # Verify it's a valid LFS pointer
  98. pointer = LFSPointer.from_bytes(pointer_content)
  99. self.assertIsNotNone(pointer)
  100. self.assertEqual(pointer.size, len(test_content))
  101. # Verify the content was stored in LFS
  102. lfs_store = LFSStore.from_repo(self.repo)
  103. with lfs_store.open_object(pointer.oid) as f:
  104. stored_content = f.read()
  105. self.assertEqual(stored_content, test_content)
  106. def test_lfs_smudge(self):
  107. """Test smudging an LFS pointer to content."""
  108. # Initialize LFS
  109. porcelain.lfs_init(self.repo)
  110. # Create test content and store it
  111. test_content = b"This is test content for smudging"
  112. lfs_store = LFSStore.from_repo(self.repo)
  113. oid = lfs_store.write_object([test_content])
  114. # Create LFS pointer
  115. pointer = LFSPointer(oid, len(test_content))
  116. pointer_content = pointer.to_bytes()
  117. # Smudge the pointer
  118. smudged_content = porcelain.lfs_smudge(self.repo, pointer_content)
  119. self.assertEqual(smudged_content, test_content)
  120. def test_lfs_ls_files(self):
  121. """Test listing LFS files."""
  122. # Initialize repo with some LFS files
  123. porcelain.lfs_init(self.repo)
  124. # Create a test file and convert to LFS
  125. test_content = b"Large file content"
  126. test_file = os.path.join(self.repo.path, "large.bin")
  127. with open(test_file, "wb") as f:
  128. f.write(test_content)
  129. # Clean to LFS pointer
  130. pointer_content = porcelain.lfs_clean(self.repo, "large.bin")
  131. with open(test_file, "wb") as f:
  132. f.write(pointer_content)
  133. # Add and commit
  134. porcelain.add(self.repo, paths=["large.bin"])
  135. porcelain.commit(self.repo, message=b"Add LFS file")
  136. # List LFS files
  137. lfs_files = porcelain.lfs_ls_files(self.repo)
  138. self.assertEqual(len(lfs_files), 1)
  139. path, oid, size = lfs_files[0]
  140. self.assertEqual(path, "large.bin")
  141. self.assertEqual(size, len(test_content))
  142. def test_lfs_migrate(self):
  143. """Test migrating files to LFS."""
  144. # Create some files
  145. files = {
  146. "small.txt": b"Small file",
  147. "large1.bin": b"X" * 1000,
  148. "large2.dat": b"Y" * 2000,
  149. "exclude.bin": b"Z" * 1500,
  150. }
  151. for filename, content in files.items():
  152. path = os.path.join(self.repo.path, filename)
  153. with open(path, "wb") as f:
  154. f.write(content)
  155. # Add files to index
  156. porcelain.add(self.repo, paths=list(files.keys()))
  157. # Migrate with patterns
  158. count = porcelain.lfs_migrate(
  159. self.repo, include=["*.bin", "*.dat"], exclude=["exclude.*"]
  160. )
  161. self.assertEqual(count, 2) # large1.bin and large2.dat
  162. # Verify files were converted to LFS pointers
  163. for filename in ["large1.bin", "large2.dat"]:
  164. path = os.path.join(self.repo.path, filename)
  165. with open(path, "rb") as f:
  166. content = f.read()
  167. pointer = LFSPointer.from_bytes(content)
  168. self.assertIsNotNone(pointer)
  169. def test_lfs_pointer_check(self):
  170. """Test checking if files are LFS pointers."""
  171. # Initialize LFS
  172. porcelain.lfs_init(self.repo)
  173. # Create an LFS pointer file
  174. test_content = b"LFS content"
  175. lfs_file = os.path.join(self.repo.path, "lfs.bin")
  176. # First create the file
  177. with open(lfs_file, "wb") as f:
  178. f.write(test_content)
  179. pointer_content = porcelain.lfs_clean(self.repo, "lfs.bin")
  180. with open(lfs_file, "wb") as f:
  181. f.write(pointer_content)
  182. # Create a regular file
  183. regular_file = os.path.join(self.repo.path, "regular.txt")
  184. with open(regular_file, "wb") as f:
  185. f.write(b"Regular content")
  186. # Check both files
  187. results = porcelain.lfs_pointer_check(
  188. self.repo, paths=["lfs.bin", "regular.txt", "nonexistent.txt"]
  189. )
  190. self.assertIsNotNone(results["lfs.bin"])
  191. self.assertIsNone(results["regular.txt"])
  192. self.assertIsNone(results["nonexistent.txt"])
  193. def test_clone_with_builtin_lfs_no_config(self):
  194. """Test cloning with built-in LFS filter when no git-lfs config exists."""
  195. # Create a source repo with LFS content
  196. source_dir = tempfile.mkdtemp()
  197. self.addCleanup(lambda: self._cleanup_test_dir_path(source_dir))
  198. source_repo = Repo.init(source_dir)
  199. # Create .gitattributes
  200. gitattributes_path = os.path.join(source_dir, ".gitattributes")
  201. with open(gitattributes_path, "w") as f:
  202. f.write("*.bin filter=lfs diff=lfs merge=lfs -text\n")
  203. # Create test content and store in LFS
  204. # LFSStore.from_repo with create=True will create the directories
  205. test_content = b"This is test content for LFS"
  206. lfs_store = LFSStore.from_repo(source_repo, create=True)
  207. oid = lfs_store.write_object([test_content])
  208. # Create LFS pointer file
  209. pointer = LFSPointer(oid, len(test_content))
  210. test_file = os.path.join(source_dir, "test.bin")
  211. with open(test_file, "wb") as f:
  212. f.write(pointer.to_bytes())
  213. # Add and commit
  214. porcelain.add(source_repo, paths=[".gitattributes", "test.bin"])
  215. porcelain.commit(source_repo, message=b"Add LFS file")
  216. # Clone with empty config (no git-lfs commands)
  217. clone_dir = tempfile.mkdtemp()
  218. self.addCleanup(lambda: self._cleanup_test_dir_path(clone_dir))
  219. # Verify source repo has no LFS filter config
  220. config = source_repo.get_config()
  221. with self.assertRaises(KeyError):
  222. config.get((b"filter", b"lfs"), b"smudge")
  223. # Clone the repository
  224. cloned_repo = porcelain.clone(source_dir, clone_dir)
  225. # Verify that built-in LFS filter was used
  226. normalizer = cloned_repo.get_blob_normalizer()
  227. if hasattr(normalizer, "filter_registry"):
  228. lfs_driver = normalizer.filter_registry.get_driver("lfs")
  229. # Should be the built-in LFSFilterDriver
  230. self.assertEqual(type(lfs_driver).__name__, "LFSFilterDriver")
  231. self.assertEqual(type(lfs_driver).__module__, "dulwich.lfs")
  232. # Check that the file remains as a pointer (expected behavior)
  233. # The built-in LFS filter preserves pointers when objects aren't available
  234. cloned_file = os.path.join(clone_dir, "test.bin")
  235. with open(cloned_file, "rb") as f:
  236. content = f.read()
  237. # Should still be a pointer since objects weren't transferred
  238. self.assertTrue(
  239. content.startswith(b"version https://git-lfs.github.com/spec/v1")
  240. )
  241. cloned_pointer = LFSPointer.from_bytes(content)
  242. self.assertIsNotNone(cloned_pointer)
  243. self.assertEqual(cloned_pointer.oid, pointer.oid)
  244. self.assertEqual(cloned_pointer.size, pointer.size)
  245. source_repo.close()
  246. cloned_repo.close()
  247. def _cleanup_test_dir_path(self, path):
  248. """Clean up a test directory by path."""
  249. import shutil
  250. shutil.rmtree(path, ignore_errors=True)
  251. def test_add_applies_clean_filter(self):
  252. """Test that add operation applies LFS clean filter."""
  253. # Don't use lfs_init to avoid configuring git-lfs commands
  254. # Create LFS store manually
  255. lfs_store = LFSStore.from_repo(self.repo, create=True)
  256. # Create .gitattributes
  257. gitattributes_path = os.path.join(self.repo.path, ".gitattributes")
  258. with open(gitattributes_path, "w") as f:
  259. f.write("*.bin filter=lfs diff=lfs merge=lfs -text\n")
  260. # Create a file that should be cleaned to LFS
  261. test_content = b"This is large file content that should be stored in LFS"
  262. test_file = os.path.join(self.repo.path, "large.bin")
  263. with open(test_file, "wb") as f:
  264. f.write(test_content)
  265. # Add the file - this should apply the clean filter
  266. porcelain.add(self.repo, paths=["large.bin"])
  267. # Check that the file was cleaned to a pointer in the index
  268. index = self.repo.open_index()
  269. entry = index[b"large.bin"]
  270. # Get the blob from the object store
  271. blob = self.repo.get_object(entry.sha)
  272. content = blob.data
  273. # Should be an LFS pointer
  274. self.assertTrue(
  275. content.startswith(b"version https://git-lfs.github.com/spec/v1")
  276. )
  277. pointer = LFSPointer.from_bytes(content)
  278. self.assertIsNotNone(pointer)
  279. self.assertEqual(pointer.size, len(test_content))
  280. # Verify the actual content was stored in LFS
  281. with lfs_store.open_object(pointer.oid) as f:
  282. stored_content = f.read()
  283. self.assertEqual(stored_content, test_content)
  284. def test_checkout_applies_smudge_filter(self):
  285. """Test that checkout operation applies LFS smudge filter."""
  286. # Create LFS store and content
  287. lfs_store = LFSStore.from_repo(self.repo, create=True)
  288. # Create .gitattributes
  289. gitattributes_path = os.path.join(self.repo.path, ".gitattributes")
  290. with open(gitattributes_path, "w") as f:
  291. f.write("*.bin filter=lfs diff=lfs merge=lfs -text\n")
  292. # Create test content and store in LFS
  293. test_content = b"This is the actual file content from LFS"
  294. oid = lfs_store.write_object([test_content])
  295. # Create LFS pointer file
  296. pointer = LFSPointer(oid, len(test_content))
  297. test_file = os.path.join(self.repo.path, "data.bin")
  298. with open(test_file, "wb") as f:
  299. f.write(pointer.to_bytes())
  300. # Add and commit the pointer
  301. porcelain.add(self.repo, paths=[".gitattributes", "data.bin"])
  302. porcelain.commit(self.repo, message=b"Add LFS file")
  303. # Remove the file from working directory
  304. os.remove(test_file)
  305. # Checkout the file - this should apply the smudge filter
  306. porcelain.checkout(self.repo, paths=["data.bin"])
  307. # Verify the file was expanded from pointer to content
  308. with open(test_file, "rb") as f:
  309. content = f.read()
  310. self.assertEqual(content, test_content)
  311. def test_reset_hard_applies_smudge_filter(self):
  312. """Test that reset --hard applies LFS smudge filter."""
  313. # Create LFS store and content
  314. lfs_store = LFSStore.from_repo(self.repo, create=True)
  315. # Create .gitattributes
  316. gitattributes_path = os.path.join(self.repo.path, ".gitattributes")
  317. with open(gitattributes_path, "w") as f:
  318. f.write("*.bin filter=lfs diff=lfs merge=lfs -text\n")
  319. # Create test content and store in LFS
  320. test_content = b"Content that should be restored by reset"
  321. oid = lfs_store.write_object([test_content])
  322. # Create LFS pointer file
  323. pointer = LFSPointer(oid, len(test_content))
  324. test_file = os.path.join(self.repo.path, "reset-test.bin")
  325. with open(test_file, "wb") as f:
  326. f.write(pointer.to_bytes())
  327. # Add and commit
  328. porcelain.add(self.repo, paths=[".gitattributes", "reset-test.bin"])
  329. commit_sha = porcelain.commit(self.repo, message=b"Add LFS file for reset test")
  330. # Modify the file in working directory
  331. with open(test_file, "wb") as f:
  332. f.write(b"Modified content that should be discarded")
  333. # Reset hard - this should restore the file with smudge filter applied
  334. porcelain.reset(self.repo, mode="hard", treeish=commit_sha)
  335. # Verify the file was restored with LFS content
  336. with open(test_file, "rb") as f:
  337. content = f.read()
  338. self.assertEqual(content, test_content)
  339. if __name__ == "__main__":
  340. unittest.main()