# test_porcelain_lfs.py -- Tests for LFS porcelain functions # Copyright (C) 2024 Jelmer Vernooij # # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as published by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """Tests for LFS porcelain functions.""" import os import tempfile import unittest from dulwich import porcelain from dulwich.lfs import LFSPointer, LFSStore from dulwich.repo import Repo from tests import TestCase class LFSPorcelainTestCase(TestCase): """Test case for LFS porcelain functions.""" def setUp(self): super().setUp() self.test_dir = tempfile.mkdtemp() self.addCleanup(self._cleanup_test_dir) self.repo = Repo.init(self.test_dir) self.addCleanup(self.repo.close) def _cleanup_test_dir(self): """Clean up test directory recursively.""" import shutil shutil.rmtree(self.test_dir, ignore_errors=True) def test_lfs_init(self): """Test LFS initialization.""" porcelain.lfs_init(self.repo) # Check that LFS store was created lfs_dir = os.path.join(self.repo.controldir(), "lfs") self.assertTrue(os.path.exists(lfs_dir)) self.assertTrue(os.path.exists(os.path.join(lfs_dir, "objects"))) self.assertTrue(os.path.exists(os.path.join(lfs_dir, "tmp"))) # Check that config was set config = self.repo.get_config() self.assertEqual( config.get((b"filter", b"lfs"), b"process"), b"git-lfs filter-process" ) self.assertEqual(config.get((b"filter", b"lfs"), b"required"), b"true") def test_lfs_track(self): """Test tracking patterns with LFS.""" # Track some patterns patterns = ["*.bin", "*.pdf"] tracked = porcelain.lfs_track(self.repo, patterns) self.assertEqual(set(tracked), set(patterns)) # Check .gitattributes was created gitattributes_path = os.path.join(self.repo.path, ".gitattributes") self.assertTrue(os.path.exists(gitattributes_path)) # Read and verify content with open(gitattributes_path, "rb") as f: content = f.read() self.assertIn(b"*.bin diff=lfs filter=lfs merge=lfs -text", content) self.assertIn(b"*.pdf diff=lfs filter=lfs merge=lfs -text", content) # Test listing tracked patterns tracked = porcelain.lfs_track(self.repo) self.assertEqual(set(tracked), set(patterns)) def test_lfs_untrack(self): """Test untracking patterns from LFS.""" # First track some patterns patterns = ["*.bin", "*.pdf", "*.zip"] porcelain.lfs_track(self.repo, patterns) # Untrack one pattern remaining = porcelain.lfs_untrack(self.repo, ["*.pdf"]) self.assertEqual(set(remaining), {"*.bin", "*.zip"}) # Verify .gitattributes with open(os.path.join(self.repo.path, ".gitattributes"), "rb") as f: content = f.read() self.assertIn(b"*.bin diff=lfs filter=lfs merge=lfs -text", content) self.assertNotIn(b"*.pdf diff=lfs filter=lfs merge=lfs -text", content) self.assertIn(b"*.zip diff=lfs filter=lfs merge=lfs -text", content) def test_lfs_clean(self): """Test cleaning a file to LFS pointer.""" # Initialize LFS porcelain.lfs_init(self.repo) # Create a test file test_content = b"This is test content for LFS" test_file = os.path.join(self.repo.path, "test.bin") with open(test_file, "wb") as f: f.write(test_content) # Clean the file pointer_content = porcelain.lfs_clean(self.repo, "test.bin") # Verify it's a valid LFS pointer pointer = LFSPointer.from_bytes(pointer_content) self.assertIsNotNone(pointer) self.assertEqual(pointer.size, len(test_content)) # Verify the content was stored in LFS lfs_store = LFSStore.from_repo(self.repo) with lfs_store.open_object(pointer.oid) as f: stored_content = f.read() self.assertEqual(stored_content, test_content) def test_lfs_smudge(self): """Test smudging an LFS pointer to content.""" # Initialize LFS porcelain.lfs_init(self.repo) # Create test content and store it test_content = b"This is test content for smudging" lfs_store = LFSStore.from_repo(self.repo) oid = lfs_store.write_object([test_content]) # Create LFS pointer pointer = LFSPointer(oid, len(test_content)) pointer_content = pointer.to_bytes() # Smudge the pointer smudged_content = porcelain.lfs_smudge(self.repo, pointer_content) self.assertEqual(smudged_content, test_content) def test_lfs_ls_files(self): """Test listing LFS files.""" # Initialize repo with some LFS files porcelain.lfs_init(self.repo) # Create a test file and convert to LFS test_content = b"Large file content" test_file = os.path.join(self.repo.path, "large.bin") with open(test_file, "wb") as f: f.write(test_content) # Clean to LFS pointer pointer_content = porcelain.lfs_clean(self.repo, "large.bin") with open(test_file, "wb") as f: f.write(pointer_content) # Add and commit porcelain.add(self.repo, paths=["large.bin"]) porcelain.commit(self.repo, message=b"Add LFS file") # List LFS files lfs_files = porcelain.lfs_ls_files(self.repo) self.assertEqual(len(lfs_files), 1) path, oid, size = lfs_files[0] self.assertEqual(path, "large.bin") self.assertEqual(size, len(test_content)) def test_lfs_migrate(self): """Test migrating files to LFS.""" # Create some files files = { "small.txt": b"Small file", "large1.bin": b"X" * 1000, "large2.dat": b"Y" * 2000, "exclude.bin": b"Z" * 1500, } for filename, content in files.items(): path = os.path.join(self.repo.path, filename) with open(path, "wb") as f: f.write(content) # Add files to index porcelain.add(self.repo, paths=list(files.keys())) # Migrate with patterns count = porcelain.lfs_migrate( self.repo, include=["*.bin", "*.dat"], exclude=["exclude.*"] ) self.assertEqual(count, 2) # large1.bin and large2.dat # Verify files were converted to LFS pointers for filename in ["large1.bin", "large2.dat"]: path = os.path.join(self.repo.path, filename) with open(path, "rb") as f: content = f.read() pointer = LFSPointer.from_bytes(content) self.assertIsNotNone(pointer) def test_lfs_pointer_check(self): """Test checking if files are LFS pointers.""" # Initialize LFS porcelain.lfs_init(self.repo) # Create an LFS pointer file test_content = b"LFS content" lfs_file = os.path.join(self.repo.path, "lfs.bin") # First create the file with open(lfs_file, "wb") as f: f.write(test_content) pointer_content = porcelain.lfs_clean(self.repo, "lfs.bin") with open(lfs_file, "wb") as f: f.write(pointer_content) # Create a regular file regular_file = os.path.join(self.repo.path, "regular.txt") with open(regular_file, "wb") as f: f.write(b"Regular content") # Check both files results = porcelain.lfs_pointer_check( self.repo, paths=["lfs.bin", "regular.txt", "nonexistent.txt"] ) self.assertIsNotNone(results["lfs.bin"]) self.assertIsNone(results["regular.txt"]) self.assertIsNone(results["nonexistent.txt"]) def test_clone_with_builtin_lfs_no_config(self): """Test cloning with built-in LFS filter when no git-lfs config exists.""" # Create a source repo with LFS content source_dir = tempfile.mkdtemp() self.addCleanup(lambda: self._cleanup_test_dir_path(source_dir)) source_repo = Repo.init(source_dir) # Create .gitattributes gitattributes_path = os.path.join(source_dir, ".gitattributes") with open(gitattributes_path, "w") as f: f.write("*.bin filter=lfs diff=lfs merge=lfs -text\n") # Create test content and store in LFS # LFSStore.from_repo with create=True will create the directories test_content = b"This is test content for LFS" lfs_store = LFSStore.from_repo(source_repo, create=True) oid = lfs_store.write_object([test_content]) # Create LFS pointer file pointer = LFSPointer(oid, len(test_content)) test_file = os.path.join(source_dir, "test.bin") with open(test_file, "wb") as f: f.write(pointer.to_bytes()) # Add and commit porcelain.add(source_repo, paths=[".gitattributes", "test.bin"]) porcelain.commit(source_repo, message=b"Add LFS file") # Clone with empty config (no git-lfs commands) clone_dir = tempfile.mkdtemp() self.addCleanup(lambda: self._cleanup_test_dir_path(clone_dir)) # Verify source repo has no LFS filter config config = source_repo.get_config() with self.assertRaises(KeyError): config.get((b"filter", b"lfs"), b"smudge") # Clone the repository cloned_repo = porcelain.clone(source_dir, clone_dir) # Verify that built-in LFS filter was used normalizer = cloned_repo.get_blob_normalizer() if hasattr(normalizer, "filter_registry"): lfs_driver = normalizer.filter_registry.get_driver("lfs") # Should be the built-in LFSFilterDriver self.assertEqual(type(lfs_driver).__name__, "LFSFilterDriver") self.assertEqual(type(lfs_driver).__module__, "dulwich.lfs") # Check that the file remains as a pointer (expected behavior) # The built-in LFS filter preserves pointers when objects aren't available cloned_file = os.path.join(clone_dir, "test.bin") with open(cloned_file, "rb") as f: content = f.read() # Should still be a pointer since objects weren't transferred self.assertTrue( content.startswith(b"version https://git-lfs.github.com/spec/v1") ) cloned_pointer = LFSPointer.from_bytes(content) self.assertIsNotNone(cloned_pointer) self.assertEqual(cloned_pointer.oid, pointer.oid) self.assertEqual(cloned_pointer.size, pointer.size) source_repo.close() cloned_repo.close() def _cleanup_test_dir_path(self, path): """Clean up a test directory by path.""" import shutil shutil.rmtree(path, ignore_errors=True) def test_add_applies_clean_filter(self): """Test that add operation applies LFS clean filter.""" # Don't use lfs_init to avoid configuring git-lfs commands # Create LFS store manually lfs_store = LFSStore.from_repo(self.repo, create=True) # Create .gitattributes gitattributes_path = os.path.join(self.repo.path, ".gitattributes") with open(gitattributes_path, "w") as f: f.write("*.bin filter=lfs diff=lfs merge=lfs -text\n") # Create a file that should be cleaned to LFS test_content = b"This is large file content that should be stored in LFS" test_file = os.path.join(self.repo.path, "large.bin") with open(test_file, "wb") as f: f.write(test_content) # Add the file - this should apply the clean filter porcelain.add(self.repo, paths=["large.bin"]) # Check that the file was cleaned to a pointer in the index index = self.repo.open_index() entry = index[b"large.bin"] # Get the blob from the object store blob = self.repo.get_object(entry.sha) content = blob.data # Should be an LFS pointer self.assertTrue( content.startswith(b"version https://git-lfs.github.com/spec/v1") ) pointer = LFSPointer.from_bytes(content) self.assertIsNotNone(pointer) self.assertEqual(pointer.size, len(test_content)) # Verify the actual content was stored in LFS with lfs_store.open_object(pointer.oid) as f: stored_content = f.read() self.assertEqual(stored_content, test_content) def test_checkout_applies_smudge_filter(self): """Test that checkout operation applies LFS smudge filter.""" # Create LFS store and content lfs_store = LFSStore.from_repo(self.repo, create=True) # Create .gitattributes gitattributes_path = os.path.join(self.repo.path, ".gitattributes") with open(gitattributes_path, "w") as f: f.write("*.bin filter=lfs diff=lfs merge=lfs -text\n") # Create test content and store in LFS test_content = b"This is the actual file content from LFS" oid = lfs_store.write_object([test_content]) # Create LFS pointer file pointer = LFSPointer(oid, len(test_content)) test_file = os.path.join(self.repo.path, "data.bin") with open(test_file, "wb") as f: f.write(pointer.to_bytes()) # Add and commit the pointer porcelain.add(self.repo, paths=[".gitattributes", "data.bin"]) porcelain.commit(self.repo, message=b"Add LFS file") # Remove the file from working directory os.remove(test_file) # Checkout the file - this should apply the smudge filter porcelain.checkout(self.repo, paths=["data.bin"]) # Verify the file was expanded from pointer to content with open(test_file, "rb") as f: content = f.read() self.assertEqual(content, test_content) def test_reset_hard_applies_smudge_filter(self): """Test that reset --hard applies LFS smudge filter.""" # Create LFS store and content lfs_store = LFSStore.from_repo(self.repo, create=True) # Create .gitattributes gitattributes_path = os.path.join(self.repo.path, ".gitattributes") with open(gitattributes_path, "w") as f: f.write("*.bin filter=lfs diff=lfs merge=lfs -text\n") # Create test content and store in LFS test_content = b"Content that should be restored by reset" oid = lfs_store.write_object([test_content]) # Create LFS pointer file pointer = LFSPointer(oid, len(test_content)) test_file = os.path.join(self.repo.path, "reset-test.bin") with open(test_file, "wb") as f: f.write(pointer.to_bytes()) # Add and commit porcelain.add(self.repo, paths=[".gitattributes", "reset-test.bin"]) commit_sha = porcelain.commit(self.repo, message=b"Add LFS file for reset test") # Modify the file in working directory with open(test_file, "wb") as f: f.write(b"Modified content that should be discarded") # Reset hard - this should restore the file with smudge filter applied porcelain.reset(self.repo, mode="hard", treeish=commit_sha) # Verify the file was restored with LFS content with open(test_file, "rb") as f: content = f.read() self.assertEqual(content, test_content) if __name__ == "__main__": unittest.main()