Browse Source

Merge pull request #1277 from jelmer/move-tests

Move tests to root. Fixes #1024
Jelmer Vernooij 11 months ago
parent
commit
28f6ff4ac4
64 changed files with 1041 additions and 951 deletions
  1. 1 1
      .github/workflows/python-distributions.yml
  2. 1 1
      .github/workflows/pythontest.yml
  3. 1 1
      .stestr.conf
  4. 1 1
      .testr.conf
  5. 6 6
      Makefile
  6. 2 0
      NEWS
  7. 2 2
      dulwich/contrib/README.swift.rst
  8. 0 12
      dulwich/contrib/__init__.py
  9. 1 215
      dulwich/tests/__init__.py
  10. 6 501
      dulwich/tests/test_object_store.py
  11. 5 6
      dulwich/tests/utils.py
  12. 0 2
      pyproject.toml
  13. 235 0
      tests/__init__.py
  14. 1 1
      tests/compat/__init__.py
  15. 6 5
      tests/compat/server_utils.py
  16. 1 1
      tests/compat/test_client.py
  17. 3 3
      tests/compat/test_pack.py
  18. 1 1
      tests/compat/test_patch.py
  19. 1 1
      tests/compat/test_porcelain.py
  20. 3 2
      tests/compat/test_repository.py
  21. 2 2
      tests/compat/test_server.py
  22. 2 2
      tests/compat/test_utils.py
  23. 4 4
      tests/compat/test_web.py
  24. 4 4
      tests/compat/utils.py
  25. 31 0
      tests/contrib/__init__.py
  26. 2 2
      tests/contrib/test_paramiko_vendor.py
  27. 2 3
      tests/contrib/test_release_robot.py
  28. 3 3
      tests/contrib/test_swift.py
  29. 0 0
      tests/contrib/test_swift_smoke.py
  30. 5 5
      tests/test_archive.py
  31. 2 2
      tests/test_blackbox.py
  32. 3 3
      tests/test_bundle.py
  33. 9 9
      tests/test_client.py
  34. 5 5
      tests/test_config.py
  35. 7 3
      tests/test_credentials.py
  36. 7 7
      tests/test_diff_tree.py
  37. 7 7
      tests/test_fastexport.py
  38. 2 2
      tests/test_file.py
  39. 4 4
      tests/test_grafts.py
  40. 4 4
      tests/test_graph.py
  41. 3 3
      tests/test_greenthreads.py
  42. 2 2
      tests/test_hooks.py
  43. 4 4
      tests/test_ignore.py
  44. 7 7
      tests/test_index.py
  45. 2 1
      tests/test_lfs.py
  46. 4 4
      tests/test_line_ending.py
  47. 2 1
      tests/test_lru_cache.py
  48. 1 1
      tests/test_mailmap.py
  49. 4 4
      tests/test_missing_obj_finder.py
  50. 533 0
      tests/test_object_store.py
  51. 12 7
      tests/test_objects.py
  52. 6 6
      tests/test_objectspec.py
  53. 9 9
      tests/test_pack.py
  54. 5 5
      tests/test_patch.py
  55. 10 10
      tests/test_porcelain.py
  56. 4 4
      tests/test_protocol.py
  57. 4 4
      tests/test_reflog.py
  58. 7 7
      tests/test_refs.py
  59. 8 8
      tests/test_repository.py
  60. 9 9
      tests/test_server.py
  61. 3 2
      tests/test_stash.py
  62. 4 4
      tests/test_utils.py
  63. 8 8
      tests/test_walk.py
  64. 8 8
      tests/test_web.py

+ 1 - 1
.github/workflows/python-distributions.yml

@@ -30,7 +30,7 @@ jobs:
         run: pip install -U gpg
         if: "matrix.os != 'windows-latest'"
       - name: Run test suite
-        run: python -m unittest dulwich.tests.test_suite
+        run: python -m unittest tests.test_suite
       - name: Set up QEMU
         uses: docker/setup-qemu-action@v3
         if: "matrix.os == 'ubuntu-latest'"

+ 1 - 1
.github/workflows/pythontest.yml

@@ -54,4 +54,4 @@ jobs:
       - name: Coverage test suite run
         run: |
           pip install --upgrade coverage
-          python -m coverage run -p -m unittest dulwich.tests.test_suite
+          python -m coverage run -p -m unittest tests.test_suite

+ 1 - 1
.stestr.conf

@@ -1,2 +1,2 @@
 [DEFAULT]
-test_path=dulwich/tests
+test_path=tests

+ 1 - 1
.testr.conf

@@ -1,4 +1,4 @@
 [DEFAULT]
-test_command=PYTHONPATH=. python -m subunit.run $IDOPTION $LISTOPT dulwich.tests.test_suite
+test_command=PYTHONPATH=. python3 -m subunit.run $IDOPTION $LISTOPT tests.test_suite
 test_id_option=--load-list $IDFILE
 test_list_option=--list

+ 6 - 6
Makefile

@@ -22,22 +22,22 @@ install::
 	$(SETUP) install --root="$(DESTDIR)"
 
 check:: build
-	$(RUNTEST) dulwich.tests.test_suite
+	$(RUNTEST) tests.test_suite
 
 check-tutorial:: build
-	$(RUNTEST) dulwich.tests.tutorial_test_suite
+	$(RUNTEST) tests.tutorial_test_suite
 
 check-nocompat:: build
-	$(RUNTEST) dulwich.tests.nocompat_test_suite
+	$(RUNTEST) tests.nocompat_test_suite
 
 check-compat:: build
-	$(RUNTEST) dulwich.tests.compat_test_suite
+	$(RUNTEST) tests.compat_test_suite
 
 check-pypy:: clean
 	$(MAKE) check-noextensions PYTHON=pypy
 
 check-noextensions:: clean
-	$(RUNTEST) dulwich.tests.test_suite
+	$(RUNTEST) tests.test_suite
 
 check-contrib:: clean
 	$(RUNTEST) -v dulwich.contrib.test_suite
@@ -55,7 +55,7 @@ style:
 	$(RUFF) check .
 
 coverage:
-	$(COVERAGE) run -m unittest dulwich.tests.test_suite dulwich.contrib.test_suite
+	$(COVERAGE) run -m unittest tests.test_suite dulwich.contrib.test_suite
 
 coverage-html: coverage
 	$(COVERAGE) html

+ 2 - 0
NEWS

@@ -1,5 +1,7 @@
 0.21.8	UNRELEASED
 
+ * Move tests to root. (Jelmer Vernooij, #1024)
+
  * Convert the optional C implementations to Rust.
    (Jelmer Vernooij)
 

+ 2 - 2
dulwich/contrib/README.swift.rst

@@ -56,7 +56,7 @@ How to start unittest
 There is no need to have a Swift cluster running to run the unitests.
 Just run the following command in the Dulwich source directory::
 
-    $ PYTHONPATH=. python -m dulwich.contrib.test_swift
+    $ PYTHONPATH=. python -m tests.contrib.test_swift
 
 How to start functional tests
 -----------------------------
@@ -65,7 +65,7 @@ We provide some basic tests to perform smoke tests against a real Swift
 cluster. To run those functional tests you need a properly configured
 configuration file. The tests can be run as follow::
 
-    $ DULWICH_SWIFT_CFG=/etc/swift-dul.conf PYTHONPATH=. python -m dulwich.contrib.test_swift_smoke
+    $ DULWICH_SWIFT_CFG=/etc/swift-dul.conf PYTHONPATH=. python -m tests.contrib.test_swift_smoke
 
 How to install
 --------------

+ 0 - 12
dulwich/contrib/__init__.py

@@ -18,15 +18,3 @@
 # License, Version 2.0.
 #
 
-
-def test_suite():
-    import unittest
-
-    names = [
-        "paramiko_vendor",
-        "release_robot",
-        "swift",
-    ]
-    module_names = ["dulwich.contrib.test_" + name for name in names]
-    loader = unittest.TestLoader()
-    return loader.loadTestsFromNames(module_names)

+ 1 - 215
dulwich/tests/__init__.py

@@ -1,5 +1,5 @@
 # __init__.py -- The tests for dulwich
-# Copyright (C) 2007 James Westby <jw+debian@jameswestby.net>
+# Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
 #
 # 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
@@ -19,217 +19,3 @@
 #
 
 """Tests for Dulwich."""
-
-__all__ = [
-    "SkipTest",
-    "TestCase",
-    "BlackboxTestCase",
-    "skipIf",
-    "expectedFailure",
-]
-
-import doctest
-import os
-import shutil
-import subprocess
-import sys
-import tempfile
-
-# If Python itself provides an exception, use that
-import unittest
-from typing import ClassVar, List
-from unittest import SkipTest, expectedFailure, skipIf
-from unittest import TestCase as _TestCase
-
-
-class TestCase(_TestCase):
-    def setUp(self):
-        super().setUp()
-        self.overrideEnv("HOME", "/nonexistent")
-        self.overrideEnv("GIT_CONFIG_NOSYSTEM", "1")
-
-    def overrideEnv(self, name, value):
-        def restore():
-            if oldval is not None:
-                os.environ[name] = oldval
-            else:
-                del os.environ[name]
-
-        oldval = os.environ.get(name)
-        if value is not None:
-            os.environ[name] = value
-        else:
-            del os.environ[name]
-        self.addCleanup(restore)
-
-
-class BlackboxTestCase(TestCase):
-    """Blackbox testing."""
-
-    # TODO(jelmer): Include more possible binary paths.
-    bin_directories: ClassVar[List[str]] = [
-        os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "bin")),
-        "/usr/bin",
-        "/usr/local/bin",
-    ]
-
-    def bin_path(self, name):
-        """Determine the full path of a binary.
-
-        Args:
-          name: Name of the script
-        Returns: Full path
-        """
-        for d in self.bin_directories:
-            p = os.path.join(d, name)
-            if os.path.isfile(p):
-                return p
-        else:
-            raise SkipTest("Unable to find binary %s" % name)
-
-    def run_command(self, name, args):
-        """Run a Dulwich command.
-
-        Args:
-          name: Name of the command, as it exists in bin/
-          args: Arguments to the command
-        """
-        env = dict(os.environ)
-        env["PYTHONPATH"] = os.pathsep.join(sys.path)
-
-        # Since they don't have any extensions, Windows can't recognize
-        # executablility of the Python files in /bin. Even then, we'd have to
-        # expect the user to set up file associations for .py files.
-        #
-        # Save us from all that headache and call python with the bin script.
-        argv = [sys.executable, self.bin_path(name), *args]
-        return subprocess.Popen(
-            argv,
-            stdout=subprocess.PIPE,
-            stdin=subprocess.PIPE,
-            stderr=subprocess.PIPE,
-            env=env,
-        )
-
-
-def self_test_suite():
-    names = [
-        "archive",
-        "blackbox",
-        "bundle",
-        "client",
-        "config",
-        "credentials",
-        "diff_tree",
-        "fastexport",
-        "file",
-        "grafts",
-        "graph",
-        "greenthreads",
-        "hooks",
-        "ignore",
-        "index",
-        "lfs",
-        "line_ending",
-        "lru_cache",
-        "mailmap",
-        "objects",
-        "objectspec",
-        "object_store",
-        "missing_obj_finder",
-        "pack",
-        "patch",
-        "porcelain",
-        "protocol",
-        "reflog",
-        "refs",
-        "repository",
-        "server",
-        "stash",
-        "utils",
-        "walk",
-        "web",
-    ]
-    module_names = ["dulwich.tests.test_" + name for name in names]
-    loader = unittest.TestLoader()
-    return loader.loadTestsFromNames(module_names)
-
-
-def tutorial_test_suite():
-    tutorial = [
-        "introduction",
-        "file-format",
-        "repo",
-        "object-store",
-        "remote",
-        "conclusion",
-    ]
-    tutorial_files = [f"../../docs/tutorial/{name}.txt" for name in tutorial]
-
-    to_restore = []
-
-    def overrideEnv(name, value):
-        oldval = os.environ.get(name)
-        if value is not None:
-            os.environ[name] = value
-        else:
-            del os.environ[name]
-        to_restore.append((name, oldval))
-
-    def setup(test):
-        test.__old_cwd = os.getcwd()
-        test.tempdir = tempfile.mkdtemp()
-        test.globs.update({"tempdir": test.tempdir})
-        os.chdir(test.tempdir)
-        overrideEnv("HOME", "/nonexistent")
-        overrideEnv("GIT_CONFIG_NOSYSTEM", "1")
-
-    def teardown(test):
-        os.chdir(test.__old_cwd)
-        shutil.rmtree(test.tempdir)
-        for name, oldval in to_restore:
-            if oldval is not None:
-                os.environ[name] = oldval
-            else:
-                del os.environ[name]
-        to_restore.clear()
-
-    return doctest.DocFileSuite(
-        module_relative=True,
-        package="dulwich.tests",
-        setUp=setup,
-        tearDown=teardown,
-        *tutorial_files,
-    )
-
-
-def nocompat_test_suite():
-    result = unittest.TestSuite()
-    result.addTests(self_test_suite())
-    result.addTests(tutorial_test_suite())
-    from dulwich.contrib import test_suite as contrib_test_suite
-
-    result.addTests(contrib_test_suite())
-    return result
-
-
-def compat_test_suite():
-    result = unittest.TestSuite()
-    from dulwich.tests.compat import test_suite as compat_test_suite
-
-    result.addTests(compat_test_suite())
-    return result
-
-
-def test_suite():
-    result = unittest.TestSuite()
-    result.addTests(self_test_suite())
-    if sys.platform != "win32":
-        result.addTests(tutorial_test_suite())
-    from dulwich.tests.compat import test_suite as compat_test_suite
-
-    result.addTests(compat_test_suite())
-    from dulwich.contrib import test_suite as contrib_test_suite
-
-    result.addTests(contrib_test_suite())
-    return result

+ 6 - 501
dulwich/tests/test_object_store.py

@@ -20,42 +20,20 @@
 
 """Tests for the object store interface."""
 
-import os
-import shutil
-import stat
-import sys
-import tempfile
-from contextlib import closing
-from io import BytesIO
 from unittest import skipUnless
 
-from dulwich.tests import TestCase
-
-from ..errors import NotTreeError
-from ..index import commit_tree
-from ..object_store import (
-    DiskObjectStore,
-    MemoryObjectStore,
-    ObjectStoreGraphWalker,
-    OverlayObjectStore,
-    commit_tree_changes,
+from dulwich.index import commit_tree
+from dulwich.object_store import (
     iter_tree_contents,
     peel_sha,
-    read_packs_file,
-    tree_lookup_path,
 )
-from ..objects import (
-    S_IFGITLINK,
+from dulwich.objects import (
     Blob,
-    EmptyFileException,
-    SubmoduleEncountered,
-    Tree,
     TreeEntry,
-    sha_to_hex,
 )
-from ..pack import REF_DELTA, write_pack_objects
-from ..protocol import DEPTH_INFINITE
-from .utils import build_pack, make_object, make_tag
+from dulwich.protocol import DEPTH_INFINITE
+
+from .utils import make_object, make_tag
 
 try:
     from unittest.mock import patch
@@ -259,63 +237,6 @@ class ObjectStoreTests:
         self.store.close()
 
 
-class OverlayObjectStoreTests(ObjectStoreTests, TestCase):
-    def setUp(self):
-        TestCase.setUp(self)
-        self.bases = [MemoryObjectStore(), MemoryObjectStore()]
-        self.store = OverlayObjectStore(self.bases, self.bases[0])
-
-
-class MemoryObjectStoreTests(ObjectStoreTests, TestCase):
-    def setUp(self):
-        TestCase.setUp(self)
-        self.store = MemoryObjectStore()
-
-    def test_add_pack(self):
-        o = MemoryObjectStore()
-        f, commit, abort = o.add_pack()
-        try:
-            b = make_object(Blob, data=b"more yummy data")
-            write_pack_objects(f.write, [(b, None)])
-        except BaseException:
-            abort()
-            raise
-        else:
-            commit()
-
-    def test_add_pack_emtpy(self):
-        o = MemoryObjectStore()
-        f, commit, abort = o.add_pack()
-        commit()
-
-    def test_add_thin_pack(self):
-        o = MemoryObjectStore()
-        blob = make_object(Blob, data=b"yummy data")
-        o.add_object(blob)
-
-        f = BytesIO()
-        entries = build_pack(
-            f,
-            [
-                (REF_DELTA, (blob.id, b"more yummy data")),
-            ],
-            store=o,
-        )
-        o.add_thin_pack(f.read, None)
-        packed_blob_sha = sha_to_hex(entries[0][3])
-        self.assertEqual(
-            (Blob.type_num, b"more yummy data"), o.get_raw(packed_blob_sha)
-        )
-
-    def test_add_thin_pack_empty(self):
-        o = MemoryObjectStore()
-
-        f = BytesIO()
-        entries = build_pack(f, [], store=o)
-        self.assertEqual([], entries)
-        o.add_thin_pack(f.read, None)
-
-
 class PackBasedObjectStoreTests(ObjectStoreTests):
     def tearDown(self):
         for pack in self.store.packs:
@@ -375,420 +296,4 @@ class PackBasedObjectStoreTests(ObjectStoreTests):
         self.assertEqual(0, self.store.pack_loose_objects())
 
 
-class DiskObjectStoreTests(PackBasedObjectStoreTests, TestCase):
-    def setUp(self):
-        TestCase.setUp(self)
-        self.store_dir = tempfile.mkdtemp()
-        self.addCleanup(shutil.rmtree, self.store_dir)
-        self.store = DiskObjectStore.init(self.store_dir)
 
-    def tearDown(self):
-        TestCase.tearDown(self)
-        PackBasedObjectStoreTests.tearDown(self)
-
-    def test_loose_compression_level(self):
-        alternate_dir = tempfile.mkdtemp()
-        self.addCleanup(shutil.rmtree, alternate_dir)
-        alternate_store = DiskObjectStore(alternate_dir, loose_compression_level=6)
-        b2 = make_object(Blob, data=b"yummy data")
-        alternate_store.add_object(b2)
-
-    def test_alternates(self):
-        alternate_dir = tempfile.mkdtemp()
-        self.addCleanup(shutil.rmtree, alternate_dir)
-        alternate_store = DiskObjectStore(alternate_dir)
-        b2 = make_object(Blob, data=b"yummy data")
-        alternate_store.add_object(b2)
-        store = DiskObjectStore(self.store_dir)
-        self.assertRaises(KeyError, store.__getitem__, b2.id)
-        store.add_alternate_path(alternate_dir)
-        self.assertIn(b2.id, store)
-        self.assertEqual(b2, store[b2.id])
-
-    def test_read_alternate_paths(self):
-        store = DiskObjectStore(self.store_dir)
-
-        abs_path = os.path.abspath(os.path.normpath("/abspath"))
-        # ensures in particular existence of the alternates file
-        store.add_alternate_path(abs_path)
-        self.assertEqual(set(store._read_alternate_paths()), {abs_path})
-
-        store.add_alternate_path("relative-path")
-        self.assertIn(
-            os.path.join(store.path, "relative-path"),
-            set(store._read_alternate_paths()),
-        )
-
-        # arguably, add_alternate_path() could strip comments.
-        # Meanwhile it's more convenient to use it than to import INFODIR
-        store.add_alternate_path("# comment")
-        for alt_path in store._read_alternate_paths():
-            self.assertNotIn("#", alt_path)
-
-    def test_file_modes(self):
-        self.store.add_object(testobject)
-        path = self.store._get_shafile_path(testobject.id)
-        mode = os.stat(path).st_mode
-
-        packmode = "0o100444" if sys.platform != "win32" else "0o100666"
-        self.assertEqual(oct(mode), packmode)
-
-    def test_corrupted_object_raise_exception(self):
-        """Corrupted sha1 disk file should raise specific exception."""
-        self.store.add_object(testobject)
-        self.assertEqual(
-            (Blob.type_num, b"yummy data"), self.store.get_raw(testobject.id)
-        )
-        self.assertTrue(self.store.contains_loose(testobject.id))
-        self.assertIsNotNone(self.store._get_loose_object(testobject.id))
-
-        path = self.store._get_shafile_path(testobject.id)
-        old_mode = os.stat(path).st_mode
-        os.chmod(path, 0o600)
-        with open(path, "wb") as f:  # corrupt the file
-            f.write(b"")
-        os.chmod(path, old_mode)
-
-        expected_error_msg = "Corrupted empty file detected"
-        try:
-            self.store.contains_loose(testobject.id)
-        except EmptyFileException as e:
-            self.assertEqual(str(e), expected_error_msg)
-
-        try:
-            self.store._get_loose_object(testobject.id)
-        except EmptyFileException as e:
-            self.assertEqual(str(e), expected_error_msg)
-
-        # this does not change iteration on loose objects though
-        self.assertEqual([testobject.id], list(self.store._iter_loose_objects()))
-
-    def test_tempfile_in_loose_store(self):
-        self.store.add_object(testobject)
-        self.assertEqual([testobject.id], list(self.store._iter_loose_objects()))
-
-        # add temporary files to the loose store
-        for i in range(256):
-            dirname = os.path.join(self.store_dir, "%02x" % i)
-            if not os.path.isdir(dirname):
-                os.makedirs(dirname)
-            fd, n = tempfile.mkstemp(prefix="tmp_obj_", dir=dirname)
-            os.close(fd)
-
-        self.assertEqual([testobject.id], list(self.store._iter_loose_objects()))
-
-    def test_add_alternate_path(self):
-        store = DiskObjectStore(self.store_dir)
-        self.assertEqual([], list(store._read_alternate_paths()))
-        store.add_alternate_path("/foo/path")
-        self.assertEqual(["/foo/path"], list(store._read_alternate_paths()))
-        store.add_alternate_path("/bar/path")
-        self.assertEqual(
-            ["/foo/path", "/bar/path"], list(store._read_alternate_paths())
-        )
-
-    def test_rel_alternative_path(self):
-        alternate_dir = tempfile.mkdtemp()
-        self.addCleanup(shutil.rmtree, alternate_dir)
-        alternate_store = DiskObjectStore(alternate_dir)
-        b2 = make_object(Blob, data=b"yummy data")
-        alternate_store.add_object(b2)
-        store = DiskObjectStore(self.store_dir)
-        self.assertRaises(KeyError, store.__getitem__, b2.id)
-        store.add_alternate_path(os.path.relpath(alternate_dir, self.store_dir))
-        self.assertEqual(list(alternate_store), list(store.alternates[0]))
-        self.assertIn(b2.id, store)
-        self.assertEqual(b2, store[b2.id])
-
-    def test_pack_dir(self):
-        o = DiskObjectStore(self.store_dir)
-        self.assertEqual(os.path.join(self.store_dir, "pack"), o.pack_dir)
-
-    def test_add_pack(self):
-        o = DiskObjectStore(self.store_dir)
-        self.addCleanup(o.close)
-        f, commit, abort = o.add_pack()
-        try:
-            b = make_object(Blob, data=b"more yummy data")
-            write_pack_objects(f.write, [(b, None)])
-        except BaseException:
-            abort()
-            raise
-        else:
-            commit()
-
-    def test_add_thin_pack(self):
-        o = DiskObjectStore(self.store_dir)
-        try:
-            blob = make_object(Blob, data=b"yummy data")
-            o.add_object(blob)
-
-            f = BytesIO()
-            entries = build_pack(
-                f,
-                [
-                    (REF_DELTA, (blob.id, b"more yummy data")),
-                ],
-                store=o,
-            )
-
-            with o.add_thin_pack(f.read, None) as pack:
-                packed_blob_sha = sha_to_hex(entries[0][3])
-                pack.check_length_and_checksum()
-                self.assertEqual(sorted([blob.id, packed_blob_sha]), list(pack))
-                self.assertTrue(o.contains_packed(packed_blob_sha))
-                self.assertTrue(o.contains_packed(blob.id))
-                self.assertEqual(
-                    (Blob.type_num, b"more yummy data"),
-                    o.get_raw(packed_blob_sha),
-                )
-        finally:
-            o.close()
-
-    def test_add_thin_pack_empty(self):
-        with closing(DiskObjectStore(self.store_dir)) as o:
-            f = BytesIO()
-            entries = build_pack(f, [], store=o)
-            self.assertEqual([], entries)
-            o.add_thin_pack(f.read, None)
-
-
-class TreeLookupPathTests(TestCase):
-    def setUp(self):
-        TestCase.setUp(self)
-        self.store = MemoryObjectStore()
-        blob_a = make_object(Blob, data=b"a")
-        blob_b = make_object(Blob, data=b"b")
-        blob_c = make_object(Blob, data=b"c")
-        for blob in [blob_a, blob_b, blob_c]:
-            self.store.add_object(blob)
-
-        blobs = [
-            (b"a", blob_a.id, 0o100644),
-            (b"ad/b", blob_b.id, 0o100644),
-            (b"ad/bd/c", blob_c.id, 0o100755),
-            (b"ad/c", blob_c.id, 0o100644),
-            (b"c", blob_c.id, 0o100644),
-            (b"d", blob_c.id, S_IFGITLINK),
-        ]
-        self.tree_id = commit_tree(self.store, blobs)
-
-    def get_object(self, sha):
-        return self.store[sha]
-
-    def test_lookup_blob(self):
-        o_id = tree_lookup_path(self.get_object, self.tree_id, b"a")[1]
-        self.assertIsInstance(self.store[o_id], Blob)
-
-    def test_lookup_tree(self):
-        o_id = tree_lookup_path(self.get_object, self.tree_id, b"ad")[1]
-        self.assertIsInstance(self.store[o_id], Tree)
-        o_id = tree_lookup_path(self.get_object, self.tree_id, b"ad/bd")[1]
-        self.assertIsInstance(self.store[o_id], Tree)
-        o_id = tree_lookup_path(self.get_object, self.tree_id, b"ad/bd/")[1]
-        self.assertIsInstance(self.store[o_id], Tree)
-
-    def test_lookup_submodule(self):
-        tree_lookup_path(self.get_object, self.tree_id, b"d")[1]
-        self.assertRaises(
-            SubmoduleEncountered,
-            tree_lookup_path,
-            self.get_object,
-            self.tree_id,
-            b"d/a",
-        )
-
-    def test_lookup_nonexistent(self):
-        self.assertRaises(
-            KeyError, tree_lookup_path, self.get_object, self.tree_id, b"j"
-        )
-
-    def test_lookup_not_tree(self):
-        self.assertRaises(
-            NotTreeError,
-            tree_lookup_path,
-            self.get_object,
-            self.tree_id,
-            b"ad/b/j",
-        )
-
-
-class ObjectStoreGraphWalkerTests(TestCase):
-    def get_walker(self, heads, parent_map):
-        new_parent_map = {
-            k * 40: [(p * 40) for p in ps] for (k, ps) in parent_map.items()
-        }
-        return ObjectStoreGraphWalker(
-            [x * 40 for x in heads], new_parent_map.__getitem__
-        )
-
-    def test_ack_invalid_value(self):
-        gw = self.get_walker([], {})
-        self.assertRaises(ValueError, gw.ack, "tooshort")
-
-    def test_empty(self):
-        gw = self.get_walker([], {})
-        self.assertIs(None, next(gw))
-        gw.ack(b"a" * 40)
-        self.assertIs(None, next(gw))
-
-    def test_descends(self):
-        gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []})
-        self.assertEqual(b"a" * 40, next(gw))
-        self.assertEqual(b"b" * 40, next(gw))
-
-    def test_present(self):
-        gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []})
-        gw.ack(b"a" * 40)
-        self.assertIs(None, next(gw))
-
-    def test_parent_present(self):
-        gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []})
-        self.assertEqual(b"a" * 40, next(gw))
-        gw.ack(b"a" * 40)
-        self.assertIs(None, next(gw))
-
-    def test_child_ack_later(self):
-        gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": [b"c"], b"c": []})
-        self.assertEqual(b"a" * 40, next(gw))
-        self.assertEqual(b"b" * 40, next(gw))
-        gw.ack(b"a" * 40)
-        self.assertIs(None, next(gw))
-
-    def test_only_once(self):
-        # a  b
-        # |  |
-        # c  d
-        # \ /
-        #  e
-        gw = self.get_walker(
-            [b"a", b"b"],
-            {
-                b"a": [b"c"],
-                b"b": [b"d"],
-                b"c": [b"e"],
-                b"d": [b"e"],
-                b"e": [],
-            },
-        )
-        walk = []
-        acked = False
-        walk.append(next(gw))
-        walk.append(next(gw))
-        # A branch (a, c) or (b, d) may be done after 2 steps or 3 depending on
-        # the order walked: 3-step walks include (a, b, c) and (b, a, d), etc.
-        if walk == [b"a" * 40, b"c" * 40] or walk == [b"b" * 40, b"d" * 40]:
-            gw.ack(walk[0])
-            acked = True
-
-        walk.append(next(gw))
-        if not acked and walk[2] == b"c" * 40:
-            gw.ack(b"a" * 40)
-        elif not acked and walk[2] == b"d" * 40:
-            gw.ack(b"b" * 40)
-        walk.append(next(gw))
-        self.assertIs(None, next(gw))
-
-        self.assertEqual([b"a" * 40, b"b" * 40, b"c" * 40, b"d" * 40], sorted(walk))
-        self.assertLess(walk.index(b"a" * 40), walk.index(b"c" * 40))
-        self.assertLess(walk.index(b"b" * 40), walk.index(b"d" * 40))
-
-
-class CommitTreeChangesTests(TestCase):
-    def setUp(self):
-        super().setUp()
-        self.store = MemoryObjectStore()
-        self.blob_a = make_object(Blob, data=b"a")
-        self.blob_b = make_object(Blob, data=b"b")
-        self.blob_c = make_object(Blob, data=b"c")
-        for blob in [self.blob_a, self.blob_b, self.blob_c]:
-            self.store.add_object(blob)
-
-        blobs = [
-            (b"a", self.blob_a.id, 0o100644),
-            (b"ad/b", self.blob_b.id, 0o100644),
-            (b"ad/bd/c", self.blob_c.id, 0o100755),
-            (b"ad/c", self.blob_c.id, 0o100644),
-            (b"c", self.blob_c.id, 0o100644),
-        ]
-        self.tree_id = commit_tree(self.store, blobs)
-
-    def test_no_changes(self):
-        self.assertEqual(
-            self.store[self.tree_id],
-            commit_tree_changes(self.store, self.store[self.tree_id], []),
-        )
-
-    def test_add_blob(self):
-        blob_d = make_object(Blob, data=b"d")
-        new_tree = commit_tree_changes(
-            self.store, self.store[self.tree_id], [(b"d", 0o100644, blob_d.id)]
-        )
-        self.assertEqual(
-            new_tree[b"d"],
-            (33188, b"c59d9b6344f1af00e504ba698129f07a34bbed8d"),
-        )
-
-    def test_add_blob_in_dir(self):
-        blob_d = make_object(Blob, data=b"d")
-        new_tree = commit_tree_changes(
-            self.store,
-            self.store[self.tree_id],
-            [(b"e/f/d", 0o100644, blob_d.id)],
-        )
-        self.assertEqual(
-            new_tree.items(),
-            [
-                TreeEntry(path=b"a", mode=stat.S_IFREG | 0o100644, sha=self.blob_a.id),
-                TreeEntry(
-                    path=b"ad",
-                    mode=stat.S_IFDIR,
-                    sha=b"0e2ce2cd7725ff4817791be31ccd6e627e801f4a",
-                ),
-                TreeEntry(path=b"c", mode=stat.S_IFREG | 0o100644, sha=self.blob_c.id),
-                TreeEntry(
-                    path=b"e",
-                    mode=stat.S_IFDIR,
-                    sha=b"6ab344e288724ac2fb38704728b8896e367ed108",
-                ),
-            ],
-        )
-        e_tree = self.store[new_tree[b"e"][1]]
-        self.assertEqual(
-            e_tree.items(),
-            [
-                TreeEntry(
-                    path=b"f",
-                    mode=stat.S_IFDIR,
-                    sha=b"24d2c94d8af232b15a0978c006bf61ef4479a0a5",
-                )
-            ],
-        )
-        f_tree = self.store[e_tree[b"f"][1]]
-        self.assertEqual(
-            f_tree.items(),
-            [TreeEntry(path=b"d", mode=stat.S_IFREG | 0o100644, sha=blob_d.id)],
-        )
-
-    def test_delete_blob(self):
-        new_tree = commit_tree_changes(
-            self.store, self.store[self.tree_id], [(b"ad/bd/c", None, None)]
-        )
-        self.assertEqual(set(new_tree), {b"a", b"ad", b"c"})
-        ad_tree = self.store[new_tree[b"ad"][1]]
-        self.assertEqual(set(ad_tree), {b"b", b"c"})
-
-
-class TestReadPacksFile(TestCase):
-    def test_read_packs(self):
-        self.assertEqual(
-            ["pack-1.pack"],
-            list(
-                read_packs_file(
-                    BytesIO(
-                        b"""P pack-1.pack
-"""
-                    )
-                )
-            ),
-        )

+ 5 - 6
dulwich/tests/utils.py

@@ -27,12 +27,11 @@ import tempfile
 import time
 import types
 import warnings
+from unittest import SkipTest
 
-from dulwich.tests import SkipTest
-
-from ..index import commit_tree
-from ..objects import Commit, FixedSha, Tag, object_class
-from ..pack import (
+from dulwich.index import commit_tree
+from dulwich.objects import Commit, FixedSha, Tag, object_class
+from dulwich.pack import (
     DELTA_TYPES,
     OFS_DELTA,
     REF_DELTA,
@@ -42,7 +41,7 @@ from ..pack import (
     write_pack_header,
     write_pack_object,
 )
-from ..repo import Repo
+from dulwich.repo import Repo
 
 # Plain files are very frequently used in tests, so let the mode be very short.
 F = 0o100644  # Shorthand mode for Files.

+ 0 - 2
pyproject.toml

@@ -54,8 +54,6 @@ ignore_missing_imports = true
 packages = [
     "dulwich",
     "dulwich.cloud",
-    "dulwich.tests",
-    "dulwich.tests.compat",
     "dulwich.contrib",
 ]
 include-package-data = true

+ 235 - 0
tests/__init__.py

@@ -0,0 +1,235 @@
+# __init__.py -- The tests for dulwich
+# Copyright (C) 2007 James Westby <jw+debian@jameswestby.net>
+#
+# 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.
+#
+
+"""Tests for Dulwich."""
+
+__all__ = [
+    "SkipTest",
+    "TestCase",
+    "BlackboxTestCase",
+    "skipIf",
+    "expectedFailure",
+]
+
+import doctest
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+# If Python itself provides an exception, use that
+import unittest
+from typing import ClassVar, List
+from unittest import SkipTest, expectedFailure, skipIf
+from unittest import TestCase as _TestCase
+
+
+class TestCase(_TestCase):
+    def setUp(self):
+        super().setUp()
+        self.overrideEnv("HOME", "/nonexistent")
+        self.overrideEnv("GIT_CONFIG_NOSYSTEM", "1")
+
+    def overrideEnv(self, name, value):
+        def restore():
+            if oldval is not None:
+                os.environ[name] = oldval
+            else:
+                del os.environ[name]
+
+        oldval = os.environ.get(name)
+        if value is not None:
+            os.environ[name] = value
+        else:
+            del os.environ[name]
+        self.addCleanup(restore)
+
+
+class BlackboxTestCase(TestCase):
+    """Blackbox testing."""
+
+    # TODO(jelmer): Include more possible binary paths.
+    bin_directories: ClassVar[List[str]] = [
+        os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "bin")),
+        "/usr/bin",
+        "/usr/local/bin",
+    ]
+
+    def bin_path(self, name):
+        """Determine the full path of a binary.
+
+        Args:
+          name: Name of the script
+        Returns: Full path
+        """
+        for d in self.bin_directories:
+            p = os.path.join(d, name)
+            if os.path.isfile(p):
+                return p
+        else:
+            raise SkipTest("Unable to find binary %s" % name)
+
+    def run_command(self, name, args):
+        """Run a Dulwich command.
+
+        Args:
+          name: Name of the command, as it exists in bin/
+          args: Arguments to the command
+        """
+        env = dict(os.environ)
+        env["PYTHONPATH"] = os.pathsep.join(sys.path)
+
+        # Since they don't have any extensions, Windows can't recognize
+        # executablility of the Python files in /bin. Even then, we'd have to
+        # expect the user to set up file associations for .py files.
+        #
+        # Save us from all that headache and call python with the bin script.
+        argv = [sys.executable, self.bin_path(name), *args]
+        return subprocess.Popen(
+            argv,
+            stdout=subprocess.PIPE,
+            stdin=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            env=env,
+        )
+
+
+def self_test_suite():
+    names = [
+        "archive",
+        "blackbox",
+        "bundle",
+        "client",
+        "config",
+        "credentials",
+        "diff_tree",
+        "fastexport",
+        "file",
+        "grafts",
+        "graph",
+        "greenthreads",
+        "hooks",
+        "ignore",
+        "index",
+        "lfs",
+        "line_ending",
+        "lru_cache",
+        "mailmap",
+        "objects",
+        "objectspec",
+        "object_store",
+        "missing_obj_finder",
+        "pack",
+        "patch",
+        "porcelain",
+        "protocol",
+        "reflog",
+        "refs",
+        "repository",
+        "server",
+        "stash",
+        "utils",
+        "walk",
+        "web",
+    ]
+    module_names = ["tests.test_" + name for name in names]
+    loader = unittest.TestLoader()
+    return loader.loadTestsFromNames(module_names)
+
+
+def tutorial_test_suite():
+    tutorial = [
+        "introduction",
+        "file-format",
+        "repo",
+        "object-store",
+        "remote",
+        "conclusion",
+    ]
+    tutorial_files = [f"../docs/tutorial/{name}.txt" for name in tutorial]
+
+    to_restore = []
+
+    def overrideEnv(name, value):
+        oldval = os.environ.get(name)
+        if value is not None:
+            os.environ[name] = value
+        else:
+            del os.environ[name]
+        to_restore.append((name, oldval))
+
+    def setup(test):
+        test.__old_cwd = os.getcwd()
+        test.tempdir = tempfile.mkdtemp()
+        test.globs.update({"tempdir": test.tempdir})
+        os.chdir(test.tempdir)
+        overrideEnv("HOME", "/nonexistent")
+        overrideEnv("GIT_CONFIG_NOSYSTEM", "1")
+
+    def teardown(test):
+        os.chdir(test.__old_cwd)
+        shutil.rmtree(test.tempdir)
+        for name, oldval in to_restore:
+            if oldval is not None:
+                os.environ[name] = oldval
+            else:
+                del os.environ[name]
+        to_restore.clear()
+
+    return doctest.DocFileSuite(
+        module_relative=True,
+        package="tests",
+        setUp=setup,
+        tearDown=teardown,
+        *tutorial_files,
+    )
+
+
+def nocompat_test_suite():
+    result = unittest.TestSuite()
+    result.addTests(self_test_suite())
+    result.addTests(tutorial_test_suite())
+    from dulwich.contrib import test_suite as contrib_test_suite
+
+    result.addTests(contrib_test_suite())
+    return result
+
+
+def compat_test_suite():
+    result = unittest.TestSuite()
+    from .compat import test_suite as compat_test_suite
+
+    result.addTests(compat_test_suite())
+    return result
+
+
+def test_suite():
+    result = unittest.TestSuite()
+    result.addTests(self_test_suite())
+    if sys.platform != "win32":
+        result.addTests(tutorial_test_suite())
+    from .compat import test_suite as compat_test_suite
+
+    result.addTests(compat_test_suite())
+    from .contrib import test_suite as contrib_test_suite
+
+    result.addTests(contrib_test_suite())
+    return result

+ 1 - 1
dulwich/tests/compat/__init__.py → tests/compat/__init__.py

@@ -34,7 +34,7 @@ def test_suite():
         "utils",
         "web",
     ]
-    module_names = ["dulwich.tests.compat.test_" + name for name in names]
+    module_names = ["tests.compat.test_" + name for name in names]
     result = unittest.TestSuite()
     loader = unittest.TestLoader()
     suite = loader.loadTestsFromNames(module_names)

+ 6 - 5
dulwich/tests/compat/server_utils.py → tests/compat/server_utils.py

@@ -26,11 +26,12 @@ import shutil
 import socket
 import tempfile
 
-from ...objects import hex_to_sha
-from ...protocol import CAPABILITY_SIDE_BAND_64K
-from ...repo import Repo
-from ...server import ReceivePackHandler
-from ..utils import tear_down_repo
+from dulwich.objects import hex_to_sha
+from dulwich.protocol import CAPABILITY_SIDE_BAND_64K
+from dulwich.repo import Repo
+from dulwich.server import ReceivePackHandler
+from dulwich.tests.utils import tear_down_repo
+
 from .utils import require_git_version, run_git_or_fail
 
 

+ 1 - 1
dulwich/tests/compat/test_client.py → tests/compat/test_client.py

@@ -36,8 +36,8 @@ from io import BytesIO
 from urllib.parse import unquote
 
 from dulwich import client, file, index, objects, protocol, repo
-from dulwich.tests import SkipTest, expectedFailure
 
+from .. import SkipTest, expectedFailure
 from .utils import (
     _DEFAULT_GIT,
     CompatTestCase,

+ 3 - 3
dulwich/tests/compat/test_pack.py → tests/compat/test_pack.py

@@ -26,10 +26,10 @@ import re
 import shutil
 import tempfile
 
-from dulwich.tests import SkipTest
+from dulwich.objects import Blob
+from dulwich.pack import write_pack
 
-from ...objects import Blob
-from ...pack import write_pack
+from .. import SkipTest
 from ..test_pack import PackTests, a_sha, pack1_sha
 from .utils import require_git_version, run_git_or_fail
 

+ 1 - 1
dulwich/tests/compat/test_patch.py → tests/compat/test_patch.py

@@ -26,8 +26,8 @@ import tempfile
 from io import BytesIO
 
 from dulwich import porcelain
+from dulwich.repo import Repo
 
-from ...repo import Repo
 from .utils import CompatTestCase, run_git_or_fail
 
 

+ 1 - 1
dulwich/tests/compat/test_porcelain.py → tests/compat/test_porcelain.py

@@ -26,9 +26,9 @@ import sys
 from unittest import skipIf
 
 from dulwich import porcelain
+from dulwich.tests.utils import build_commit_graph
 
 from ..test_porcelain import PorcelainGpgTestCase
-from ..utils import build_commit_graph
 from .utils import CompatTestCase, run_git_or_fail
 
 

+ 3 - 2
dulwich/tests/compat/test_repository.py → tests/compat/test_repository.py

@@ -25,8 +25,9 @@ import tempfile
 from io import BytesIO
 from itertools import chain
 
-from ...objects import hex_to_sha
-from ...repo import Repo, check_ref_format
+from dulwich.objects import hex_to_sha
+from dulwich.repo import Repo, check_ref_format
+
 from .utils import CompatTestCase, require_git_version, rmtree_ro, run_git_or_fail
 
 

+ 2 - 2
dulwich/tests/compat/test_server.py → tests/compat/test_server.py

@@ -29,9 +29,9 @@ import os
 import sys
 import threading
 
-from dulwich.tests import skipIf
+from dulwich.server import DictBackend, TCPGitServer
 
-from ...server import DictBackend, TCPGitServer
+from .. import skipIf
 from .server_utils import NoSideBand64kReceivePackHandler, ServerTests
 from .utils import CompatTestCase, require_git_version
 

+ 2 - 2
dulwich/tests/compat/test_utils.py → tests/compat/test_utils.py

@@ -20,8 +20,8 @@
 
 """Tests for git compatibility utilities."""
 
-from dulwich.tests import SkipTest, TestCase
-from dulwich.tests.compat import utils
+from .. import SkipTest, TestCase
+from . import utils
 
 
 class GitVersionTests(TestCase):

+ 4 - 4
dulwich/tests/compat/test_web.py → tests/compat/test_web.py

@@ -30,15 +30,15 @@ import threading
 from typing import Tuple
 from wsgiref import simple_server
 
-from dulwich.tests import SkipTest, skipIf
-
-from ...server import DictBackend, ReceivePackHandler, UploadPackHandler
-from ...web import (
+from dulwich.server import DictBackend, ReceivePackHandler, UploadPackHandler
+from dulwich.web import (
     HTTPGitApplication,
     WSGIRequestHandlerLogger,
     WSGIServerLogger,
     make_wsgi_chain,
 )
+
+from .. import SkipTest, skipIf
 from .server_utils import NoSideBand64kReceivePackHandler, ServerTests
 from .utils import CompatTestCase
 

+ 4 - 4
dulwich/tests/compat/utils.py → tests/compat/utils.py

@@ -32,16 +32,16 @@ import tempfile
 import time
 from typing import Tuple
 
-from dulwich.tests import SkipTest, TestCase
+from dulwich.protocol import TCP_GIT_PORT
+from dulwich.repo import Repo
 
-from ...protocol import TCP_GIT_PORT
-from ...repo import Repo
+from .. import SkipTest, TestCase
 
 _DEFAULT_GIT = "git"
 _VERSION_LEN = 4
 _REPOS_DATA_DIR = os.path.abspath(
     os.path.join(
-        os.path.dirname(__file__), os.pardir, os.pardir, os.pardir, "testdata", "repos"
+        os.path.dirname(__file__), os.pardir, os.pardir, "testdata", "repos"
     )
 )
 

+ 31 - 0
tests/contrib/__init__.py

@@ -0,0 +1,31 @@
+# __init__.py -- Contrib module for Dulwich
+# Copyright (C) 2014 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# 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.
+#
+
+def test_suite():
+    import unittest
+
+    names = [
+        "paramiko_vendor",
+        "release_robot",
+        "swift",
+    ]
+    module_names = ["tests.contrib.test_" + name for name in names]
+    loader = unittest.TestLoader()
+    return loader.loadTestsFromNames(module_names)

+ 2 - 2
dulwich/contrib/test_paramiko_vendor.py → tests/contrib/test_paramiko_vendor.py

@@ -24,7 +24,7 @@ import threading
 from io import StringIO
 from unittest import skipIf
 
-from dulwich.tests import TestCase
+from .. import TestCase
 
 try:
     import paramiko
@@ -32,7 +32,7 @@ except ImportError:
     has_paramiko = False
 else:
     has_paramiko = True
-    from .paramiko_vendor import ParamikoSSHVendor
+    from dulwich.contrib.paramiko_vendor import ParamikoSSHVendor
 
     class Server(paramiko.ServerInterface):
         """http://docs.paramiko.org/en/2.4/api/server.html."""

+ 2 - 3
dulwich/contrib/test_release_robot.py → tests/contrib/test_release_robot.py

@@ -29,9 +29,8 @@ import unittest
 from typing import ClassVar, Dict, List, Optional, Tuple
 
 from dulwich.contrib import release_robot
-
-from ..repo import Repo
-from ..tests.utils import make_commit, make_tag
+from dulwich.repo import Repo
+from dulwich.tests.utils import make_commit, make_tag
 
 BASEDIR = os.path.abspath(os.path.dirname(__file__))  # this directory
 

+ 3 - 3
dulwich/contrib/test_swift.py → tests/contrib/test_swift.py

@@ -28,10 +28,10 @@ from io import BytesIO, StringIO
 from time import time
 from unittest import skipIf
 
-from dulwich.tests import TestCase
+from dulwich.objects import Blob, Commit, Tag, Tree, parse_timezone
 
-from ..objects import Blob, Commit, Tag, Tree, parse_timezone
-from ..tests.test_object_store import ObjectStoreTests
+from .. import TestCase
+from ..test_object_store import ObjectStoreTests
 
 missing_libs = []
 

+ 0 - 0
dulwich/contrib/test_swift_smoke.py → tests/contrib/test_swift_smoke.py


+ 5 - 5
dulwich/tests/test_archive.py → tests/test_archive.py

@@ -25,12 +25,12 @@ import tarfile
 from io import BytesIO
 from unittest import skipUnless
 
-from dulwich.tests import TestCase
+from dulwich.archive import tar_stream
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Blob, Tree
+from dulwich.tests.utils import build_commit_graph
 
-from ..archive import tar_stream
-from ..object_store import MemoryObjectStore
-from ..objects import Blob, Tree
-from .utils import build_commit_graph
+from . import TestCase
 
 try:
     from unittest.mock import patch

+ 2 - 2
dulwich/tests/test_blackbox.py → tests/test_blackbox.py

@@ -23,9 +23,9 @@
 import shutil
 import tempfile
 
-from dulwich.tests import BlackboxTestCase
+from dulwich.repo import Repo
 
-from ..repo import Repo
+from . import BlackboxTestCase
 
 
 class GitReceivePackTests(BlackboxTestCase):

+ 3 - 3
dulwich/tests/test_bundle.py → tests/test_bundle.py

@@ -24,10 +24,10 @@ import os
 import tempfile
 from io import BytesIO
 
-from dulwich.tests import TestCase
+from dulwich.bundle import Bundle, read_bundle, write_bundle
+from dulwich.pack import PackData, write_pack_objects
 
-from ..bundle import Bundle, read_bundle, write_bundle
-from ..pack import PackData, write_pack_objects
+from . import TestCase
 
 
 class BundleTests(TestCase):

+ 9 - 9
dulwich/tests/test_client.py → tests/test_client.py

@@ -32,9 +32,7 @@ from urllib.parse import urlparse
 
 import dulwich
 from dulwich import client
-from dulwich.tests import TestCase, skipIf
-
-from ..client import (
+from dulwich.client import (
     FetchPackResult,
     GitProtocolError,
     HangupException,
@@ -57,12 +55,14 @@ from ..client import (
     get_transport_and_path_from_url,
     parse_rsync_url,
 )
-from ..config import ConfigDict
-from ..objects import Commit, Tree
-from ..pack import pack_objects_to_data, write_pack_data, write_pack_objects
-from ..protocol import TCP_GIT_PORT, Protocol
-from ..repo import MemoryRepo, Repo
-from .utils import open_repo, setup_warning_catcher, tear_down_repo
+from dulwich.config import ConfigDict
+from dulwich.objects import Commit, Tree
+from dulwich.pack import pack_objects_to_data, write_pack_data, write_pack_objects
+from dulwich.protocol import TCP_GIT_PORT, Protocol
+from dulwich.repo import MemoryRepo, Repo
+from dulwich.tests.utils import open_repo, setup_warning_catcher, tear_down_repo
+
+from . import TestCase, skipIf
 
 
 class DummyClient(TraditionalGitClient):

+ 5 - 5
dulwich/tests/test_config.py → tests/test_config.py

@@ -26,9 +26,7 @@ from io import BytesIO
 from unittest import skipIf
 from unittest.mock import patch
 
-from dulwich.tests import TestCase
-
-from ..config import (
+from dulwich.config import (
     ConfigDict,
     ConfigFile,
     StackedConfig,
@@ -41,6 +39,8 @@ from ..config import (
     parse_submodules,
 )
 
+from . import TestCase
+
 
 class ConfigFileTests(TestCase):
     def from_file(self, text):
@@ -312,7 +312,7 @@ class StackedConfigTests(TestCase):
 
     @skipIf(sys.platform != "win32", "Windows specific config location.")
     def test_windows_config_from_path(self):
-        from ..config import get_win_system_paths
+        from dulwich.config import get_win_system_paths
 
         install_dir = os.path.join("C:", "foo", "Git")
         self.overrideEnv("PATH", os.path.join(install_dir, "cmd"))
@@ -330,7 +330,7 @@ class StackedConfigTests(TestCase):
     def test_windows_config_from_reg(self):
         import winreg
 
-        from ..config import get_win_system_paths
+        from dulwich.config import get_win_system_paths
 
         self.overrideEnv("PATH", None)
         install_dir = os.path.join("C:", "foo", "Git")

+ 7 - 3
dulwich/tests/test_credentials.py → tests/test_credentials.py

@@ -21,10 +21,14 @@
 
 from urllib.parse import urlparse
 
-from dulwich.tests import TestCase
+from dulwich.config import ConfigDict
+from dulwich.credentials import (
+    match_partial_url,
+    match_urls,
+    urlmatch_credential_sections,
+)
 
-from ..config import ConfigDict
-from ..credentials import match_partial_url, match_urls, urlmatch_credential_sections
+from . import TestCase
 
 
 class TestCredentialHelpersUtils(TestCase):

+ 7 - 7
dulwich/tests/test_diff_tree.py → tests/test_diff_tree.py

@@ -22,9 +22,7 @@
 
 from itertools import permutations
 
-from dulwich.tests import TestCase
-
-from ..diff_tree import (
+from dulwich.diff_tree import (
     CHANGE_COPY,
     CHANGE_MODIFY,
     CHANGE_RENAME,
@@ -42,10 +40,12 @@ from ..diff_tree import (
     tree_changes,
     tree_changes_for_merge,
 )
-from ..index import commit_tree
-from ..object_store import MemoryObjectStore
-from ..objects import Blob, ShaFile, Tree, TreeEntry
-from .utils import F, ext_functest_builder, functest_builder, make_object
+from dulwich.index import commit_tree
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Blob, ShaFile, Tree, TreeEntry
+from dulwich.tests.utils import F, ext_functest_builder, functest_builder, make_object
+
+from . import TestCase
 
 
 class DiffTestCase(TestCase):

+ 7 - 7
dulwich/tests/test_fastexport.py → tests/test_fastexport.py

@@ -21,12 +21,12 @@
 import stat
 from io import BytesIO
 
-from dulwich.tests import SkipTest, TestCase
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import ZERO_SHA, Blob, Commit, Tree
+from dulwich.repo import MemoryRepo
+from dulwich.tests.utils import build_commit_graph
 
-from ..object_store import MemoryObjectStore
-from ..objects import ZERO_SHA, Blob, Commit, Tree
-from ..repo import MemoryRepo
-from .utils import build_commit_graph
+from . import SkipTest, TestCase
 
 
 class GitFastExporterTests(TestCase):
@@ -37,7 +37,7 @@ class GitFastExporterTests(TestCase):
         self.store = MemoryObjectStore()
         self.stream = BytesIO()
         try:
-            from ..fastexport import GitFastExporter
+            from dulwich.fastexport import GitFastExporter
         except ImportError as exc:
             raise SkipTest("python-fastimport not available") from exc
         self.fastexporter = GitFastExporter(self.stream, self.store)
@@ -85,7 +85,7 @@ class GitImportProcessorTests(TestCase):
         super().setUp()
         self.repo = MemoryRepo()
         try:
-            from ..fastexport import GitImportProcessor
+            from dulwich.fastexport import GitImportProcessor
         except ImportError as exc:
             raise SkipTest("python-fastimport not available") from exc
         self.processor = GitImportProcessor(self.repo)

+ 2 - 2
dulwich/tests/test_file.py → tests/test_file.py

@@ -24,9 +24,9 @@ import shutil
 import sys
 import tempfile
 
-from dulwich.tests import SkipTest, TestCase
+from dulwich.file import FileLocked, GitFile, _fancy_rename
 
-from ..file import FileLocked, GitFile, _fancy_rename
+from . import SkipTest, TestCase
 
 
 class FancyRenameTests(TestCase):

+ 4 - 4
dulwich/tests/test_grafts.py → tests/test_grafts.py

@@ -23,11 +23,11 @@ import os
 import shutil
 import tempfile
 
-from dulwich.tests import TestCase
+from dulwich.errors import ObjectFormatException
+from dulwich.objects import Tree
+from dulwich.repo import MemoryRepo, Repo, parse_graftpoints, serialize_graftpoints
 
-from ..errors import ObjectFormatException
-from ..objects import Tree
-from ..repo import MemoryRepo, Repo, parse_graftpoints, serialize_graftpoints
+from . import TestCase
 
 
 def makesha(digit):

+ 4 - 4
dulwich/tests/test_graph.py → tests/test_graph.py

@@ -19,11 +19,11 @@
 
 """Tests for dulwich.graph."""
 
-from dulwich.tests import TestCase
+from dulwich.graph import WorkList, _find_lcas, can_fast_forward
+from dulwich.repo import MemoryRepo
+from dulwich.tests.utils import make_commit
 
-from ..graph import WorkList, _find_lcas, can_fast_forward
-from ..repo import MemoryRepo
-from .utils import make_commit
+from . import TestCase
 
 
 class FindMergeBaseTests(TestCase):

+ 3 - 3
dulwich/tests/test_greenthreads.py → tests/test_greenthreads.py

@@ -22,10 +22,10 @@
 
 import time
 
-from dulwich.tests import TestCase, skipIf
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Blob, Commit, Tree, parse_timezone
 
-from ..object_store import MemoryObjectStore
-from ..objects import Blob, Commit, Tree, parse_timezone
+from . import TestCase, skipIf
 
 try:
     import gevent  # noqa: F401

+ 2 - 2
dulwich/tests/test_hooks.py → tests/test_hooks.py

@@ -26,9 +26,9 @@ import sys
 import tempfile
 
 from dulwich import errors
-from dulwich.tests import TestCase
+from dulwich.hooks import CommitMsgShellHook, PostCommitShellHook, PreCommitShellHook
 
-from ..hooks import CommitMsgShellHook, PostCommitShellHook, PreCommitShellHook
+from . import TestCase
 
 
 class ShellHookTests(TestCase):

+ 4 - 4
dulwich/tests/test_ignore.py → tests/test_ignore.py

@@ -26,9 +26,7 @@ import shutil
 import tempfile
 from io import BytesIO
 
-from dulwich.tests import TestCase
-
-from ..ignore import (
+from dulwich.ignore import (
     IgnoreFilter,
     IgnoreFilterManager,
     IgnoreFilterStack,
@@ -37,7 +35,9 @@ from ..ignore import (
     read_ignore_patterns,
     translate,
 )
-from ..repo import Repo
+from dulwich.repo import Repo
+
+from . import TestCase
 
 POSITIVE_MATCH_TESTS = [
     (b"foo.c", b"*.c"),

+ 7 - 7
dulwich/tests/test_index.py → tests/test_index.py

@@ -28,9 +28,7 @@ import sys
 import tempfile
 from io import BytesIO
 
-from dulwich.tests import TestCase, skipIf
-
-from ..index import (
+from dulwich.index import (
     Index,
     IndexEntry,
     SerializedIndexEntry,
@@ -49,9 +47,11 @@ from ..index import (
     write_index,
     write_index_dict,
 )
-from ..object_store import MemoryObjectStore
-from ..objects import S_IFGITLINK, Blob, Commit, Tree
-from ..repo import Repo
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import S_IFGITLINK, Blob, Commit, Tree
+from dulwich.repo import Repo
+
+from . import TestCase, skipIf
 
 
 def can_symlink():
@@ -70,7 +70,7 @@ def can_symlink():
 
 
 class IndexTestCase(TestCase):
-    datadir = os.path.join(os.path.dirname(__file__), "../../testdata/indexes")
+    datadir = os.path.join(os.path.dirname(__file__), "../testdata/indexes")
 
     def get_simple_index(self, name):
         return Index(os.path.join(self.datadir, name))

+ 2 - 1
dulwich/tests/test_lfs.py → tests/test_lfs.py

@@ -23,7 +23,8 @@
 import shutil
 import tempfile
 
-from ..lfs import LFSStore
+from dulwich.lfs import LFSStore
+
 from . import TestCase
 
 

+ 4 - 4
dulwich/tests/test_line_ending.py → tests/test_line_ending.py

@@ -20,16 +20,16 @@
 
 """Tests for the line ending conversion."""
 
-from dulwich.tests import TestCase
-
-from ..line_ending import (
+from dulwich.line_ending import (
     convert_crlf_to_lf,
     convert_lf_to_crlf,
     get_checkin_filter_autocrlf,
     get_checkout_filter_autocrlf,
     normalize_blob,
 )
-from ..objects import Blob
+from dulwich.objects import Blob
+
+from . import TestCase
 
 
 class LineEndingConversion(TestCase):

+ 2 - 1
dulwich/tests/test_lru_cache.py → tests/test_lru_cache.py

@@ -20,7 +20,8 @@
 """Tests for the lru_cache module."""
 
 from dulwich import lru_cache
-from dulwich.tests import TestCase
+
+from . import TestCase
 
 
 class TestLRUCache(TestCase):

+ 1 - 1
dulwich/tests/test_mailmap.py → tests/test_mailmap.py

@@ -23,7 +23,7 @@
 from io import BytesIO
 from unittest import TestCase
 
-from ..mailmap import Mailmap, read_mailmap
+from dulwich.mailmap import Mailmap, read_mailmap
 
 
 class ReadMailmapTests(TestCase):

+ 4 - 4
dulwich/tests/test_missing_obj_finder.py → tests/test_missing_obj_finder.py

@@ -18,11 +18,11 @@
 # License, Version 2.0.
 #
 
-from dulwich.tests import TestCase
+from dulwich.object_store import MemoryObjectStore, MissingObjectFinder
+from dulwich.objects import Blob
+from dulwich.tests.utils import build_commit_graph, make_object, make_tag
 
-from ..object_store import MemoryObjectStore, MissingObjectFinder
-from ..objects import Blob
-from .utils import build_commit_graph, make_object, make_tag
+from . import TestCase
 
 
 class MissingObjectFinderTest(TestCase):

+ 533 - 0
tests/test_object_store.py

@@ -0,0 +1,533 @@
+# test_object_store.py -- tests for object_store.py
+# Copyright (C) 2008 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# 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.
+#
+
+"""Tests for the object store interface."""
+
+import os
+import shutil
+import stat
+import sys
+import tempfile
+from contextlib import closing
+from io import BytesIO
+
+from dulwich.errors import NotTreeError
+from dulwich.index import commit_tree
+from dulwich.object_store import (
+    DiskObjectStore,
+    MemoryObjectStore,
+    ObjectStoreGraphWalker,
+    OverlayObjectStore,
+    commit_tree_changes,
+    read_packs_file,
+    tree_lookup_path,
+)
+from dulwich.objects import (
+    S_IFGITLINK,
+    Blob,
+    EmptyFileException,
+    SubmoduleEncountered,
+    Tree,
+    TreeEntry,
+    sha_to_hex,
+)
+from dulwich.pack import REF_DELTA, write_pack_objects
+from dulwich.tests.test_object_store import ObjectStoreTests, PackBasedObjectStoreTests
+from dulwich.tests.utils import build_pack, make_object
+
+from . import TestCase
+
+testobject = make_object(Blob, data=b"yummy data")
+
+
+class OverlayObjectStoreTests(ObjectStoreTests, TestCase):
+    def setUp(self):
+        TestCase.setUp(self)
+        self.bases = [MemoryObjectStore(), MemoryObjectStore()]
+        self.store = OverlayObjectStore(self.bases, self.bases[0])
+
+
+class MemoryObjectStoreTests(ObjectStoreTests, TestCase):
+    def setUp(self):
+        TestCase.setUp(self)
+        self.store = MemoryObjectStore()
+
+    def test_add_pack(self):
+        o = MemoryObjectStore()
+        f, commit, abort = o.add_pack()
+        try:
+            b = make_object(Blob, data=b"more yummy data")
+            write_pack_objects(f.write, [(b, None)])
+        except BaseException:
+            abort()
+            raise
+        else:
+            commit()
+
+    def test_add_pack_emtpy(self):
+        o = MemoryObjectStore()
+        f, commit, abort = o.add_pack()
+        commit()
+
+    def test_add_thin_pack(self):
+        o = MemoryObjectStore()
+        blob = make_object(Blob, data=b"yummy data")
+        o.add_object(blob)
+
+        f = BytesIO()
+        entries = build_pack(
+            f,
+            [
+                (REF_DELTA, (blob.id, b"more yummy data")),
+            ],
+            store=o,
+        )
+        o.add_thin_pack(f.read, None)
+        packed_blob_sha = sha_to_hex(entries[0][3])
+        self.assertEqual(
+            (Blob.type_num, b"more yummy data"), o.get_raw(packed_blob_sha)
+        )
+
+    def test_add_thin_pack_empty(self):
+        o = MemoryObjectStore()
+
+        f = BytesIO()
+        entries = build_pack(f, [], store=o)
+        self.assertEqual([], entries)
+        o.add_thin_pack(f.read, None)
+
+
+class DiskObjectStoreTests(PackBasedObjectStoreTests, TestCase):
+    def setUp(self):
+        TestCase.setUp(self)
+        self.store_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.store_dir)
+        self.store = DiskObjectStore.init(self.store_dir)
+
+    def tearDown(self):
+        TestCase.tearDown(self)
+        PackBasedObjectStoreTests.tearDown(self)
+
+    def test_loose_compression_level(self):
+        alternate_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, alternate_dir)
+        alternate_store = DiskObjectStore(alternate_dir, loose_compression_level=6)
+        b2 = make_object(Blob, data=b"yummy data")
+        alternate_store.add_object(b2)
+
+    def test_alternates(self):
+        alternate_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, alternate_dir)
+        alternate_store = DiskObjectStore(alternate_dir)
+        b2 = make_object(Blob, data=b"yummy data")
+        alternate_store.add_object(b2)
+        store = DiskObjectStore(self.store_dir)
+        self.assertRaises(KeyError, store.__getitem__, b2.id)
+        store.add_alternate_path(alternate_dir)
+        self.assertIn(b2.id, store)
+        self.assertEqual(b2, store[b2.id])
+
+    def test_read_alternate_paths(self):
+        store = DiskObjectStore(self.store_dir)
+
+        abs_path = os.path.abspath(os.path.normpath("/abspath"))
+        # ensures in particular existence of the alternates file
+        store.add_alternate_path(abs_path)
+        self.assertEqual(set(store._read_alternate_paths()), {abs_path})
+
+        store.add_alternate_path("relative-path")
+        self.assertIn(
+            os.path.join(store.path, "relative-path"),
+            set(store._read_alternate_paths()),
+        )
+
+        # arguably, add_alternate_path() could strip comments.
+        # Meanwhile it's more convenient to use it than to import INFODIR
+        store.add_alternate_path("# comment")
+        for alt_path in store._read_alternate_paths():
+            self.assertNotIn("#", alt_path)
+
+    def test_file_modes(self):
+        self.store.add_object(testobject)
+        path = self.store._get_shafile_path(testobject.id)
+        mode = os.stat(path).st_mode
+
+        packmode = "0o100444" if sys.platform != "win32" else "0o100666"
+        self.assertEqual(oct(mode), packmode)
+
+    def test_corrupted_object_raise_exception(self):
+        """Corrupted sha1 disk file should raise specific exception."""
+        self.store.add_object(testobject)
+        self.assertEqual(
+            (Blob.type_num, b"yummy data"), self.store.get_raw(testobject.id)
+        )
+        self.assertTrue(self.store.contains_loose(testobject.id))
+        self.assertIsNotNone(self.store._get_loose_object(testobject.id))
+
+        path = self.store._get_shafile_path(testobject.id)
+        old_mode = os.stat(path).st_mode
+        os.chmod(path, 0o600)
+        with open(path, "wb") as f:  # corrupt the file
+            f.write(b"")
+        os.chmod(path, old_mode)
+
+        expected_error_msg = "Corrupted empty file detected"
+        try:
+            self.store.contains_loose(testobject.id)
+        except EmptyFileException as e:
+            self.assertEqual(str(e), expected_error_msg)
+
+        try:
+            self.store._get_loose_object(testobject.id)
+        except EmptyFileException as e:
+            self.assertEqual(str(e), expected_error_msg)
+
+        # this does not change iteration on loose objects though
+        self.assertEqual([testobject.id], list(self.store._iter_loose_objects()))
+
+    def test_tempfile_in_loose_store(self):
+        self.store.add_object(testobject)
+        self.assertEqual([testobject.id], list(self.store._iter_loose_objects()))
+
+        # add temporary files to the loose store
+        for i in range(256):
+            dirname = os.path.join(self.store_dir, "%02x" % i)
+            if not os.path.isdir(dirname):
+                os.makedirs(dirname)
+            fd, n = tempfile.mkstemp(prefix="tmp_obj_", dir=dirname)
+            os.close(fd)
+
+        self.assertEqual([testobject.id], list(self.store._iter_loose_objects()))
+
+    def test_add_alternate_path(self):
+        store = DiskObjectStore(self.store_dir)
+        self.assertEqual([], list(store._read_alternate_paths()))
+        store.add_alternate_path("/foo/path")
+        self.assertEqual(["/foo/path"], list(store._read_alternate_paths()))
+        store.add_alternate_path("/bar/path")
+        self.assertEqual(
+            ["/foo/path", "/bar/path"], list(store._read_alternate_paths())
+        )
+
+    def test_rel_alternative_path(self):
+        alternate_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, alternate_dir)
+        alternate_store = DiskObjectStore(alternate_dir)
+        b2 = make_object(Blob, data=b"yummy data")
+        alternate_store.add_object(b2)
+        store = DiskObjectStore(self.store_dir)
+        self.assertRaises(KeyError, store.__getitem__, b2.id)
+        store.add_alternate_path(os.path.relpath(alternate_dir, self.store_dir))
+        self.assertEqual(list(alternate_store), list(store.alternates[0]))
+        self.assertIn(b2.id, store)
+        self.assertEqual(b2, store[b2.id])
+
+    def test_pack_dir(self):
+        o = DiskObjectStore(self.store_dir)
+        self.assertEqual(os.path.join(self.store_dir, "pack"), o.pack_dir)
+
+    def test_add_pack(self):
+        o = DiskObjectStore(self.store_dir)
+        self.addCleanup(o.close)
+        f, commit, abort = o.add_pack()
+        try:
+            b = make_object(Blob, data=b"more yummy data")
+            write_pack_objects(f.write, [(b, None)])
+        except BaseException:
+            abort()
+            raise
+        else:
+            commit()
+
+    def test_add_thin_pack(self):
+        o = DiskObjectStore(self.store_dir)
+        try:
+            blob = make_object(Blob, data=b"yummy data")
+            o.add_object(blob)
+
+            f = BytesIO()
+            entries = build_pack(
+                f,
+                [
+                    (REF_DELTA, (blob.id, b"more yummy data")),
+                ],
+                store=o,
+            )
+
+            with o.add_thin_pack(f.read, None) as pack:
+                packed_blob_sha = sha_to_hex(entries[0][3])
+                pack.check_length_and_checksum()
+                self.assertEqual(sorted([blob.id, packed_blob_sha]), list(pack))
+                self.assertTrue(o.contains_packed(packed_blob_sha))
+                self.assertTrue(o.contains_packed(blob.id))
+                self.assertEqual(
+                    (Blob.type_num, b"more yummy data"),
+                    o.get_raw(packed_blob_sha),
+                )
+        finally:
+            o.close()
+
+    def test_add_thin_pack_empty(self):
+        with closing(DiskObjectStore(self.store_dir)) as o:
+            f = BytesIO()
+            entries = build_pack(f, [], store=o)
+            self.assertEqual([], entries)
+            o.add_thin_pack(f.read, None)
+
+
+class TreeLookupPathTests(TestCase):
+    def setUp(self):
+        TestCase.setUp(self)
+        self.store = MemoryObjectStore()
+        blob_a = make_object(Blob, data=b"a")
+        blob_b = make_object(Blob, data=b"b")
+        blob_c = make_object(Blob, data=b"c")
+        for blob in [blob_a, blob_b, blob_c]:
+            self.store.add_object(blob)
+
+        blobs = [
+            (b"a", blob_a.id, 0o100644),
+            (b"ad/b", blob_b.id, 0o100644),
+            (b"ad/bd/c", blob_c.id, 0o100755),
+            (b"ad/c", blob_c.id, 0o100644),
+            (b"c", blob_c.id, 0o100644),
+            (b"d", blob_c.id, S_IFGITLINK),
+        ]
+        self.tree_id = commit_tree(self.store, blobs)
+
+    def get_object(self, sha):
+        return self.store[sha]
+
+    def test_lookup_blob(self):
+        o_id = tree_lookup_path(self.get_object, self.tree_id, b"a")[1]
+        self.assertIsInstance(self.store[o_id], Blob)
+
+    def test_lookup_tree(self):
+        o_id = tree_lookup_path(self.get_object, self.tree_id, b"ad")[1]
+        self.assertIsInstance(self.store[o_id], Tree)
+        o_id = tree_lookup_path(self.get_object, self.tree_id, b"ad/bd")[1]
+        self.assertIsInstance(self.store[o_id], Tree)
+        o_id = tree_lookup_path(self.get_object, self.tree_id, b"ad/bd/")[1]
+        self.assertIsInstance(self.store[o_id], Tree)
+
+    def test_lookup_submodule(self):
+        tree_lookup_path(self.get_object, self.tree_id, b"d")[1]
+        self.assertRaises(
+            SubmoduleEncountered,
+            tree_lookup_path,
+            self.get_object,
+            self.tree_id,
+            b"d/a",
+        )
+
+    def test_lookup_nonexistent(self):
+        self.assertRaises(
+            KeyError, tree_lookup_path, self.get_object, self.tree_id, b"j"
+        )
+
+    def test_lookup_not_tree(self):
+        self.assertRaises(
+            NotTreeError,
+            tree_lookup_path,
+            self.get_object,
+            self.tree_id,
+            b"ad/b/j",
+        )
+
+
+class ObjectStoreGraphWalkerTests(TestCase):
+    def get_walker(self, heads, parent_map):
+        new_parent_map = {
+            k * 40: [(p * 40) for p in ps] for (k, ps) in parent_map.items()
+        }
+        return ObjectStoreGraphWalker(
+            [x * 40 for x in heads], new_parent_map.__getitem__
+        )
+
+    def test_ack_invalid_value(self):
+        gw = self.get_walker([], {})
+        self.assertRaises(ValueError, gw.ack, "tooshort")
+
+    def test_empty(self):
+        gw = self.get_walker([], {})
+        self.assertIs(None, next(gw))
+        gw.ack(b"a" * 40)
+        self.assertIs(None, next(gw))
+
+    def test_descends(self):
+        gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []})
+        self.assertEqual(b"a" * 40, next(gw))
+        self.assertEqual(b"b" * 40, next(gw))
+
+    def test_present(self):
+        gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []})
+        gw.ack(b"a" * 40)
+        self.assertIs(None, next(gw))
+
+    def test_parent_present(self):
+        gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []})
+        self.assertEqual(b"a" * 40, next(gw))
+        gw.ack(b"a" * 40)
+        self.assertIs(None, next(gw))
+
+    def test_child_ack_later(self):
+        gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": [b"c"], b"c": []})
+        self.assertEqual(b"a" * 40, next(gw))
+        self.assertEqual(b"b" * 40, next(gw))
+        gw.ack(b"a" * 40)
+        self.assertIs(None, next(gw))
+
+    def test_only_once(self):
+        # a  b
+        # |  |
+        # c  d
+        # \ /
+        #  e
+        gw = self.get_walker(
+            [b"a", b"b"],
+            {
+                b"a": [b"c"],
+                b"b": [b"d"],
+                b"c": [b"e"],
+                b"d": [b"e"],
+                b"e": [],
+            },
+        )
+        walk = []
+        acked = False
+        walk.append(next(gw))
+        walk.append(next(gw))
+        # A branch (a, c) or (b, d) may be done after 2 steps or 3 depending on
+        # the order walked: 3-step walks include (a, b, c) and (b, a, d), etc.
+        if walk == [b"a" * 40, b"c" * 40] or walk == [b"b" * 40, b"d" * 40]:
+            gw.ack(walk[0])
+            acked = True
+
+        walk.append(next(gw))
+        if not acked and walk[2] == b"c" * 40:
+            gw.ack(b"a" * 40)
+        elif not acked and walk[2] == b"d" * 40:
+            gw.ack(b"b" * 40)
+        walk.append(next(gw))
+        self.assertIs(None, next(gw))
+
+        self.assertEqual([b"a" * 40, b"b" * 40, b"c" * 40, b"d" * 40], sorted(walk))
+        self.assertLess(walk.index(b"a" * 40), walk.index(b"c" * 40))
+        self.assertLess(walk.index(b"b" * 40), walk.index(b"d" * 40))
+
+
+class CommitTreeChangesTests(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.store = MemoryObjectStore()
+        self.blob_a = make_object(Blob, data=b"a")
+        self.blob_b = make_object(Blob, data=b"b")
+        self.blob_c = make_object(Blob, data=b"c")
+        for blob in [self.blob_a, self.blob_b, self.blob_c]:
+            self.store.add_object(blob)
+
+        blobs = [
+            (b"a", self.blob_a.id, 0o100644),
+            (b"ad/b", self.blob_b.id, 0o100644),
+            (b"ad/bd/c", self.blob_c.id, 0o100755),
+            (b"ad/c", self.blob_c.id, 0o100644),
+            (b"c", self.blob_c.id, 0o100644),
+        ]
+        self.tree_id = commit_tree(self.store, blobs)
+
+    def test_no_changes(self):
+        self.assertEqual(
+            self.store[self.tree_id],
+            commit_tree_changes(self.store, self.store[self.tree_id], []),
+        )
+
+    def test_add_blob(self):
+        blob_d = make_object(Blob, data=b"d")
+        new_tree = commit_tree_changes(
+            self.store, self.store[self.tree_id], [(b"d", 0o100644, blob_d.id)]
+        )
+        self.assertEqual(
+            new_tree[b"d"],
+            (33188, b"c59d9b6344f1af00e504ba698129f07a34bbed8d"),
+        )
+
+    def test_add_blob_in_dir(self):
+        blob_d = make_object(Blob, data=b"d")
+        new_tree = commit_tree_changes(
+            self.store,
+            self.store[self.tree_id],
+            [(b"e/f/d", 0o100644, blob_d.id)],
+        )
+        self.assertEqual(
+            new_tree.items(),
+            [
+                TreeEntry(path=b"a", mode=stat.S_IFREG | 0o100644, sha=self.blob_a.id),
+                TreeEntry(
+                    path=b"ad",
+                    mode=stat.S_IFDIR,
+                    sha=b"0e2ce2cd7725ff4817791be31ccd6e627e801f4a",
+                ),
+                TreeEntry(path=b"c", mode=stat.S_IFREG | 0o100644, sha=self.blob_c.id),
+                TreeEntry(
+                    path=b"e",
+                    mode=stat.S_IFDIR,
+                    sha=b"6ab344e288724ac2fb38704728b8896e367ed108",
+                ),
+            ],
+        )
+        e_tree = self.store[new_tree[b"e"][1]]
+        self.assertEqual(
+            e_tree.items(),
+            [
+                TreeEntry(
+                    path=b"f",
+                    mode=stat.S_IFDIR,
+                    sha=b"24d2c94d8af232b15a0978c006bf61ef4479a0a5",
+                )
+            ],
+        )
+        f_tree = self.store[e_tree[b"f"][1]]
+        self.assertEqual(
+            f_tree.items(),
+            [TreeEntry(path=b"d", mode=stat.S_IFREG | 0o100644, sha=blob_d.id)],
+        )
+
+    def test_delete_blob(self):
+        new_tree = commit_tree_changes(
+            self.store, self.store[self.tree_id], [(b"ad/bd/c", None, None)]
+        )
+        self.assertEqual(set(new_tree), {b"a", b"ad", b"c"})
+        ad_tree = self.store[new_tree[b"ad"][1]]
+        self.assertEqual(set(ad_tree), {b"b", b"c"})
+
+
+class TestReadPacksFile(TestCase):
+    def test_read_packs(self):
+        self.assertEqual(
+            ["pack-1.pack"],
+            list(
+                read_packs_file(
+                    BytesIO(
+                        b"""P pack-1.pack
+"""
+                    )
+                )
+            ),
+        )

+ 12 - 7
dulwich/tests/test_objects.py → tests/test_objects.py

@@ -29,10 +29,8 @@ from contextlib import contextmanager
 from io import BytesIO
 from itertools import permutations
 
-from dulwich.tests import TestCase
-
-from ..errors import ObjectFormatException
-from ..objects import (
+from dulwich.errors import ObjectFormatException
+from dulwich.objects import (
     MAX_TIME,
     Blob,
     Commit,
@@ -54,7 +52,14 @@ from ..objects import (
     sha_to_hex,
     sorted_tree_items,
 )
-from .utils import ext_functest_builder, functest_builder, make_commit, make_object
+from dulwich.tests.utils import (
+    ext_functest_builder,
+    functest_builder,
+    make_commit,
+    make_object,
+)
+
+from . import TestCase
 
 a_sha = b"6f670c0fb53f9463760b7295fbb814e965fb20c8"
 b_sha = b"2969be3e8ee1c0222396a5611407e4769f14e54b"
@@ -75,7 +80,7 @@ class BlobReadTests(TestCase):
     """Test decompression of blobs."""
 
     def get_sha_file(self, cls, base, sha):
-        dir = os.path.join(os.path.dirname(__file__), "..", "..", "testdata", base)
+        dir = os.path.join(os.path.dirname(__file__), "..", "testdata", base)
         return cls.from_path(hex_to_filename(dir, sha))
 
     def get_blob(self, sha):
@@ -855,7 +860,7 @@ class TreeTests(ShaFileCheckTests):
         self.assertEqual(_SORTED_TREE_ITEMS, x.items())
 
     def _do_test_parse_tree(self, parse_tree):
-        dir = os.path.join(os.path.dirname(__file__), "..", "..", "testdata", "trees")
+        dir = os.path.join(os.path.dirname(__file__), "..", "testdata", "trees")
         o = Tree.from_path(hex_to_filename(dir, tree_sha))
         self.assertEqual(
             [(b"a", 0o100644, a_sha), (b"b", 0o100644, b_sha)],

+ 6 - 6
dulwich/tests/test_objectspec.py → tests/test_objectspec.py

@@ -22,10 +22,8 @@
 
 # TODO: Round-trip parse-serialize-parse and serialize-parse-serialize tests.
 
-from dulwich.tests import TestCase
-
-from ..objects import Blob
-from ..objectspec import (
+from dulwich.objects import Blob
+from dulwich.objectspec import (
     parse_commit,
     parse_commit_range,
     parse_object,
@@ -35,8 +33,10 @@ from ..objectspec import (
     parse_reftuples,
     parse_tree,
 )
-from ..repo import MemoryRepo
-from .utils import build_commit_graph
+from dulwich.repo import MemoryRepo
+from dulwich.tests.utils import build_commit_graph
+
+from . import TestCase
 
 
 class ParseObjectTests(TestCase):

+ 9 - 9
dulwich/tests/test_pack.py → tests/test_pack.py

@@ -30,13 +30,11 @@ from hashlib import sha1
 from io import BytesIO
 from typing import Set
 
-from dulwich.tests import TestCase
-
-from ..errors import ApplyDeltaError, ChecksumMismatch
-from ..file import GitFile
-from ..object_store import MemoryObjectStore
-from ..objects import Blob, Commit, Tree, hex_to_sha, sha_to_hex
-from ..pack import (
+from dulwich.errors import ApplyDeltaError, ChecksumMismatch
+from dulwich.file import GitFile
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Blob, Commit, Tree, hex_to_sha, sha_to_hex
+from dulwich.pack import (
     OFS_DELTA,
     REF_DELTA,
     DeltaChainIterator,
@@ -61,7 +59,9 @@ from ..pack import (
     write_pack_index_v2,
     write_pack_object,
 )
-from .utils import build_pack, make_object
+from dulwich.tests.utils import build_pack, make_object
+
+from . import TestCase
 
 pack1_sha = b"bc63ddad95e7321ee734ea11a7a62d314e0d7481"
 
@@ -80,7 +80,7 @@ class PackTests(TestCase):
         self.addCleanup(shutil.rmtree, self.tempdir)
 
     datadir = os.path.abspath(
-        os.path.join(os.path.dirname(__file__), "../../testdata/packs")
+        os.path.join(os.path.dirname(__file__), "../testdata/packs")
     )
 
     def get_pack_index(self, sha):

+ 5 - 5
dulwich/tests/test_patch.py → tests/test_patch.py

@@ -22,11 +22,9 @@
 
 from io import BytesIO, StringIO
 
-from dulwich.tests import SkipTest, TestCase
-
-from ..object_store import MemoryObjectStore
-from ..objects import S_IFGITLINK, Blob, Commit, Tree
-from ..patch import (
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import S_IFGITLINK, Blob, Commit, Tree
+from dulwich.patch import (
     get_summary,
     git_am_patch_split,
     write_blob_diff,
@@ -35,6 +33,8 @@ from ..patch import (
     write_tree_diff,
 )
 
+from . import SkipTest, TestCase
+
 
 class WriteCommitPatchTests(TestCase):
     def test_simple_bytesio(self):

+ 10 - 10
dulwich/tests/test_porcelain.py → tests/test_porcelain.py

@@ -36,16 +36,16 @@ from io import BytesIO, StringIO
 from unittest import skipIf
 
 from dulwich import porcelain
-from dulwich.tests import TestCase
-
-from ..diff_tree import tree_changes
-from ..errors import CommitError
-from ..objects import ZERO_SHA, Blob, Tag, Tree
-from ..porcelain import CheckoutError
-from ..repo import NoIndexPresent, Repo
-from ..server import DictBackend
-from ..web import make_server, make_wsgi_chain
-from .utils import build_commit_graph, make_commit, make_object
+from dulwich.diff_tree import tree_changes
+from dulwich.errors import CommitError
+from dulwich.objects import ZERO_SHA, Blob, Tag, Tree
+from dulwich.porcelain import CheckoutError
+from dulwich.repo import NoIndexPresent, Repo
+from dulwich.server import DictBackend
+from dulwich.tests.utils import build_commit_graph, make_commit, make_object
+from dulwich.web import make_server, make_wsgi_chain
+
+from . import TestCase
 
 try:
     import gpg

+ 4 - 4
dulwich/tests/test_protocol.py → tests/test_protocol.py

@@ -22,10 +22,8 @@
 
 from io import BytesIO
 
-from dulwich.tests import TestCase
-
-from ..errors import HangupException
-from ..protocol import (
+from dulwich.errors import HangupException
+from dulwich.protocol import (
     MULTI_ACK,
     MULTI_ACK_DETAILED,
     SINGLE_ACK,
@@ -39,6 +37,8 @@ from ..protocol import (
     extract_want_line_capabilities,
 )
 
+from . import TestCase
+
 
 class BaseProtocolTests:
     def test_write_pkt_line_none(self):

+ 4 - 4
dulwich/tests/test_reflog.py → tests/test_reflog.py

@@ -22,16 +22,16 @@
 
 from io import BytesIO
 
-from dulwich.tests import TestCase
-
-from ..objects import ZERO_SHA
-from ..reflog import (
+from dulwich.objects import ZERO_SHA
+from dulwich.reflog import (
     drop_reflog_entry,
     format_reflog_line,
     parse_reflog_line,
     read_reflog,
 )
 
+from . import TestCase
+
 
 class ReflogLineTests(TestCase):
     def test_format(self):

+ 7 - 7
dulwich/tests/test_refs.py → tests/test_refs.py

@@ -27,11 +27,9 @@ from io import BytesIO
 from typing import ClassVar, Dict
 
 from dulwich import errors
-from dulwich.tests import SkipTest, TestCase
-
-from ..file import GitFile
-from ..objects import ZERO_SHA
-from ..refs import (
+from dulwich.file import GitFile
+from dulwich.objects import ZERO_SHA
+from dulwich.refs import (
     DictRefsContainer,
     InfoRefsContainer,
     SymrefLoop,
@@ -43,8 +41,10 @@ from ..refs import (
     strip_peeled_refs,
     write_packed_refs,
 )
-from ..repo import Repo
-from .utils import open_repo, tear_down_repo
+from dulwich.repo import Repo
+from dulwich.tests.utils import open_repo, tear_down_repo
+
+from . import SkipTest, TestCase
 
 
 class CheckRefFormatTests(TestCase):

+ 8 - 8
dulwich/tests/test_repository.py → tests/test_repository.py

@@ -30,12 +30,10 @@ import tempfile
 import warnings
 
 from dulwich import errors, objects, porcelain
-from dulwich.tests import TestCase, skipIf
-
-from ..config import Config
-from ..errors import NotGitRepository
-from ..object_store import tree_lookup_path
-from ..repo import (
+from dulwich.config import Config
+from dulwich.errors import NotGitRepository
+from dulwich.object_store import tree_lookup_path
+from dulwich.repo import (
     InvalidUserIdentity,
     MemoryRepo,
     Repo,
@@ -43,7 +41,9 @@ from ..repo import (
     UnsupportedVersion,
     check_user_identity,
 )
-from .utils import open_repo, setup_warning_catcher, tear_down_repo
+from dulwich.tests.utils import open_repo, setup_warning_catcher, tear_down_repo
+
+from . import TestCase, skipIf
 
 missing_sha = b"b91fa4d900e17e99b433218e988c4eb4a3e9a097"
 
@@ -406,7 +406,7 @@ class RepositoryRootTests(TestCase):
         temp_dir = self.mkdtemp()
         self.addCleanup(shutil.rmtree, temp_dir)
         repo_dir = os.path.join(
-            os.path.dirname(__file__), "..", "..", "testdata", "repos"
+            os.path.dirname(__file__), "..", "testdata", "repos"
         )
         dest_dir = os.path.join(temp_dir, "a.git")
         shutil.copytree(os.path.join(repo_dir, "a.git"), dest_dir, symlinks=True)

+ 9 - 9
dulwich/tests/test_server.py → tests/test_server.py

@@ -27,19 +27,17 @@ import tempfile
 from io import BytesIO
 from typing import Dict, List
 
-from dulwich.tests import TestCase
-
-from ..errors import (
+from dulwich.errors import (
     GitProtocolError,
     HangupException,
     NotGitRepository,
     UnexpectedCommandError,
 )
-from ..object_store import MemoryObjectStore
-from ..objects import Tree
-from ..protocol import ZERO_SHA, format_capability_line
-from ..repo import MemoryRepo, Repo
-from ..server import (
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Tree
+from dulwich.protocol import ZERO_SHA, format_capability_line
+from dulwich.repo import MemoryRepo, Repo
+from dulwich.server import (
     Backend,
     DictBackend,
     FileSystemBackend,
@@ -55,7 +53,9 @@ from ..server import (
     serve_command,
     update_server_info,
 )
-from .utils import make_commit, make_tag
+from dulwich.tests.utils import make_commit, make_tag
+
+from . import TestCase
 
 ONE = b"1" * 40
 TWO = b"2" * 40

+ 3 - 2
dulwich/tests/test_stash.py → tests/test_stash.py

@@ -20,8 +20,9 @@
 
 """Tests for stashes."""
 
-from ..repo import MemoryRepo
-from ..stash import Stash
+from dulwich.repo import MemoryRepo
+from dulwich.stash import Stash
+
 from . import TestCase
 
 

+ 4 - 4
dulwich/tests/test_utils.py → tests/test_utils.py

@@ -20,11 +20,11 @@
 
 """Tests for git test utilities."""
 
-from dulwich.tests import TestCase
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Blob
+from dulwich.tests.utils import build_commit_graph, make_object
 
-from ..object_store import MemoryObjectStore
-from ..objects import Blob
-from .utils import build_commit_graph, make_object
+from . import TestCase
 
 
 class BuildCommitGraphTest(TestCase):

+ 8 - 8
dulwich/tests/test_walk.py → tests/test_walk.py

@@ -23,14 +23,14 @@
 from itertools import permutations
 from unittest import expectedFailure
 
-from dulwich.tests import TestCase
-
-from ..diff_tree import CHANGE_MODIFY, CHANGE_RENAME, RenameDetector, TreeChange
-from ..errors import MissingCommitError
-from ..object_store import MemoryObjectStore
-from ..objects import Blob, Commit
-from ..walk import ORDER_TOPO, WalkEntry, Walker, _topo_reorder
-from .utils import F, build_commit_graph, make_object, make_tag
+from dulwich.diff_tree import CHANGE_MODIFY, CHANGE_RENAME, RenameDetector, TreeChange
+from dulwich.errors import MissingCommitError
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Blob, Commit
+from dulwich.tests.utils import F, build_commit_graph, make_object, make_tag
+from dulwich.walk import ORDER_TOPO, WalkEntry, Walker, _topo_reorder
+
+from . import TestCase
 
 
 class TestWalkEntry:

+ 8 - 8
dulwich/tests/test_web.py → tests/test_web.py

@@ -26,13 +26,12 @@ import re
 from io import BytesIO
 from typing import Type
 
-from dulwich.tests import TestCase
-
-from ..object_store import MemoryObjectStore
-from ..objects import Blob
-from ..repo import BaseRepo, MemoryRepo
-from ..server import DictBackend
-from ..web import (
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Blob
+from dulwich.repo import BaseRepo, MemoryRepo
+from dulwich.server import DictBackend
+from dulwich.tests.utils import make_object, make_tag
+from dulwich.web import (
     HTTP_ERROR,
     HTTP_FORBIDDEN,
     HTTP_NOT_FOUND,
@@ -50,7 +49,8 @@ from ..web import (
     handle_service_request,
     send_file,
 )
-from .utils import make_object, make_tag
+
+from . import TestCase
 
 
 class MinimalistWSGIInputStream: