Jelmer Vernooij 6 napja
szülő
commit
58deddd6b8

+ 1 - 1
dulwich/porcelain/submodule.py

@@ -51,7 +51,7 @@ def submodule_add(
 
     with open_repo_closing(repo) as r:
         if path is None:
-            path = os.path.relpath(_canonical_part(url), r.path)
+            path = _canonical_part(url)
         if name is None:
             name = os.fsdecode(path) if path is not None else None
 

+ 1 - 1
dulwich/porcelain/tag.py

@@ -157,7 +157,7 @@ def tag_create(
             if tag_timezone is None:
                 tag_timezone = get_user_timezones()[1]
             elif isinstance(tag_timezone, str):
-                tag_timezone = parse_timezone(tag_timezone.encode())
+                tag_timezone, _ = parse_timezone(tag_timezone.encode())
             tag_obj.tag_timezone = tag_timezone
 
             # Check if we should sign the tag

+ 8 - 2
tests/__init__.py

@@ -123,6 +123,8 @@ class BlackboxTestCase(TestCase):
 
 def self_test_suite() -> unittest.TestSuite:
     names = [
+        "__init__",
+        "__main__",
         "aiohttp",
         "annotate",
         "approxidate",
@@ -164,10 +166,11 @@ def self_test_suite() -> unittest.TestSuite:
         "midx",
         "missing_obj_finder",
         "notes",
-        "objects",
-        "objectspec",
         "object_filters",
+        "object_format",
         "object_store",
+        "objects",
+        "objectspec",
         "pack",
         "patch",
         "protocol",
@@ -212,6 +215,9 @@ def self_test_suite() -> unittest.TestSuite:
         "merge",
         "notes",
         "rebase",
+        "submodule",
+        "tag",
+        "worktree",
     ]
     module_names += ["tests.porcelain"] + [
         "tests.porcelain.test_" + name for name in porcelain_names

+ 45 - 0
tests/porcelain/test_notes.py

@@ -172,3 +172,48 @@ class TestPorcelainNotes(TestCase):
         commit = self.repo[note_commit_id]
         self.assertEqual(b"Custom Author <author@example.com>", commit.author)
         self.assertEqual(b"Custom Committer <committer@example.com>", commit.committer)
+
+    def test_notes_ref_already_qualified(self):
+        """Test using a notes ref that's already fully qualified."""
+        # Add note using fully qualified ref
+        porcelain.notes_add(
+            self.test_dir,
+            self.test_commit_id,
+            b"Note with qualified ref",
+            ref=b"refs/notes/custom",
+        )
+
+        # Show using the same qualified ref
+        note = porcelain.notes_show(
+            self.test_dir, self.test_commit_id, ref=b"refs/notes/custom"
+        )
+        self.assertEqual(b"Note with qualified ref", note)
+
+    def test_notes_remove_with_string_ref(self):
+        """Test removing a note with string ref parameter."""
+        # Add a note to custom ref
+        porcelain.notes_add(
+            self.test_dir, self.test_commit_id, b"Note to remove", ref="custom"
+        )
+
+        # Remove using string ref
+        result = porcelain.notes_remove(
+            self.test_dir, self.test_commit_id, ref="custom"
+        )
+        self.assertIsNotNone(result)
+
+        # Verify it's gone
+        note = porcelain.notes_show(self.test_dir, self.test_commit_id, ref="custom")
+        self.assertIsNone(note)
+
+    def test_notes_list_with_string_ref(self):
+        """Test listing notes with string ref parameter."""
+        # Add notes to custom ref
+        porcelain.notes_add(
+            self.test_dir, self.test_commit_id, b"Custom note", ref="myref"
+        )
+
+        # List using string ref
+        notes = porcelain.notes_list(self.test_dir, ref="myref")
+        self.assertEqual(1, len(notes))
+        self.assertEqual(b"Custom note", notes[0][1])

+ 226 - 0
tests/porcelain/test_submodule.py

@@ -0,0 +1,226 @@
+# test_submodule.py -- tests for porcelain submodule functions
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# 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
+# <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.
+#
+
+"""Tests for porcelain submodule functions."""
+
+import os
+import shutil
+import tempfile
+
+from dulwich import porcelain
+from dulwich.config import ConfigFile
+from dulwich.objects import Blob, Commit, Tree
+from dulwich.repo import Repo
+
+from .. import TestCase
+
+
+class SubmoduleAddTests(TestCase):
+    """Tests for submodule_add function."""
+
+    def setUp(self):
+        super().setUp()
+        self.test_dir = tempfile.mkdtemp()
+        self.repo_path = os.path.join(self.test_dir, "repo")
+        self.repo = Repo.init(self.repo_path, mkdir=True)
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir)
+        super().tearDown()
+
+    def test_submodule_add_basic(self) -> None:
+        """Test basic submodule_add with URL."""
+        url = "https://github.com/dulwich/dulwich.git"
+        path = "libs/dulwich"
+
+        porcelain.submodule_add(self.repo, url, path, name="dulwich")
+
+        # Check that .gitmodules was created
+        gitmodules_path = os.path.join(self.repo_path, ".gitmodules")
+        self.assertTrue(os.path.exists(gitmodules_path))
+
+        # Check .gitmodules content
+        config = ConfigFile.from_path(gitmodules_path)
+        self.assertEqual(url.encode(), config.get(("submodule", "dulwich"), "url"))
+        self.assertEqual(path.encode(), config.get(("submodule", "dulwich"), "path"))
+
+    def test_submodule_add_without_name(self) -> None:
+        """Test submodule_add derives name from path when not specified."""
+        url = "https://github.com/dulwich/dulwich.git"
+        path = "libs/dulwich"
+
+        porcelain.submodule_add(self.repo, url, path)
+
+        # Check that .gitmodules was created
+        gitmodules_path = os.path.join(self.repo_path, ".gitmodules")
+        config = ConfigFile.from_path(gitmodules_path)
+
+        # Name should be derived from path
+        self.assertEqual(url.encode(), config.get(("submodule", "libs/dulwich"), "url"))
+
+    def test_submodule_add_without_path(self) -> None:
+        """Test submodule_add derives path from URL when not specified."""
+        url = "https://github.com/dulwich/dulwich.git"
+
+        porcelain.submodule_add(self.repo, url)
+
+        # Check that .gitmodules was created
+        gitmodules_path = os.path.join(self.repo_path, ".gitmodules")
+        config = ConfigFile.from_path(gitmodules_path)
+
+        # Path should be derived from URL (just "dulwich")
+        # The actual value depends on _canonical_part implementation
+        # We just check that something was written
+        sections = list(config.keys())
+        self.assertEqual(1, len([s for s in sections if s[0] == b"submodule"]))
+
+    def test_submodule_add_updates_existing_gitmodules(self) -> None:
+        """Test that submodule_add updates existing .gitmodules file."""
+        # Add first submodule
+        url1 = "https://github.com/dulwich/dulwich.git"
+        path1 = "libs/dulwich"
+        porcelain.submodule_add(self.repo, url1, path1, name="dulwich")
+
+        # Add second submodule
+        url2 = "https://github.com/dulwich/dulwich-tests.git"
+        path2 = "libs/tests"
+        porcelain.submodule_add(self.repo, url2, path2, name="tests")
+
+        # Check both submodules are in .gitmodules
+        gitmodules_path = os.path.join(self.repo_path, ".gitmodules")
+        config = ConfigFile.from_path(gitmodules_path)
+
+        self.assertEqual(url1.encode(), config.get(("submodule", "dulwich"), "url"))
+        self.assertEqual(url2.encode(), config.get(("submodule", "tests"), "url"))
+
+
+class SubmoduleInitTests(TestCase):
+    """Tests for submodule_init function."""
+
+    def setUp(self):
+        super().setUp()
+        self.test_dir = tempfile.mkdtemp()
+        self.repo_path = os.path.join(self.test_dir, "repo")
+        self.repo = Repo.init(self.repo_path, mkdir=True)
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir)
+        super().tearDown()
+
+    def test_submodule_init(self) -> None:
+        """Test submodule_init reads from .gitmodules and updates config."""
+        # Create .gitmodules file
+        gitmodules_path = os.path.join(self.repo_path, ".gitmodules")
+        config = ConfigFile()
+        config.set(
+            ("submodule", "dulwich"), "url", "https://github.com/dulwich/dulwich.git"
+        )
+        config.set(("submodule", "dulwich"), "path", "libs/dulwich")
+        config.path = gitmodules_path
+        config.write_to_path()
+
+        # Initialize submodules
+        porcelain.submodule_init(self.repo)
+
+        # Check that repo config was updated
+        repo_config = self.repo.get_config()
+        self.assertEqual(
+            b"true", repo_config.get((b"submodule", b"dulwich"), b"active")
+        )
+        self.assertEqual(
+            b"https://github.com/dulwich/dulwich.git",
+            repo_config.get((b"submodule", b"dulwich"), b"url"),
+        )
+
+    def test_submodule_init_no_gitmodules(self) -> None:
+        """Test submodule_init raises FileNotFoundError when .gitmodules is missing."""
+        # Should raise FileNotFoundError when .gitmodules doesn't exist
+        with self.assertRaises(FileNotFoundError):
+            porcelain.submodule_init(self.repo)
+
+
+class SubmoduleListTests(TestCase):
+    """Tests for submodule_list function."""
+
+    def setUp(self):
+        super().setUp()
+        self.test_dir = tempfile.mkdtemp()
+        self.repo_path = os.path.join(self.test_dir, "repo")
+        self.repo = Repo.init(self.repo_path, mkdir=True)
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir)
+        super().tearDown()
+
+    def test_submodule_list_empty(self) -> None:
+        """Test submodule_list with no submodules."""
+        # Create an initial commit
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test.txt", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = commit.committer = b"Test User <test@example.com>"
+        commit.author_time = commit.commit_time = 1234567890
+        commit.author_timezone = commit.commit_timezone = 0
+        commit.encoding = b"UTF-8"
+        commit.message = b"Initial commit"
+        self.repo.object_store.add_object(commit)
+
+        self.repo.refs[b"refs/heads/main"] = commit.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/main")
+
+        # List should be empty
+        submodules = list(porcelain.submodule_list(self.repo))
+        self.assertEqual([], submodules)
+
+    def test_submodule_list_with_submodule(self) -> None:
+        """Test submodule_list with a submodule in the tree."""
+        # Create a tree with a submodule entry
+        tree = Tree()
+        tree.add(b"test.txt", 0o100644, b"a" * 40)  # Dummy file
+        # Add a submodule entry with gitlink mode (0o160000)
+        submodule_sha = b"1" * 40
+        tree.add(b"libs/mylib", 0o160000, submodule_sha)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = commit.committer = b"Test User <test@example.com>"
+        commit.author_time = commit.commit_time = 1234567890
+        commit.author_timezone = commit.commit_timezone = 0
+        commit.encoding = b"UTF-8"
+        commit.message = b"Add submodule"
+        self.repo.object_store.add_object(commit)
+
+        self.repo.refs[b"refs/heads/main"] = commit.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/main")
+
+        # List should contain the submodule
+        submodules = list(porcelain.submodule_list(self.repo))
+        self.assertEqual(1, len(submodules))
+        path, sha = submodules[0]
+        self.assertEqual("libs/mylib", path)
+        self.assertEqual(submodule_sha.decode(), sha)

+ 253 - 0
tests/porcelain/test_tag.py

@@ -0,0 +1,253 @@
+# test_tag.py -- tests for porcelain tag functions
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# 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
+# <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.
+#
+
+"""Tests for porcelain tag functions."""
+
+import os
+import shutil
+import tempfile
+
+from dulwich import porcelain
+from dulwich.objects import Blob, Commit, Tree
+from dulwich.repo import Repo
+
+from .. import TestCase
+
+
+class TagDeleteTests(TestCase):
+    """Tests for tag_delete function."""
+
+    def setUp(self):
+        super().setUp()
+        self.test_dir = tempfile.mkdtemp()
+        self.repo_path = os.path.join(self.test_dir, "repo")
+        self.repo = Repo.init(self.repo_path, mkdir=True)
+
+        # Create a simple commit to tag
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test.txt", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = commit.committer = b"Test User <test@example.com>"
+        commit.author_time = commit.commit_time = 1234567890
+        commit.author_timezone = commit.commit_timezone = 0
+        commit.encoding = b"UTF-8"
+        commit.message = b"Test commit"
+        self.repo.object_store.add_object(commit)
+
+        self.repo.refs[b"refs/heads/main"] = commit.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/main")
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir)
+        super().tearDown()
+
+    def test_tag_delete_with_bytes(self) -> None:
+        """Test tag_delete with bytes tag name."""
+        # Create a tag
+        porcelain.tag_create(self.repo, b"test-tag", annotated=False)
+        self.assertIn(b"refs/tags/test-tag", self.repo.refs)
+
+        # Delete the tag with bytes
+        porcelain.tag_delete(self.repo, b"test-tag")
+        self.assertNotIn(b"refs/tags/test-tag", self.repo.refs)
+
+    def test_tag_delete_invalid_type(self) -> None:
+        """Test tag_delete with invalid input type raises Error."""
+        from dulwich.porcelain import Error
+
+        # Create a tag
+        porcelain.tag_create(self.repo, b"test-tag", annotated=False)
+
+        # Try to delete with invalid type (should raise Error)
+        with self.assertRaises(Error) as cm:
+            porcelain.tag_delete(self.repo, 123)  # type: ignore
+
+        self.assertEqual("Unexpected tag name type 123", str(cm.exception))
+
+
+class TagCreateWithEncodingTests(TestCase):
+    """Tests for tag_create with encoding parameters."""
+
+    def setUp(self):
+        super().setUp()
+        self.test_dir = tempfile.mkdtemp()
+        self.repo_path = os.path.join(self.test_dir, "repo")
+        self.repo = Repo.init(self.repo_path, mkdir=True)
+
+        # Create a simple commit
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test.txt", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = commit.committer = b"Test User <test@example.com>"
+        commit.author_time = commit.commit_time = 1234567890
+        commit.author_timezone = commit.commit_timezone = 0
+        commit.encoding = b"UTF-8"
+        commit.message = b"Test commit"
+        self.repo.object_store.add_object(commit)
+
+        self.repo.refs[b"refs/heads/main"] = commit.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/main")
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir)
+        super().tearDown()
+
+    def test_tag_create_with_string_tag_name(self) -> None:
+        """Test tag_create with string tag name gets encoded."""
+        porcelain.tag_create(self.repo, "my-tag", annotated=True, message="Tag message")
+
+        # Verify tag was created
+        self.assertIn(b"refs/tags/my-tag", self.repo.refs)
+
+    def test_tag_create_with_string_author(self) -> None:
+        """Test tag_create with string author gets encoded."""
+        porcelain.tag_create(
+            self.repo,
+            b"test-tag",
+            author="Test Author <author@example.com>",
+            annotated=True,
+            message=b"Test message",
+        )
+
+        # Verify tag was created
+        tag_ref = self.repo.refs[b"refs/tags/test-tag"]
+        tag_obj = self.repo[tag_ref]
+        self.assertEqual(b"Test Author <author@example.com>", tag_obj.tagger)
+
+    def test_tag_create_with_bytes_author(self) -> None:
+        """Test tag_create with bytes author."""
+        porcelain.tag_create(
+            self.repo,
+            b"test-tag",
+            author=b"Test Author <author@example.com>",
+            annotated=True,
+            message=b"Test message",
+        )
+
+        # Verify tag was created
+        tag_ref = self.repo.refs[b"refs/tags/test-tag"]
+        tag_obj = self.repo[tag_ref]
+        self.assertEqual(b"Test Author <author@example.com>", tag_obj.tagger)
+
+    def test_tag_create_with_string_message(self) -> None:
+        """Test tag_create with string message gets encoded."""
+        porcelain.tag_create(
+            self.repo,
+            b"test-tag",
+            author=b"Test <test@example.com>",
+            annotated=True,
+            message="This is a test message",
+        )
+
+        # Verify tag was created with message
+        tag_ref = self.repo.refs[b"refs/tags/test-tag"]
+        tag_obj = self.repo[tag_ref]
+        self.assertEqual(b"This is a test message\n", tag_obj.message)
+
+    def test_tag_create_with_bytes_message(self) -> None:
+        """Test tag_create with bytes message."""
+        porcelain.tag_create(
+            self.repo,
+            b"test-tag",
+            author=b"Test <test@example.com>",
+            annotated=True,
+            message=b"This is a test message",
+        )
+
+        # Verify tag was created with message
+        tag_ref = self.repo.refs[b"refs/tags/test-tag"]
+        tag_obj = self.repo[tag_ref]
+        self.assertEqual(b"This is a test message\n", tag_obj.message)
+
+    def test_tag_create_with_none_message(self) -> None:
+        """Test tag_create with None message defaults to empty."""
+        porcelain.tag_create(
+            self.repo,
+            b"test-tag",
+            author=b"Test <test@example.com>",
+            annotated=True,
+            message=None,
+        )
+
+        # Verify tag was created with empty message
+        tag_ref = self.repo.refs[b"refs/tags/test-tag"]
+        tag_obj = self.repo[tag_ref]
+        self.assertEqual(b"\n", tag_obj.message)
+
+    def test_tag_create_with_explicit_tag_time(self) -> None:
+        """Test tag_create with explicit tag_time."""
+        tag_time = 1700000000
+        porcelain.tag_create(
+            self.repo,
+            b"test-tag",
+            author=b"Test <test@example.com>",
+            annotated=True,
+            message=b"Test",
+            tag_time=tag_time,
+        )
+
+        # Verify tag time is set correctly
+        tag_ref = self.repo.refs[b"refs/tags/test-tag"]
+        tag_obj = self.repo[tag_ref]
+        self.assertEqual(tag_time, tag_obj.tag_time)
+
+    def test_tag_create_with_string_timezone(self) -> None:
+        """Test tag_create with string timezone."""
+        porcelain.tag_create(
+            self.repo,
+            b"test-tag",
+            author=b"Test <test@example.com>",
+            annotated=True,
+            message=b"Test",
+            tag_timezone="+0200",
+        )
+
+        # Verify tag was created with timezone
+        tag_ref = self.repo.refs[b"refs/tags/test-tag"]
+        tag_obj = self.repo[tag_ref]
+        # +0200 is 2 * 60 * 60 = 7200 seconds
+        self.assertEqual(7200, tag_obj.tag_timezone)
+
+    def test_tag_create_with_custom_encoding(self) -> None:
+        """Test tag_create with custom encoding."""
+        porcelain.tag_create(
+            self.repo,
+            "test-tag",
+            author="Test <test@example.com>",
+            annotated=True,
+            message="Test message",
+            encoding="utf-8",
+        )
+
+        # Verify tag was created
+        self.assertIn(b"refs/tags/test-tag", self.repo.refs)

+ 134 - 0
tests/porcelain/test_worktree.py

@@ -0,0 +1,134 @@
+# test_worktree.py -- tests for porcelain worktree functions
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# 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
+# <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.
+#
+
+"""Tests for porcelain worktree functions."""
+
+import os
+import shutil
+import tempfile
+
+from dulwich import porcelain
+from dulwich.objects import Blob, Commit, Tree
+from dulwich.repo import Repo
+
+from .. import TestCase
+
+
+class WorktreeErrorTests(TestCase):
+    """Tests for worktree function error handling."""
+
+    def setUp(self):
+        super().setUp()
+        self.test_dir = tempfile.mkdtemp()
+        self.repo_path = os.path.join(self.test_dir, "repo")
+        self.repo = Repo.init(self.repo_path, mkdir=True)
+
+        # Create a simple commit
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test.txt", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = commit.committer = b"Test User <test@example.com>"
+        commit.author_time = commit.commit_time = 1234567890
+        commit.author_timezone = commit.commit_timezone = 0
+        commit.encoding = b"UTF-8"
+        commit.message = b"Test commit"
+        self.repo.object_store.add_object(commit)
+
+        self.repo.refs[b"refs/heads/main"] = commit.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/main")
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir)
+        super().tearDown()
+
+    def test_worktree_add_without_path(self) -> None:
+        """Test worktree_add raises ValueError when path is None."""
+        with self.assertRaises(ValueError) as cm:
+            porcelain.worktree_add(self.repo, path=None)
+
+        self.assertEqual("Path is required for worktree add", str(cm.exception))
+
+    def test_worktree_remove_without_path(self) -> None:
+        """Test worktree_remove raises ValueError when path is None."""
+        with self.assertRaises(ValueError) as cm:
+            porcelain.worktree_remove(self.repo, path=None)
+
+        self.assertEqual("Path is required for worktree remove", str(cm.exception))
+
+    def test_worktree_lock_without_path(self) -> None:
+        """Test worktree_lock raises ValueError when path is None."""
+        with self.assertRaises(ValueError) as cm:
+            porcelain.worktree_lock(self.repo, path=None)
+
+        self.assertEqual("Path is required for worktree lock", str(cm.exception))
+
+    def test_worktree_unlock_without_path(self) -> None:
+        """Test worktree_unlock raises ValueError when path is None."""
+        with self.assertRaises(ValueError) as cm:
+            porcelain.worktree_unlock(self.repo, path=None)
+
+        self.assertEqual("Path is required for worktree unlock", str(cm.exception))
+
+    def test_worktree_move_without_old_path(self) -> None:
+        """Test worktree_move raises ValueError when old_path is None."""
+        new_path = os.path.join(self.test_dir, "new_worktree")
+
+        with self.assertRaises(ValueError) as cm:
+            porcelain.worktree_move(self.repo, old_path=None, new_path=new_path)
+
+        self.assertEqual(
+            "Both old_path and new_path are required for worktree move",
+            str(cm.exception),
+        )
+
+    def test_worktree_move_without_new_path(self) -> None:
+        """Test worktree_move raises ValueError when new_path is None."""
+        old_path = os.path.join(self.test_dir, "old_worktree")
+
+        with self.assertRaises(ValueError) as cm:
+            porcelain.worktree_move(self.repo, old_path=old_path, new_path=None)
+
+        self.assertEqual(
+            "Both old_path and new_path are required for worktree move",
+            str(cm.exception),
+        )
+
+    def test_worktree_move_without_both_paths(self) -> None:
+        """Test worktree_move raises ValueError when both paths are None."""
+        with self.assertRaises(ValueError) as cm:
+            porcelain.worktree_move(self.repo, old_path=None, new_path=None)
+
+        self.assertEqual(
+            "Both old_path and new_path are required for worktree move",
+            str(cm.exception),
+        )
+
+    def test_worktree_repair_with_no_paths(self) -> None:
+        """Test worktree_repair with paths=None (repairs all)."""
+        # Should not raise, just return empty list if no worktrees to repair
+        result = porcelain.worktree_repair(self.repo, paths=None)
+        self.assertIsInstance(result, list)

+ 134 - 0
tests/test___init__.py

@@ -0,0 +1,134 @@
+# test___init__.py -- tests for __init__.py
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# 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
+# <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.
+#
+
+"""Tests for dulwich __init__ module."""
+
+import sys
+import warnings
+from unittest import mock
+
+from . import TestCase
+
+
+class ReplaceMeDecoratorTests(TestCase):
+    """Tests for the replace_me decorator fallback implementation."""
+
+    def test_replace_me_with_since_only(self) -> None:
+        """Test replace_me decorator with only 'since' parameter."""
+        # Mock dissolve to not be available
+        with mock.patch.dict(sys.modules, {"dissolve": None}):
+            # Need to reimport to get the fallback implementation
+            import importlib
+
+            import dulwich
+
+            importlib.reload(dulwich)
+
+            @dulwich.replace_me(since=(0, 1, 0))
+            def deprecated_func():
+                return "result"
+
+            with warnings.catch_warnings(record=True) as w:
+                warnings.simplefilter("always")
+                result = deprecated_func()
+
+                self.assertEqual("result", result)
+                self.assertEqual(1, len(w))
+                self.assertTrue(issubclass(w[0].category, DeprecationWarning))
+                self.assertEqual(
+                    "deprecated_func is deprecated since (0, 1, 0)",
+                    str(w[0].message),
+                )
+
+    def test_replace_me_with_remove_in_only(self) -> None:
+        """Test replace_me decorator with only 'remove_in' parameter."""
+        with mock.patch.dict(sys.modules, {"dissolve": None}):
+            import importlib
+
+            import dulwich
+
+            importlib.reload(dulwich)
+
+            @dulwich.replace_me(remove_in=(2, 0, 0))
+            def deprecated_func():
+                return "result"
+
+            with warnings.catch_warnings(record=True) as w:
+                warnings.simplefilter("always")
+                result = deprecated_func()
+
+                self.assertEqual("result", result)
+                self.assertEqual(1, len(w))
+                self.assertTrue(issubclass(w[0].category, DeprecationWarning))
+                self.assertEqual(
+                    "deprecated_func is deprecated and will be removed in (2, 0, 0)",
+                    str(w[0].message),
+                )
+
+    def test_replace_me_with_neither_parameter(self) -> None:
+        """Test replace_me decorator with neither 'since' nor 'remove_in'."""
+        with mock.patch.dict(sys.modules, {"dissolve": None}):
+            import importlib
+
+            import dulwich
+
+            importlib.reload(dulwich)
+
+            @dulwich.replace_me()
+            def deprecated_func():
+                return "result"
+
+            with warnings.catch_warnings(record=True) as w:
+                warnings.simplefilter("always")
+                result = deprecated_func()
+
+                self.assertEqual("result", result)
+                self.assertEqual(1, len(w))
+                self.assertTrue(issubclass(w[0].category, DeprecationWarning))
+                self.assertEqual(
+                    "deprecated_func is deprecated and will be removed in a future version",
+                    str(w[0].message),
+                )
+
+    def test_replace_me_with_both_parameters(self) -> None:
+        """Test replace_me decorator with both 'since' and 'remove_in'."""
+        with mock.patch.dict(sys.modules, {"dissolve": None}):
+            import importlib
+
+            import dulwich
+
+            importlib.reload(dulwich)
+
+            @dulwich.replace_me(since=(0, 1, 0), remove_in=(2, 0, 0))
+            def deprecated_func():
+                return "result"
+
+            with warnings.catch_warnings(record=True) as w:
+                warnings.simplefilter("always")
+                result = deprecated_func()
+
+                self.assertEqual("result", result)
+                self.assertEqual(1, len(w))
+                self.assertTrue(issubclass(w[0].category, DeprecationWarning))
+                self.assertEqual(
+                    "deprecated_func is deprecated since (0, 1, 0) and will be removed in (2, 0, 0)",
+                    str(w[0].message),
+                )

+ 80 - 0
tests/test___main__.py

@@ -0,0 +1,80 @@
+# test___main__.py -- tests for __main__.py
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# 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
+# <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.
+#
+
+"""Tests for __main__.py module entry point."""
+
+import subprocess
+import sys
+
+from . import TestCase
+
+
+class MainModuleTests(TestCase):
+    """Tests for the __main__.py module entry point."""
+
+    def test_main_module_help_flag(self) -> None:
+        """Test that running dulwich as a module with --help works."""
+        # Run dulwich as a module using python -m
+        result = subprocess.run(
+            [sys.executable, "-m", "dulwich", "--help"],
+            capture_output=True,
+            text=True,
+        )
+
+        # Help command exits with code 1 (standard behavior when no command is given)
+        self.assertEqual(1, result.returncode)
+
+        # Should start with usage line
+        self.assertTrue(result.stdout.startswith("usage: dulwich"))
+
+    def test_main_module_help_command(self) -> None:
+        """Test that running dulwich as a module with help command works."""
+        result = subprocess.run(
+            [sys.executable, "-m", "dulwich", "help"],
+            capture_output=True,
+            text=True,
+        )
+
+        # Help command should succeed
+        self.assertEqual(0, result.returncode)
+
+        # Check exact output (help goes to stderr)
+        expected = (
+            "The dulwich command line tool is currently a very basic frontend for the\n"
+            "Dulwich python module. For full functionality, please see the API reference.\n"
+            "\n"
+            "For a list of supported commands, see 'dulwich help -a'.\n"
+        )
+        self.assertEqual(expected, result.stderr)
+
+    def test_main_module_no_args(self) -> None:
+        """Test that running dulwich as a module with no arguments shows help."""
+        result = subprocess.run(
+            [sys.executable, "-m", "dulwich"],
+            capture_output=True,
+            text=True,
+        )
+
+        # No arguments should show help and exit with code 1
+        self.assertEqual(1, result.returncode)
+
+        # Should start with usage line
+        self.assertTrue(result.stdout.startswith("usage: dulwich"))

+ 133 - 1
tests/test_archive.py

@@ -26,7 +26,7 @@ import tarfile
 from io import BytesIO
 from unittest.mock import patch
 
-from dulwich.archive import tar_stream
+from dulwich.archive import ChunkedBytesIO, tar_stream
 from dulwich.object_store import MemoryObjectStore
 from dulwich.objects import Blob, Tree
 from dulwich.tests.utils import build_commit_graph
@@ -98,3 +98,135 @@ class ArchiveTests(TestCase):
                 contents[1],
                 f"Different file contents for format {format!r}",
             )
+
+    def test_tar_stream_with_directory(self) -> None:
+        """Test tar_stream with a tree containing directories."""
+        store = MemoryObjectStore()
+
+        # Create a blob for a file
+        b1 = Blob.from_string(b"file in subdir")
+        store.add_object(b1)
+
+        # Create a subtree
+        subtree = Tree()
+        subtree.add(b"file.txt", 0o100644, b1.id)
+        store.add_object(subtree)
+
+        # Create root tree with a directory
+        root_tree = Tree()
+        root_tree.add(b"subdir", 0o040000, subtree.id)
+        store.add_object(root_tree)
+
+        # Generate tar stream
+        stream = b"".join(tar_stream(store, root_tree, 0))
+        tf = tarfile.TarFile(fileobj=BytesIO(stream))
+        self.addCleanup(tf.close)
+
+        # Should contain the file in the subdirectory
+        self.assertEqual(["subdir/file.txt"], tf.getnames())
+
+    def test_tar_stream_with_submodule(self) -> None:
+        """Test tar_stream handles missing objects (submodules) gracefully."""
+        store = MemoryObjectStore()
+
+        # Create a tree with an entry that doesn't exist in the store
+        # (simulating a submodule reference)
+        root_tree = Tree()
+        # Use a valid hex SHA (40 hex chars = 20 bytes)
+        nonexistent_sha = b"a" * 40
+        root_tree.add(b"submodule", 0o160000, nonexistent_sha)
+        store.add_object(root_tree)
+
+        # Should not raise, just skip the missing entry
+        stream = b"".join(tar_stream(store, root_tree, 0))
+        tf = tarfile.TarFile(fileobj=BytesIO(stream))
+        self.addCleanup(tf.close)
+
+        # Submodule should be skipped
+        self.assertEqual([], tf.getnames())
+
+
+class ChunkedBytesIOTests(TestCase):
+    """Tests for ChunkedBytesIO class."""
+
+    def test_read_all(self) -> None:
+        """Test reading all bytes from ChunkedBytesIO."""
+        chunks = [b"hello", b" ", b"world"]
+        chunked = ChunkedBytesIO(chunks)
+
+        result = chunked.read()
+        self.assertEqual(b"hello world", result)
+
+    def test_read_with_limit(self) -> None:
+        """Test reading limited bytes from ChunkedBytesIO."""
+        chunks = [b"hello", b" ", b"world"]
+        chunked = ChunkedBytesIO(chunks)
+
+        # Read first 5 bytes
+        result = chunked.read(5)
+        self.assertEqual(b"hello", result)
+
+        # Read next 3 bytes
+        result = chunked.read(3)
+        self.assertEqual(b" wo", result)
+
+        # Read remaining
+        result = chunked.read()
+        self.assertEqual(b"rld", result)
+
+    def test_read_negative_maxbytes(self) -> None:
+        """Test reading with negative maxbytes reads all."""
+        chunks = [b"hello", b" ", b"world"]
+        chunked = ChunkedBytesIO(chunks)
+
+        result = chunked.read(-1)
+        self.assertEqual(b"hello world", result)
+
+    def test_read_across_chunks(self) -> None:
+        """Test reading across multiple chunks."""
+        chunks = [b"abc", b"def", b"ghi"]
+        chunked = ChunkedBytesIO(chunks)
+
+        # Read 7 bytes (spans three chunks)
+        result = chunked.read(7)
+        self.assertEqual(b"abcdefg", result)
+
+        # Read remaining
+        result = chunked.read()
+        self.assertEqual(b"hi", result)
+
+    def test_read_empty_chunks(self) -> None:
+        """Test reading from empty chunks list."""
+        chunked = ChunkedBytesIO([])
+
+        result = chunked.read()
+        self.assertEqual(b"", result)
+
+    def test_read_with_empty_chunks_mixed(self) -> None:
+        """Test reading with some empty chunks in the list."""
+        chunks = [b"hello", b"", b"world", b""]
+        chunked = ChunkedBytesIO(chunks)
+
+        result = chunked.read()
+        self.assertEqual(b"helloworld", result)
+
+    def test_read_exact_chunk_boundary(self) -> None:
+        """Test reading exactly to a chunk boundary."""
+        chunks = [b"abc", b"def", b"ghi"]
+        chunked = ChunkedBytesIO(chunks)
+
+        # Read exactly first chunk
+        result = chunked.read(3)
+        self.assertEqual(b"abc", result)
+
+        # Read exactly second chunk
+        result = chunked.read(3)
+        self.assertEqual(b"def", result)
+
+        # Read exactly third chunk
+        result = chunked.read(3)
+        self.assertEqual(b"ghi", result)
+
+        # Should be at end
+        result = chunked.read()
+        self.assertEqual(b"", result)

+ 31 - 0
tests/test_credentials.py

@@ -53,6 +53,18 @@ class TestCredentialHelpersUtils(TestCase):
         self.assertFalse(match_partial_url(url, "github.com/jel"))
         self.assertFalse(match_partial_url(url, "github.com/jel/"))
 
+    def test_match_partial_url_with_scheme(self) -> None:
+        """Test match_partial_url with a URL that includes a scheme."""
+        url = urlparse("https://github.com/jelmer/dulwich/")
+
+        # Match with same scheme
+        self.assertTrue(match_partial_url(url, "https://github.com"))
+        self.assertTrue(match_partial_url(url, "https://github.com/jelmer/dulwich"))
+
+        # No match with different scheme
+        self.assertFalse(match_partial_url(url, "http://github.com"))
+        self.assertFalse(match_partial_url(url, "ssh://github.com"))
+
     def test_urlmatch_credential_sections(self) -> None:
         config = ConfigDict()
         config.set((b"credential", "https://github.com"), b"helper", "foo")
@@ -79,3 +91,22 @@ class TestCredentialHelpersUtils(TestCase):
             list(urlmatch_credential_sections(config, "missing_url")),
             [(b"credential",)],
         )
+
+    def test_urlmatch_credential_sections_with_other_sections(self) -> None:
+        """Test that non-credential sections are skipped."""
+        config = ConfigDict()
+        config.set((b"credential", "https://github.com"), b"helper", "foo")
+        config.set(b"credential", b"helper", "bar")
+        # Add some non-credential sections
+        config.set(b"user", b"name", "Test User")
+        config.set(b"core", b"editor", "vim")
+
+        # Should only return credential sections
+        result = list(urlmatch_credential_sections(config, "https://github.com"))
+        self.assertEqual(
+            result,
+            [
+                (b"credential", b"https://github.com"),
+                (b"credential",),
+            ],
+        )

+ 109 - 0
tests/test_log_utils.py

@@ -246,3 +246,112 @@ class LogUtilsTests(TestCase):
 
         # Check that the level is DEBUG when tracing is enabled
         self.assertEqual(root_logger.level, logging.DEBUG)
+
+    def test_configure_logging_from_trace_file_path(self) -> None:
+        """Test _configure_logging_from_trace with file path."""
+        from dulwich.log_utils import _configure_logging_from_trace
+
+        # Save current root logger state
+        root_logger = logging.getLogger()
+        original_level = root_logger.level
+        original_handlers = list(root_logger.handlers)
+
+        def cleanup() -> None:
+            root_logger.handlers = original_handlers
+            root_logger.level = original_level
+
+        self.addCleanup(cleanup)
+
+        # Reset root logger
+        root_logger.handlers = []
+        root_logger.level = logging.WARNING
+
+        # Create a temporary file for trace output
+        with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+            trace_file = f.name
+
+        try:
+            self._set_git_trace(trace_file)
+            result = _configure_logging_from_trace()
+
+            # Should succeed
+            self.assertTrue(result)
+
+            # Check that the root logger has a handler
+            self.assertTrue(root_logger.handlers)
+            self.assertEqual(root_logger.level, logging.DEBUG)
+        finally:
+            # Close all handlers to release file locks (needed on Windows)
+            for handler in root_logger.handlers[:]:
+                handler.close()
+                root_logger.removeHandler(handler)
+            if os.path.exists(trace_file):
+                os.unlink(trace_file)
+
+    def test_configure_logging_from_trace_directory(self) -> None:
+        """Test _configure_logging_from_trace with directory path."""
+        from dulwich.log_utils import _configure_logging_from_trace
+
+        # Save current root logger state
+        root_logger = logging.getLogger()
+        original_level = root_logger.level
+        original_handlers = list(root_logger.handlers)
+
+        def cleanup() -> None:
+            root_logger.handlers = original_handlers
+            root_logger.level = original_level
+
+        self.addCleanup(cleanup)
+
+        # Reset root logger
+        root_logger.handlers = []
+        root_logger.level = logging.WARNING
+
+        # Create a temporary directory for trace output
+        with tempfile.TemporaryDirectory() as tmpdir:
+            self._set_git_trace(tmpdir)
+            result = _configure_logging_from_trace()
+
+            # Should succeed
+            self.assertTrue(result)
+
+            # Check that the root logger has a handler
+            self.assertTrue(root_logger.handlers)
+            self.assertEqual(root_logger.level, logging.DEBUG)
+
+            # Check that a file was created in the directory
+            trace_file = os.path.join(tmpdir, f"trace.{os.getpid()}")
+            # The file should exist after some logging
+            root_logger.debug("Test message")
+            self.assertTrue(os.path.exists(trace_file))
+
+            # Close all handlers to release file locks (needed on Windows)
+            for handler in root_logger.handlers[:]:
+                handler.close()
+                root_logger.removeHandler(handler)
+
+    def test_configure_logging_from_trace_invalid_file(self) -> None:
+        """Test _configure_logging_from_trace with invalid file path."""
+        from dulwich.log_utils import _configure_logging_from_trace
+
+        # Save current root logger state
+        root_logger = logging.getLogger()
+        original_level = root_logger.level
+        original_handlers = list(root_logger.handlers)
+
+        def cleanup() -> None:
+            root_logger.handlers = original_handlers
+            root_logger.level = original_level
+
+        self.addCleanup(cleanup)
+
+        # Reset root logger
+        root_logger.handlers = []
+        root_logger.level = logging.WARNING
+
+        # Use an invalid path (directory that doesn't exist)
+        self._set_git_trace("/nonexistent/path/trace.log")
+        result = _configure_logging_from_trace()
+
+        # Should fail
+        self.assertFalse(result)

+ 76 - 0
tests/test_mailmap.py

@@ -166,3 +166,79 @@ class MailmapTests(TestCase):
         self.assertEqual(
             b"Other <real@example.com>", m.lookup(b"Other <other@example.com>")
         )
+
+    def test_init_with_iterator(self) -> None:
+        """Test initializing Mailmap with an iterator of entries."""
+        entries = [
+            ((b"Real Name", b"real@example.com"), (b"Alias", b"alias@example.com")),
+            ((b"Another", b"another@example.com"), None),
+        ]
+
+        m = Mailmap(iter(entries))
+
+        # Verify the entries were added
+        self.assertEqual(
+            b"Real Name <real@example.com>", m.lookup(b"Alias <alias@example.com>")
+        )
+        self.assertEqual(
+            b"Another <another@example.com>",
+            m.lookup(b"Another <another@example.com>"),
+        )
+
+    def test_lookup_with_none_name(self) -> None:
+        """Test lookup when canonical name is None (only email is canonical)."""
+        m = Mailmap()
+        # Add entry with canonical email but no canonical name
+        m.add_entry((None, b"real@example.com"), (b"Alias", b"alias@example.com"))
+
+        # When canonical name is None, use original name
+        result = m.lookup(b"Alias <alias@example.com>")
+        self.assertEqual(b"Alias <real@example.com>", result)
+
+        # Test formatting when name becomes empty
+        m2 = Mailmap()
+        m2.add_entry((None, b"real@example.com"), (None, b"alias@example.com"))
+        result2 = m2.lookup(b" <alias@example.com>")
+        # When both names are None/empty, result has empty name
+        self.assertEqual(b" <real@example.com>", result2)
+
+    def test_lookup_with_none_email(self) -> None:
+        """Test lookup when canonical email is None (only name is canonical)."""
+        m = Mailmap()
+        # Add entry with canonical name but no canonical email
+        m.add_entry((b"Real Name", None), (b"Alias", b"alias@example.com"))
+
+        # When canonical email is None, use original email
+        result = m.lookup(b"Alias <alias@example.com>")
+        self.assertEqual(b"Real Name <alias@example.com>", result)
+
+        # Test formatting when email becomes empty
+        m2 = Mailmap()
+        m2.add_entry((b"Real Name", None), (b"Alias", None))
+        result2 = m2.lookup(b"Alias <>")
+        # When both emails are None/empty, result has empty email
+        self.assertEqual(b"Real Name <>", result2)
+
+    def test_from_path(self) -> None:
+        """Test creating Mailmap from a file path."""
+        import os
+        import tempfile
+
+        # Create a temporary mailmap file
+        with tempfile.NamedTemporaryFile(
+            mode="wb", delete=False, suffix=".mailmap"
+        ) as f:
+            f.write(b"Real Name <real@example.com> <alias@example.com>\n")
+            f.write(b"Another Person <another@example.com>\n")
+            mailmap_path = f.name
+
+        try:
+            # Load from path
+            m = Mailmap.from_path(mailmap_path)
+
+            # Verify entries were loaded
+            self.assertEqual(
+                b"Real Name <real@example.com>", m.lookup(b"Alias <alias@example.com>")
+            )
+        finally:
+            os.unlink(mailmap_path)

+ 207 - 0
tests/test_object_format.py

@@ -0,0 +1,207 @@
+# test_object_format.py -- tests for object_format.py
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# 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
+# <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.
+#
+
+"""Tests for object_format module."""
+
+from dulwich.object_format import (
+    DEFAULT_OBJECT_FORMAT,
+    OBJECT_FORMAT_TYPE_NUMS,
+    OBJECT_FORMATS,
+    SHA1,
+    SHA256,
+    get_object_format,
+    verify_same_object_format,
+)
+
+from . import TestCase
+
+
+class ObjectFormatTests(TestCase):
+    """Tests for ObjectFormat class."""
+
+    def test_sha1_attributes(self) -> None:
+        """Test SHA1 object format attributes."""
+        self.assertEqual("sha1", SHA1.name)
+        self.assertEqual(1, SHA1.type_num)
+        self.assertEqual(20, SHA1.oid_length)
+        self.assertEqual(40, SHA1.hex_length)
+
+    def test_sha256_attributes(self) -> None:
+        """Test SHA256 object format attributes."""
+        self.assertEqual("sha256", SHA256.name)
+        self.assertEqual(20, SHA256.type_num)
+        self.assertEqual(32, SHA256.oid_length)
+        self.assertEqual(64, SHA256.hex_length)
+
+    def test_str_representation(self) -> None:
+        """Test __str__ method returns format name."""
+        self.assertEqual("sha1", str(SHA1))
+        self.assertEqual("sha256", str(SHA256))
+
+    def test_repr_representation(self) -> None:
+        """Test __repr__ method."""
+        self.assertEqual("ObjectFormat('sha1')", repr(SHA1))
+        self.assertEqual("ObjectFormat('sha256')", repr(SHA256))
+
+    def test_new_hash(self) -> None:
+        """Test new_hash creates a hash object."""
+        sha1_hash = SHA1.new_hash()
+        self.assertEqual("sha1", sha1_hash.name)
+
+        sha256_hash = SHA256.new_hash()
+        self.assertEqual("sha256", sha256_hash.name)
+
+    def test_hash_object(self) -> None:
+        """Test hash_object returns binary digest."""
+        data = b"test data"
+
+        # Test SHA1
+        sha1_digest = SHA1.hash_object(data)
+        self.assertIsInstance(sha1_digest, bytes)
+        self.assertEqual(20, len(sha1_digest))
+
+        # Verify it matches expected hash
+        import hashlib
+
+        expected_sha1 = hashlib.sha1(data).digest()
+        self.assertEqual(expected_sha1, sha1_digest)
+
+        # Test SHA256
+        sha256_digest = SHA256.hash_object(data)
+        self.assertIsInstance(sha256_digest, bytes)
+        self.assertEqual(32, len(sha256_digest))
+
+        expected_sha256 = hashlib.sha256(data).digest()
+        self.assertEqual(expected_sha256, sha256_digest)
+
+    def test_hash_object_hex(self) -> None:
+        """Test hash_object_hex returns hexadecimal digest."""
+        data = b"test data"
+
+        # Test SHA1
+        sha1_hex = SHA1.hash_object_hex(data)
+        self.assertIsInstance(sha1_hex, bytes)
+        self.assertEqual(40, len(sha1_hex))
+
+        # Verify it matches expected hash
+        import hashlib
+
+        expected_sha1_hex = hashlib.sha1(data).hexdigest().encode("ascii")
+        self.assertEqual(expected_sha1_hex, sha1_hex)
+
+        # Test SHA256
+        sha256_hex = SHA256.hash_object_hex(data)
+        self.assertIsInstance(sha256_hex, bytes)
+        self.assertEqual(64, len(sha256_hex))
+
+        expected_sha256_hex = hashlib.sha256(data).hexdigest().encode("ascii")
+        self.assertEqual(expected_sha256_hex, sha256_hex)
+
+
+class ObjectFormatMappingTests(TestCase):
+    """Tests for object format mappings."""
+
+    def test_object_formats_dict(self) -> None:
+        """Test OBJECT_FORMATS dictionary."""
+        self.assertEqual(SHA1, OBJECT_FORMATS["sha1"])
+        self.assertEqual(SHA256, OBJECT_FORMATS["sha256"])
+
+    def test_object_format_type_nums_dict(self) -> None:
+        """Test OBJECT_FORMAT_TYPE_NUMS dictionary."""
+        self.assertEqual(SHA1, OBJECT_FORMAT_TYPE_NUMS[1])
+        self.assertEqual(SHA256, OBJECT_FORMAT_TYPE_NUMS[2])
+
+    def test_default_object_format(self) -> None:
+        """Test DEFAULT_OBJECT_FORMAT is SHA1."""
+        self.assertEqual(SHA1, DEFAULT_OBJECT_FORMAT)
+
+
+class GetObjectFormatTests(TestCase):
+    """Tests for get_object_format function."""
+
+    def test_get_object_format_none(self) -> None:
+        """Test get_object_format with None returns default."""
+        result = get_object_format(None)
+        self.assertEqual(DEFAULT_OBJECT_FORMAT, result)
+
+    def test_get_object_format_sha1(self) -> None:
+        """Test get_object_format with 'sha1'."""
+        result = get_object_format("sha1")
+        self.assertEqual(SHA1, result)
+
+    def test_get_object_format_sha256(self) -> None:
+        """Test get_object_format with 'sha256'."""
+        result = get_object_format("sha256")
+        self.assertEqual(SHA256, result)
+
+    def test_get_object_format_case_insensitive(self) -> None:
+        """Test get_object_format is case insensitive."""
+        self.assertEqual(SHA1, get_object_format("SHA1"))
+        self.assertEqual(SHA1, get_object_format("Sha1"))
+        self.assertEqual(SHA256, get_object_format("SHA256"))
+        self.assertEqual(SHA256, get_object_format("Sha256"))
+
+    def test_get_object_format_invalid(self) -> None:
+        """Test get_object_format with invalid name raises ValueError."""
+        with self.assertRaises(ValueError) as cm:
+            get_object_format("md5")
+
+        self.assertEqual("Unsupported object format: md5", str(cm.exception))
+
+
+class VerifySameObjectFormatTests(TestCase):
+    """Tests for verify_same_object_format function."""
+
+    def test_verify_single_format(self) -> None:
+        """Test verify_same_object_format with single format."""
+        result = verify_same_object_format(SHA1)
+        self.assertEqual(SHA1, result)
+
+    def test_verify_multiple_same_formats(self) -> None:
+        """Test verify_same_object_format with multiple same formats."""
+        result = verify_same_object_format(SHA1, SHA1, SHA1)
+        self.assertEqual(SHA1, result)
+
+        result = verify_same_object_format(SHA256, SHA256)
+        self.assertEqual(SHA256, result)
+
+    def test_verify_no_formats(self) -> None:
+        """Test verify_same_object_format with no formats raises ValueError."""
+        with self.assertRaises(ValueError) as cm:
+            verify_same_object_format()
+
+        self.assertEqual(
+            "At least one object format must be provided", str(cm.exception)
+        )
+
+    def test_verify_different_formats(self) -> None:
+        """Test verify_same_object_format with different formats raises ValueError."""
+        with self.assertRaises(ValueError) as cm:
+            verify_same_object_format(SHA1, SHA256)
+
+        self.assertEqual("Object format mismatch: sha1 != sha256", str(cm.exception))
+
+    def test_verify_multiple_different_formats(self) -> None:
+        """Test verify_same_object_format fails on first mismatch."""
+        with self.assertRaises(ValueError) as cm:
+            verify_same_object_format(SHA1, SHA1, SHA256, SHA1)
+
+        self.assertEqual("Object format mismatch: sha1 != sha256", str(cm.exception))