|
@@ -0,0 +1,351 @@
|
|
|
+#!/usr/bin/python
|
|
|
+# test_lfs.py -- Compatibility tests for LFS.
|
|
|
+# Copyright (C) 2025 Dulwich contributors
|
|
|
+#
|
|
|
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
|
|
|
+# General Public License as public 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
|
|
|
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
|
|
|
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
|
|
|
+# License, Version 2.0.
|
|
|
+#
|
|
|
+
|
|
|
+"""Compatibility tests for LFS functionality between dulwich and git-lfs."""
|
|
|
+
|
|
|
+import os
|
|
|
+import shutil
|
|
|
+import subprocess
|
|
|
+import tempfile
|
|
|
+from unittest import skipUnless
|
|
|
+
|
|
|
+from dulwich import porcelain
|
|
|
+from dulwich.lfs import LFSPointer
|
|
|
+from dulwich.porcelain import lfs_clean, lfs_init, lfs_smudge, lfs_track
|
|
|
+
|
|
|
+from .utils import CompatTestCase, run_git_or_fail
|
|
|
+
|
|
|
+
|
|
|
+def git_lfs_version():
|
|
|
+ """Get git-lfs version tuple."""
|
|
|
+ try:
|
|
|
+ output = run_git_or_fail(["lfs", "version"])
|
|
|
+ # Example output: "git-lfs/3.0.2 (GitHub; linux amd64; go 1.17.2)"
|
|
|
+ version_str = output.split(b"/")[1].split()[0]
|
|
|
+ return tuple(map(int, version_str.decode().split(".")))
|
|
|
+ except (OSError, subprocess.CalledProcessError, AssertionError):
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+class LFSCompatTestCase(CompatTestCase):
|
|
|
+ """Base class for LFS compatibility tests."""
|
|
|
+
|
|
|
+ min_git_version = (2, 0, 0) # git-lfs requires git 2.0+
|
|
|
+
|
|
|
+ def setUp(self):
|
|
|
+ super().setUp()
|
|
|
+ if git_lfs_version() is None:
|
|
|
+ self.skipTest("git-lfs not available")
|
|
|
+
|
|
|
+ def assertPointerEquals(self, pointer1, pointer2):
|
|
|
+ """Assert two LFS pointers are equivalent."""
|
|
|
+ self.assertEqual(pointer1.oid, pointer2.oid)
|
|
|
+ self.assertEqual(pointer1.size, pointer2.size)
|
|
|
+
|
|
|
+ def make_temp_dir(self):
|
|
|
+ """Create a temporary directory that will be cleaned up."""
|
|
|
+ temp_dir = tempfile.mkdtemp()
|
|
|
+ self.addCleanup(shutil.rmtree, temp_dir)
|
|
|
+ return temp_dir
|
|
|
+
|
|
|
+
|
|
|
+class LFSInitCompatTest(LFSCompatTestCase):
|
|
|
+ """Tests for LFS initialization compatibility."""
|
|
|
+
|
|
|
+ def test_lfs_init_dulwich(self):
|
|
|
+ """Test that dulwich lfs_init is compatible with git-lfs."""
|
|
|
+ # Initialize with dulwich
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=repo_dir)
|
|
|
+ lfs_init(repo_dir)
|
|
|
+
|
|
|
+ # Verify with git-lfs
|
|
|
+ output = run_git_or_fail(["lfs", "env"], cwd=repo_dir)
|
|
|
+ self.assertIn("git config filter.lfs.clean", output)
|
|
|
+ self.assertIn("git config filter.lfs.smudge", output)
|
|
|
+
|
|
|
+ def test_lfs_init_git(self):
|
|
|
+ """Test that git-lfs init is compatible with dulwich."""
|
|
|
+ # Initialize with git-lfs
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["lfs", "install"], cwd=repo_dir)
|
|
|
+
|
|
|
+ # Verify with dulwich
|
|
|
+ config = porcelain.get_config_stack(repo_dir)
|
|
|
+ self.assertEqual(
|
|
|
+ config.get(("filter", "lfs"), "clean").decode(), "git-lfs clean -- %f"
|
|
|
+ )
|
|
|
+ self.assertEqual(
|
|
|
+ config.get(("filter", "lfs"), "smudge").decode(), "git-lfs smudge -- %f"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class LFSTrackCompatTest(LFSCompatTestCase):
|
|
|
+ """Tests for LFS tracking compatibility."""
|
|
|
+
|
|
|
+ def test_track_dulwich(self):
|
|
|
+ """Test that dulwich lfs_track is compatible with git-lfs."""
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=repo_dir)
|
|
|
+ lfs_init(repo_dir)
|
|
|
+
|
|
|
+ # Track with dulwich
|
|
|
+ lfs_track(repo_dir, ["*.bin", "*.dat"])
|
|
|
+
|
|
|
+ # Verify with git-lfs
|
|
|
+ output = run_git_or_fail(["lfs", "track"], cwd=repo_dir)
|
|
|
+ self.assertIn("*.bin", output)
|
|
|
+ self.assertIn("*.dat", output)
|
|
|
+
|
|
|
+ def test_track_git(self):
|
|
|
+ """Test that git-lfs track is compatible with dulwich."""
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["lfs", "install"], cwd=repo_dir)
|
|
|
+
|
|
|
+ # Track with git-lfs
|
|
|
+ run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["lfs", "track", "*.dat"], cwd=repo_dir)
|
|
|
+
|
|
|
+ # Verify with dulwich
|
|
|
+ gitattributes_path = os.path.join(repo_dir, ".gitattributes")
|
|
|
+ with open(gitattributes_path, "rb") as f:
|
|
|
+ content = f.read().decode()
|
|
|
+ self.assertIn("*.bin filter=lfs", content)
|
|
|
+ self.assertIn("*.dat filter=lfs", content)
|
|
|
+
|
|
|
+
|
|
|
+class LFSFileOperationsCompatTest(LFSCompatTestCase):
|
|
|
+ """Tests for LFS file operations compatibility."""
|
|
|
+
|
|
|
+ def test_add_commit_dulwich(self):
|
|
|
+ """Test adding and committing LFS files with dulwich."""
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=repo_dir)
|
|
|
+ lfs_init(repo_dir)
|
|
|
+ lfs_track(repo_dir, ["*.bin"])
|
|
|
+
|
|
|
+ # Create and add a large file
|
|
|
+ test_file = os.path.join(repo_dir, "test.bin")
|
|
|
+ test_content = b"x" * 1024 * 1024 # 1MB
|
|
|
+ with open(test_file, "wb") as f:
|
|
|
+ f.write(test_content)
|
|
|
+
|
|
|
+ # Add with dulwich
|
|
|
+ porcelain.add(repo_dir, [test_file])
|
|
|
+ porcelain.commit(repo_dir, message=b"Add LFS file")
|
|
|
+
|
|
|
+ # Verify with git-lfs
|
|
|
+ output = run_git_or_fail(["lfs", "ls-files"], cwd=repo_dir)
|
|
|
+ self.assertIn("test.bin", output)
|
|
|
+
|
|
|
+ # Check pointer file in git
|
|
|
+ output = run_git_or_fail(["show", "HEAD:test.bin"], cwd=repo_dir)
|
|
|
+ self.assertIn("version https://git-lfs.github.com/spec/v1", output)
|
|
|
+ self.assertIn("oid sha256:", output)
|
|
|
+ self.assertIn("size 1048576", output)
|
|
|
+
|
|
|
+ def test_add_commit_git(self):
|
|
|
+ """Test adding and committing LFS files with git-lfs."""
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["lfs", "install"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
|
|
|
+
|
|
|
+ # Create and add a large file
|
|
|
+ test_file = os.path.join(repo_dir, "test.bin")
|
|
|
+ test_content = b"y" * 1024 * 1024 # 1MB
|
|
|
+ with open(test_file, "wb") as f:
|
|
|
+ f.write(test_content)
|
|
|
+
|
|
|
+ # Add with git-lfs
|
|
|
+ run_git_or_fail(["add", "test.bin"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=repo_dir)
|
|
|
+
|
|
|
+ # Verify with dulwich
|
|
|
+ repo = porcelain.open_repo(repo_dir)
|
|
|
+ tree = repo[repo.head()].tree
|
|
|
+ entry = repo.object_store[tree][b"test.bin"]
|
|
|
+ blob = repo.object_store[entry.binsha]
|
|
|
+ pointer = LFSPointer.from_bytes(blob.data)
|
|
|
+ self.assertEqual(pointer.size, 1048576)
|
|
|
+
|
|
|
+ def test_checkout_dulwich(self):
|
|
|
+ """Test checking out LFS files with dulwich."""
|
|
|
+ # Create repo with git-lfs
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["lfs", "install"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["lfs", "track", "*.bin"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["add", ".gitattributes"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=repo_dir)
|
|
|
+
|
|
|
+ # Add LFS file
|
|
|
+ test_file = os.path.join(repo_dir, "test.bin")
|
|
|
+ test_content = b"z" * 1024 * 1024 # 1MB
|
|
|
+ with open(test_file, "wb") as f:
|
|
|
+ f.write(test_content)
|
|
|
+ run_git_or_fail(["add", "test.bin"], cwd=repo_dir)
|
|
|
+ run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=repo_dir)
|
|
|
+
|
|
|
+ # Remove working copy
|
|
|
+ os.remove(test_file)
|
|
|
+
|
|
|
+ # Checkout with dulwich
|
|
|
+ porcelain.reset(repo_dir, mode="hard")
|
|
|
+
|
|
|
+ # Verify file contents
|
|
|
+ with open(test_file, "rb") as f:
|
|
|
+ content = f.read()
|
|
|
+ self.assertEqual(content, test_content)
|
|
|
+
|
|
|
+
|
|
|
+class LFSPointerCompatTest(LFSCompatTestCase):
|
|
|
+ """Tests for LFS pointer file compatibility."""
|
|
|
+
|
|
|
+ def test_pointer_format_dulwich(self):
|
|
|
+ """Test that dulwich creates git-lfs compatible pointers."""
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ test_content = b"test content for LFS"
|
|
|
+
|
|
|
+ # Create pointer with dulwich
|
|
|
+ pointer_data = lfs_clean(test_content)
|
|
|
+
|
|
|
+ # Parse with git-lfs (create a file and check)
|
|
|
+ test_file = os.path.join(repo_dir, "test_pointer")
|
|
|
+ with open(test_file, "wb") as f:
|
|
|
+ f.write(pointer_data)
|
|
|
+
|
|
|
+ # Verify pointer format
|
|
|
+ with open(test_file, "rb") as f:
|
|
|
+ lines = f.read().decode().strip().split("\n")
|
|
|
+
|
|
|
+ self.assertEqual(lines[0], "version https://git-lfs.github.com/spec/v1")
|
|
|
+ self.assertTrue(lines[1].startswith("oid sha256:"))
|
|
|
+ self.assertTrue(lines[2].startswith("size "))
|
|
|
+
|
|
|
+ def test_pointer_format_git(self):
|
|
|
+ """Test that dulwich can parse git-lfs pointers."""
|
|
|
+ # Create a git-lfs pointer manually
|
|
|
+ oid = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
|
+ size = 12345
|
|
|
+ pointer_content = f"version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n"
|
|
|
+
|
|
|
+ # Parse with dulwich
|
|
|
+ pointer = LFSPointer.from_bytes(pointer_content.encode())
|
|
|
+
|
|
|
+ self.assertEqual(pointer.oid, oid)
|
|
|
+ self.assertEqual(pointer.size, size)
|
|
|
+
|
|
|
+
|
|
|
+class LFSFilterCompatTest(LFSCompatTestCase):
|
|
|
+ """Tests for LFS filter operations compatibility."""
|
|
|
+
|
|
|
+ def test_clean_filter_compat(self):
|
|
|
+ """Test clean filter compatibility between dulwich and git-lfs."""
|
|
|
+ test_content = b"x" * 1000
|
|
|
+
|
|
|
+ # Clean with dulwich
|
|
|
+ dulwich_pointer = lfs_clean(test_content)
|
|
|
+
|
|
|
+ # Clean with git-lfs (simulate)
|
|
|
+ # Since we can't easily invoke git-lfs clean directly,
|
|
|
+ # we'll test that the pointer format is correct
|
|
|
+ self.assertIn(b"version https://git-lfs.github.com/spec/v1", dulwich_pointer)
|
|
|
+ self.assertIn(b"oid sha256:", dulwich_pointer)
|
|
|
+ self.assertIn(b"size 1000", dulwich_pointer)
|
|
|
+
|
|
|
+ def test_smudge_filter_compat(self):
|
|
|
+ """Test smudge filter compatibility between dulwich and git-lfs."""
|
|
|
+ # Create a test repo with LFS
|
|
|
+ repo_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=repo_dir)
|
|
|
+ lfs_init(repo_dir)
|
|
|
+
|
|
|
+ # Create test content
|
|
|
+ test_content = b"test data for smudge filter"
|
|
|
+ pointer_data = lfs_clean(test_content)
|
|
|
+
|
|
|
+ # Store object in LFS
|
|
|
+ lfs_dir = os.path.join(repo_dir, ".git", "lfs")
|
|
|
+ os.makedirs(lfs_dir, exist_ok=True)
|
|
|
+
|
|
|
+ # Parse pointer to get oid
|
|
|
+ pointer = LFSPointer.from_bytes(pointer_data)
|
|
|
+
|
|
|
+ # Store object
|
|
|
+ obj_dir = os.path.join(lfs_dir, "objects", pointer.oid[:2], pointer.oid[2:4])
|
|
|
+ os.makedirs(obj_dir, exist_ok=True)
|
|
|
+ obj_path = os.path.join(obj_dir, pointer.oid)
|
|
|
+ with open(obj_path, "wb") as f:
|
|
|
+ f.write(test_content)
|
|
|
+
|
|
|
+ # Test smudge
|
|
|
+ smudged = lfs_smudge(repo_dir, pointer_data)
|
|
|
+ self.assertEqual(smudged, test_content)
|
|
|
+
|
|
|
+
|
|
|
+class LFSCloneCompatTest(LFSCompatTestCase):
|
|
|
+ """Tests for cloning repositories with LFS files."""
|
|
|
+
|
|
|
+ @skipUnless(
|
|
|
+ git_lfs_version() and git_lfs_version() >= (2, 0, 0),
|
|
|
+ "git-lfs 2.0+ required for clone tests",
|
|
|
+ )
|
|
|
+ def test_clone_with_lfs(self):
|
|
|
+ """Test cloning a repository with LFS files."""
|
|
|
+ # Create source repo with LFS
|
|
|
+ source_dir = self.make_temp_dir()
|
|
|
+ run_git_or_fail(["init"], cwd=source_dir)
|
|
|
+ run_git_or_fail(["lfs", "install"], cwd=source_dir)
|
|
|
+ run_git_or_fail(["lfs", "track", "*.bin"], cwd=source_dir)
|
|
|
+ run_git_or_fail(["add", ".gitattributes"], cwd=source_dir)
|
|
|
+ run_git_or_fail(["commit", "-m", "Track .bin files"], cwd=source_dir)
|
|
|
+
|
|
|
+ # Add LFS file
|
|
|
+ test_file = os.path.join(source_dir, "test.bin")
|
|
|
+ test_content = b"w" * 1024 * 1024 # 1MB
|
|
|
+ with open(test_file, "wb") as f:
|
|
|
+ f.write(test_content)
|
|
|
+ run_git_or_fail(["add", "test.bin"], cwd=source_dir)
|
|
|
+ run_git_or_fail(["commit", "-m", "Add LFS file"], cwd=source_dir)
|
|
|
+
|
|
|
+ # Clone with dulwich
|
|
|
+ target_dir = self.make_temp_dir()
|
|
|
+ porcelain.clone(source_dir, target_dir)
|
|
|
+
|
|
|
+ # Verify LFS file exists as pointer
|
|
|
+ cloned_file = os.path.join(target_dir, "test.bin")
|
|
|
+ with open(cloned_file, "rb") as f:
|
|
|
+ content = f.read()
|
|
|
+
|
|
|
+ # Should be a pointer, not the full content
|
|
|
+ self.assertLess(len(content), 1000) # Pointer is much smaller
|
|
|
+ self.assertIn(b"version https://git-lfs.github.com/spec/v1", content)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ import unittest
|
|
|
+
|
|
|
+ unittest.main()
|