Browse Source

Add support for os.PathLike objects throughout the API

Functions that accept file paths now support pathlib.Path objects in addition to
strings and bytes. This includes repository operations, configuration file
handling, ignore file processing, and all major entry points.

Fixes #1074
Jelmer Vernooij 1 month ago
parent
commit
32e73e511e

+ 6 - 0
NEWS

@@ -5,6 +5,12 @@
    when trying to process pack data from repositories that were cloned externally.
    when trying to process pack data from repositories that were cloned externally.
    (Jelmer Vernooij, #1179)
    (Jelmer Vernooij, #1179)
 
 
+ * Add support for ``os.PathLike`` objects throughout the API. Functions that
+   accept file paths now support ``pathlib.Path`` objects in addition to
+   strings and bytes. This includes repository operations, configuration file
+   handling, ignore file processing, and all major entry points.
+   (Jelmer Vernooij, #1074)
+
  * Add support for ``format`` argument to ``Repo.init()`` and ``Repo.init_bare()``
  * Add support for ``format`` argument to ``Repo.init()`` and ``Repo.init_bare()``
    to specify repository format version (0 or 1). This allows creating repositories
    to specify repository format version (0 or 1). This allows creating repositories
    with different format versions by setting the ``core.repositoryformatversion``
    with different format versions by setting the ``core.repositoryformatversion``

+ 12 - 6
dulwich/config.py

@@ -643,18 +643,22 @@ class ConfigFile(ConfigDict):
         return ret
         return ret
 
 
     @classmethod
     @classmethod
-    def from_path(cls, path: str) -> "ConfigFile":
+    def from_path(cls, path: Union[str, os.PathLike]) -> "ConfigFile":
         """Read configuration from a file on disk."""
         """Read configuration from a file on disk."""
         with GitFile(path, "rb") as f:
         with GitFile(path, "rb") as f:
             ret = cls.from_file(f)
             ret = cls.from_file(f)
-            ret.path = path
+            ret.path = os.fspath(path)
             return ret
             return ret
 
 
-    def write_to_path(self, path: Optional[str] = None) -> None:
+    def write_to_path(self, path: Optional[Union[str, os.PathLike]] = None) -> None:
         """Write configuration to a file on disk."""
         """Write configuration to a file on disk."""
         if path is None:
         if path is None:
-            path = self.path
-        with GitFile(path, "wb") as f:
+            if self.path is None:
+                raise ValueError("No path specified and no default path available")
+            path_to_use: Union[str, os.PathLike] = self.path
+        else:
+            path_to_use = path
+        with GitFile(path_to_use, "wb") as f:
             self.write_to_file(f)
             self.write_to_file(f)
 
 
     def write_to_file(self, f: BinaryIO) -> None:
     def write_to_file(self, f: BinaryIO) -> None:
@@ -809,7 +813,9 @@ class StackedConfig(Config):
                     yield section
                     yield section
 
 
 
 
-def read_submodules(path: str) -> Iterator[tuple[bytes, bytes, bytes]]:
+def read_submodules(
+    path: Union[str, os.PathLike],
+) -> Iterator[tuple[bytes, bytes, bytes]]:
     """Read a .gitmodules file."""
     """Read a .gitmodules file."""
     cfg = ConfigFile.from_path(path)
     cfg = ConfigFile.from_path(path)
     return parse_submodules(cfg)
     return parse_submodules(cfg)

+ 10 - 5
dulwich/file.py

@@ -24,7 +24,7 @@
 import os
 import os
 import sys
 import sys
 import warnings
 import warnings
-from typing import ClassVar
+from typing import ClassVar, Union
 
 
 
 
 def ensure_dir_exists(dirname) -> None:
 def ensure_dir_exists(dirname) -> None:
@@ -68,7 +68,9 @@ def _fancy_rename(oldname, newname) -> None:
     os.remove(tmpfile)
     os.remove(tmpfile)
 
 
 
 
-def GitFile(filename, mode="rb", bufsize=-1, mask=0o644):
+def GitFile(
+    filename: Union[str, bytes, os.PathLike], mode="rb", bufsize=-1, mask=0o644
+):
     """Create a file object that obeys the git file locking protocol.
     """Create a file object that obeys the git file locking protocol.
 
 
     Returns: a builtin file object or a _GitFile object
     Returns: a builtin file object or a _GitFile object
@@ -140,10 +142,13 @@ class _GitFile:
         "writelines",
         "writelines",
     }
     }
 
 
-    def __init__(self, filename, mode, bufsize, mask) -> None:
-        self._filename = filename
+    def __init__(
+        self, filename: Union[str, bytes, os.PathLike], mode, bufsize, mask
+    ) -> None:
+        # Convert PathLike to str/bytes for our internal use
+        self._filename: Union[str, bytes] = os.fspath(filename)
         if isinstance(self._filename, bytes):
         if isinstance(self._filename, bytes):
-            self._lockfilename = self._filename + b".lock"
+            self._lockfilename: Union[str, bytes] = self._filename + b".lock"
         else:
         else:
             self._lockfilename = self._filename + ".lock"
             self._lockfilename = self._filename + ".lock"
         try:
         try:

+ 3 - 1
dulwich/ignore.py

@@ -421,7 +421,9 @@ class IgnoreFilter:
         return _check_parent_exclusion(path, matching_patterns)
         return _check_parent_exclusion(path, matching_patterns)
 
 
     @classmethod
     @classmethod
-    def from_path(cls, path, ignorecase: bool = False) -> "IgnoreFilter":
+    def from_path(
+        cls, path: Union[str, os.PathLike], ignorecase: bool = False
+    ) -> "IgnoreFilter":
         with open(path, "rb") as f:
         with open(path, "rb") as f:
             return cls(read_ignore_patterns(f), ignorecase, path=path)
             return cls(read_ignore_patterns(f), ignorecase, path=path)
 
 

+ 2 - 2
dulwich/index.py

@@ -798,7 +798,7 @@ class Index:
 
 
     def __init__(
     def __init__(
         self,
         self,
-        filename: Union[bytes, str],
+        filename: Union[bytes, str, os.PathLike],
         read=True,
         read=True,
         skip_hash: bool = False,
         skip_hash: bool = False,
         version: Optional[int] = None,
         version: Optional[int] = None,
@@ -811,7 +811,7 @@ class Index:
           skip_hash: Whether to skip SHA1 hash when writing (for manyfiles feature)
           skip_hash: Whether to skip SHA1 hash when writing (for manyfiles feature)
           version: Index format version to use (None = auto-detect from file or use default)
           version: Index format version to use (None = auto-detect from file or use default)
         """
         """
-        self._filename = filename
+        self._filename = os.fspath(filename)
         # TODO(jelmer): Store the version returned by read_index
         # TODO(jelmer): Store the version returned by read_index
         self._version = version
         self._version = version
         self._skip_hash = skip_hash
         self._skip_hash = skip_hash

+ 7 - 3
dulwich/object_store.py

@@ -35,6 +35,7 @@ from typing import (
     Callable,
     Callable,
     Optional,
     Optional,
     Protocol,
     Protocol,
+    Union,
     cast,
     cast,
 )
 )
 
 
@@ -750,7 +751,10 @@ class DiskObjectStore(PackBasedObjectStore):
     """Git-style object store that exists on disk."""
     """Git-style object store that exists on disk."""
 
 
     def __init__(
     def __init__(
-        self, path, loose_compression_level=-1, pack_compression_level=-1
+        self,
+        path: Union[str, os.PathLike],
+        loose_compression_level=-1,
+        pack_compression_level=-1,
     ) -> None:
     ) -> None:
         """Open an object store.
         """Open an object store.
 
 
@@ -773,7 +777,7 @@ class DiskObjectStore(PackBasedObjectStore):
         return f"<{self.__class__.__name__}({self.path!r})>"
         return f"<{self.__class__.__name__}({self.path!r})>"
 
 
     @classmethod
     @classmethod
-    def from_config(cls, path, config):
+    def from_config(cls, path: Union[str, os.PathLike], config):
         try:
         try:
             default_compression_level = int(
             default_compression_level = int(
                 config.get((b"core",), b"compression").decode()
                 config.get((b"core",), b"compression").decode()
@@ -1046,7 +1050,7 @@ class DiskObjectStore(PackBasedObjectStore):
             )
             )
 
 
     @classmethod
     @classmethod
-    def init(cls, path):
+    def init(cls, path: Union[str, os.PathLike]):
         try:
         try:
             os.mkdir(path)
             os.mkdir(path)
         except FileExistsError:
         except FileExistsError:

+ 11 - 6
dulwich/pack.py

@@ -363,7 +363,7 @@ def iter_sha1(iter):
     return sha.hexdigest().encode("ascii")
     return sha.hexdigest().encode("ascii")
 
 
 
 
-def load_pack_index(path):
+def load_pack_index(path: Union[str, os.PathLike]):
     """Load an index file by path.
     """Load an index file by path.
 
 
     Args:
     Args:
@@ -396,7 +396,7 @@ def _load_file_contents(f, size=None):
     return contents, size
     return contents, size
 
 
 
 
-def load_pack_index_file(path, f):
+def load_pack_index_file(path: Union[str, os.PathLike], f):
     """Load an index file from a file-like object.
     """Load an index file from a file-like object.
 
 
     Args:
     Args:
@@ -404,15 +404,20 @@ def load_pack_index_file(path, f):
       f: File-like object
       f: File-like object
     Returns: A PackIndex loaded from the given file
     Returns: A PackIndex loaded from the given file
     """
     """
+    # Ensure path is a string for PackIndex classes
+    path_str = os.fspath(path)
+    if isinstance(path_str, bytes):
+        path_str = os.fsdecode(path_str)
+
     contents, size = _load_file_contents(f)
     contents, size = _load_file_contents(f)
     if contents[:4] == b"\377tOc":
     if contents[:4] == b"\377tOc":
         version = struct.unpack(b">L", contents[4:8])[0]
         version = struct.unpack(b">L", contents[4:8])[0]
         if version == 2:
         if version == 2:
-            return PackIndex2(path, file=f, contents=contents, size=size)
+            return PackIndex2(path_str, file=f, contents=contents, size=size)
         else:
         else:
             raise KeyError(f"Unknown pack index format {version}")
             raise KeyError(f"Unknown pack index format {version}")
     else:
     else:
-        return PackIndex1(path, file=f, contents=contents, size=size)
+        return PackIndex1(path_str, file=f, contents=contents, size=size)
 
 
 
 
 def bisect_find_sha(start, end, sha, unpack_name):
 def bisect_find_sha(start, end, sha, unpack_name):
@@ -1191,7 +1196,7 @@ class PackData:
     position.  It will all just throw a zlib or KeyError.
     position.  It will all just throw a zlib or KeyError.
     """
     """
 
 
-    def __init__(self, filename, file=None, size=None) -> None:
+    def __init__(self, filename: Union[str, os.PathLike], file=None, size=None) -> None:
         """Create a PackData object representing the pack in the given filename.
         """Create a PackData object representing the pack in the given filename.
 
 
         The file must exist and stay readable until the object is disposed of.
         The file must exist and stay readable until the object is disposed of.
@@ -1225,7 +1230,7 @@ class PackData:
         return cls(str(file), file=file, size=size)
         return cls(str(file), file=file, size=size)
 
 
     @classmethod
     @classmethod
-    def from_path(cls, path):
+    def from_path(cls, path: Union[str, os.PathLike]):
         return cls(filename=path)
         return cls(filename=path)
 
 
     def close(self) -> None:
     def close(self) -> None:

+ 7 - 5
dulwich/porcelain.py

@@ -262,7 +262,7 @@ def get_user_timezones():
     return author_timezone, commit_timezone
     return author_timezone, commit_timezone
 
 
 
 
-def open_repo(path_or_repo):
+def open_repo(path_or_repo: Union[str, os.PathLike, BaseRepo]):
     """Open an argument that can be a repository or a path for a repository."""
     """Open an argument that can be a repository or a path for a repository."""
     if isinstance(path_or_repo, BaseRepo):
     if isinstance(path_or_repo, BaseRepo):
         return path_or_repo
         return path_or_repo
@@ -275,7 +275,7 @@ def _noop_context_manager(obj):
     yield obj
     yield obj
 
 
 
 
-def open_repo_closing(path_or_repo):
+def open_repo_closing(path_or_repo: Union[str, os.PathLike, BaseRepo]):
     """Open an argument that can be a repository or a path for a repository.
     """Open an argument that can be a repository or a path for a repository.
     returns a context manager that will close the repo on exit if the argument
     returns a context manager that will close the repo on exit if the argument
     is a path, else does nothing if the argument is a repo.
     is a path, else does nothing if the argument is a repo.
@@ -477,7 +477,9 @@ def commit_tree(repo, tree, message=None, author=None, committer=None):
         )
         )
 
 
 
 
-def init(path=".", *, bare=False, symlinks: Optional[bool] = None):
+def init(
+    path: Union[str, os.PathLike] = ".", *, bare=False, symlinks: Optional[bool] = None
+):
     """Create a new git repository.
     """Create a new git repository.
 
 
     Args:
     Args:
@@ -497,7 +499,7 @@ def init(path=".", *, bare=False, symlinks: Optional[bool] = None):
 
 
 def clone(
 def clone(
     source,
     source,
-    target=None,
+    target: Optional[Union[str, os.PathLike]] = None,
     bare=False,
     bare=False,
     checkout=None,
     checkout=None,
     errstream=default_bytes_err_stream,
     errstream=default_bytes_err_stream,
@@ -582,7 +584,7 @@ def clone(
     )
     )
 
 
 
 
-def add(repo=".", paths=None):
+def add(repo: Union[str, os.PathLike, BaseRepo] = ".", paths=None):
     """Add files to the staging area.
     """Add files to the staging area.
 
 
     Args:
     Args:

+ 12 - 9
dulwich/refs.py

@@ -26,7 +26,7 @@ import os
 import warnings
 import warnings
 from collections.abc import Iterator
 from collections.abc import Iterator
 from contextlib import suppress
 from contextlib import suppress
-from typing import Any, Optional
+from typing import Any, Optional, Union
 
 
 from .errors import PackedRefsException, RefFormatError
 from .errors import PackedRefsException, RefFormatError
 from .file import GitFile, ensure_dir_exists
 from .file import GitFile, ensure_dir_exists
@@ -611,16 +611,19 @@ class InfoRefsContainer(RefsContainer):
 class DiskRefsContainer(RefsContainer):
 class DiskRefsContainer(RefsContainer):
     """Refs container that reads refs from disk."""
     """Refs container that reads refs from disk."""
 
 
-    def __init__(self, path, worktree_path=None, logger=None) -> None:
+    def __init__(
+        self,
+        path: Union[str, bytes, os.PathLike],
+        worktree_path: Optional[Union[str, bytes, os.PathLike]] = None,
+        logger=None,
+    ) -> None:
         super().__init__(logger=logger)
         super().__init__(logger=logger)
-        if getattr(path, "encode", None) is not None:
-            path = os.fsencode(path)
-        self.path = path
+        # Convert path-like objects to strings, then to bytes for Git compatibility
+        self.path = os.fsencode(os.fspath(path))
         if worktree_path is None:
         if worktree_path is None:
-            worktree_path = path
-        if getattr(worktree_path, "encode", None) is not None:
-            worktree_path = os.fsencode(worktree_path)
-        self.worktree_path = worktree_path
+            self.worktree_path = self.path
+        else:
+            self.worktree_path = os.fsencode(os.fspath(worktree_path))
         self._packed_refs = None
         self._packed_refs = None
         self._peeled_refs = None
         self._peeled_refs = None
 
 

+ 30 - 6
dulwich/repo.py

@@ -1151,7 +1151,7 @@ class Repo(BaseRepo):
 
 
     def __init__(
     def __init__(
         self,
         self,
-        root: str,
+        root: Union[str, bytes, os.PathLike],
         object_store: Optional[PackBasedObjectStore] = None,
         object_store: Optional[PackBasedObjectStore] = None,
         bare: Optional[bool] = None,
         bare: Optional[bool] = None,
     ) -> None:
     ) -> None:
@@ -1163,6 +1163,9 @@ class Repo(BaseRepo):
             repository's default object store
             repository's default object store
           bare: True if this is a bare repository.
           bare: True if this is a bare repository.
         """
         """
+        root = os.fspath(root)
+        if isinstance(root, bytes):
+            root = os.fsdecode(root)
         hidden_path = os.path.join(root, CONTROLDIR)
         hidden_path = os.path.join(root, CONTROLDIR)
         if bare is None:
         if bare is None:
             if os.path.isfile(hidden_path) or os.path.isdir(
             if os.path.isfile(hidden_path) or os.path.isdir(
@@ -1736,8 +1739,8 @@ class Repo(BaseRepo):
     @classmethod
     @classmethod
     def _init_maybe_bare(
     def _init_maybe_bare(
         cls,
         cls,
-        path,
-        controldir,
+        path: Union[str, bytes, os.PathLike],
+        controldir: Union[str, bytes, os.PathLike],
         bare,
         bare,
         object_store=None,
         object_store=None,
         config=None,
         config=None,
@@ -1745,6 +1748,12 @@ class Repo(BaseRepo):
         symlinks: Optional[bool] = None,
         symlinks: Optional[bool] = None,
         format: Optional[int] = None,
         format: Optional[int] = None,
     ):
     ):
+        path = os.fspath(path)
+        if isinstance(path, bytes):
+            path = os.fsdecode(path)
+        controldir = os.fspath(controldir)
+        if isinstance(controldir, bytes):
+            controldir = os.fsdecode(controldir)
         for d in BASE_DIRECTORIES:
         for d in BASE_DIRECTORIES:
             os.mkdir(os.path.join(controldir, *d))
             os.mkdir(os.path.join(controldir, *d))
         if object_store is None:
         if object_store is None:
@@ -1766,7 +1775,7 @@ class Repo(BaseRepo):
     @classmethod
     @classmethod
     def init(
     def init(
         cls,
         cls,
-        path: str,
+        path: Union[str, bytes, os.PathLike],
         *,
         *,
         mkdir: bool = False,
         mkdir: bool = False,
         config=None,
         config=None,
@@ -1782,6 +1791,9 @@ class Repo(BaseRepo):
           format: Repository format version (defaults to 0)
           format: Repository format version (defaults to 0)
         Returns: `Repo` instance
         Returns: `Repo` instance
         """
         """
+        path = os.fspath(path)
+        if isinstance(path, bytes):
+            path = os.fsdecode(path)
         if mkdir:
         if mkdir:
             os.mkdir(path)
             os.mkdir(path)
         controldir = os.path.join(path, CONTROLDIR)
         controldir = os.path.join(path, CONTROLDIR)
@@ -1798,7 +1810,13 @@ class Repo(BaseRepo):
         )
         )
 
 
     @classmethod
     @classmethod
-    def _init_new_working_directory(cls, path, main_repo, identifier=None, mkdir=False):
+    def _init_new_working_directory(
+        cls,
+        path: Union[str, bytes, os.PathLike],
+        main_repo,
+        identifier=None,
+        mkdir=False,
+    ):
         """Create a new working directory linked to a repository.
         """Create a new working directory linked to a repository.
 
 
         Args:
         Args:
@@ -1808,6 +1826,9 @@ class Repo(BaseRepo):
           mkdir: Whether to create the directory
           mkdir: Whether to create the directory
         Returns: `Repo` instance
         Returns: `Repo` instance
         """
         """
+        path = os.fspath(path)
+        if isinstance(path, bytes):
+            path = os.fsdecode(path)
         if mkdir:
         if mkdir:
             os.mkdir(path)
             os.mkdir(path)
         if identifier is None:
         if identifier is None:
@@ -1838,7 +1859,7 @@ class Repo(BaseRepo):
     @classmethod
     @classmethod
     def init_bare(
     def init_bare(
         cls,
         cls,
-        path,
+        path: Union[str, bytes, os.PathLike],
         *,
         *,
         mkdir=False,
         mkdir=False,
         object_store=None,
         object_store=None,
@@ -1855,6 +1876,9 @@ class Repo(BaseRepo):
           format: Repository format version (defaults to 0)
           format: Repository format version (defaults to 0)
         Returns: a `Repo` instance
         Returns: a `Repo` instance
         """
         """
+        path = os.fspath(path)
+        if isinstance(path, bytes):
+            path = os.fsdecode(path)
         if mkdir:
         if mkdir:
             os.mkdir(path)
             os.mkdir(path)
         return cls._init_maybe_bare(
         return cls._init_maybe_bare(

+ 41 - 0
tests/test_config.py

@@ -316,6 +316,47 @@ who\"
             cf2.get((b"remote", b"origin"), b"fetch"),
             cf2.get((b"remote", b"origin"), b"fetch"),
         )
         )
 
 
+    def test_from_path_pathlib(self) -> None:
+        import tempfile
+        from pathlib import Path
+
+        # Create a temporary config file
+        with tempfile.NamedTemporaryFile(mode="w", suffix=".config", delete=False) as f:
+            f.write("[core]\n    filemode = true\n")
+            temp_path = f.name
+
+        try:
+            # Test with pathlib.Path
+            path_obj = Path(temp_path)
+            cf = ConfigFile.from_path(path_obj)
+            self.assertEqual(cf.get((b"core",), b"filemode"), b"true")
+        finally:
+            # Clean up
+            os.unlink(temp_path)
+
+    def test_write_to_path_pathlib(self) -> None:
+        import tempfile
+        from pathlib import Path
+
+        # Create a config
+        cf = ConfigFile()
+        cf.set((b"user",), b"name", b"Test User")
+
+        # Write to pathlib.Path
+        with tempfile.NamedTemporaryFile(suffix=".config", delete=False) as f:
+            temp_path = f.name
+
+        try:
+            path_obj = Path(temp_path)
+            cf.write_to_path(path_obj)
+
+            # Read it back
+            cf2 = ConfigFile.from_path(path_obj)
+            self.assertEqual(cf2.get((b"user",), b"name"), b"Test User")
+        finally:
+            # Clean up
+            os.unlink(temp_path)
+
 
 
 class ConfigDictTests(TestCase):
 class ConfigDictTests(TestCase):
     def test_get_set(self) -> None:
     def test_get_set(self) -> None:

+ 24 - 0
tests/test_ignore.py

@@ -193,6 +193,30 @@ class IgnoreFilterTests(TestCase):
         self.assertTrue(filter.is_ignored("foo"))
         self.assertTrue(filter.is_ignored("foo"))
         self.assertTrue(filter.is_ignored("foo[bar]"))
         self.assertTrue(filter.is_ignored("foo[bar]"))
 
 
+    def test_from_path_pathlib(self) -> None:
+        import tempfile
+        from pathlib import Path
+
+        # Create a temporary .gitignore file
+        with tempfile.NamedTemporaryFile(
+            mode="w", suffix=".gitignore", delete=False
+        ) as f:
+            f.write("*.pyc\n__pycache__/\n")
+            temp_path = f.name
+
+        try:
+            # Test with pathlib.Path
+            path_obj = Path(temp_path)
+            ignore_filter = IgnoreFilter.from_path(path_obj)
+
+            # Test that it loaded the patterns correctly
+            self.assertTrue(ignore_filter.is_ignored("test.pyc"))
+            self.assertTrue(ignore_filter.is_ignored("__pycache__/"))
+            self.assertFalse(ignore_filter.is_ignored("test.py"))
+        finally:
+            # Clean up
+            os.unlink(temp_path)
+
 
 
 class IgnoreFilterStackTests(TestCase):
 class IgnoreFilterStackTests(TestCase):
     def test_stack_first(self) -> None:
     def test_stack_first(self) -> None:

+ 35 - 0
tests/test_index.py

@@ -127,6 +127,41 @@ class SimpleIndexTestCase(IndexTestCase):
         self.assertEqual(b"bla", newname)
         self.assertEqual(b"bla", newname)
         self.assertEqual(b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", newsha)
         self.assertEqual(b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", newsha)
 
 
+    def test_index_pathlib(self) -> None:
+        import tempfile
+        from pathlib import Path
+
+        # Create a temporary index file
+        with tempfile.NamedTemporaryFile(suffix=".index", delete=False) as f:
+            temp_path = f.name
+
+        try:
+            # Test creating Index with pathlib.Path
+            path_obj = Path(temp_path)
+            index = Index(path_obj, read=False)
+            self.assertEqual(str(path_obj), index.path)
+
+            # Add an entry and write
+            index[b"test"] = IndexEntry(
+                ctime=(0, 0),
+                mtime=(0, 0),
+                dev=0,
+                ino=0,
+                mode=33188,
+                uid=0,
+                gid=0,
+                size=0,
+                sha=b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
+            )
+            index.write()
+
+            # Read it back with pathlib.Path
+            index2 = Index(path_obj)
+            self.assertIn(b"test", index2)
+        finally:
+            # Clean up
+            os.unlink(temp_path)
+
 
 
 class SimpleIndexWriterTestCase(IndexTestCase):
 class SimpleIndexWriterTestCase(IndexTestCase):
     def setUp(self) -> None:
     def setUp(self) -> None:

+ 46 - 0
tests/test_porcelain.py

@@ -883,6 +883,28 @@ class CloneTests(PorcelainTestCase):
         ) as r:
         ) as r:
             self.assertEqual(c3.id, r.refs[b"HEAD"])
             self.assertEqual(c3.id, r.refs[b"HEAD"])
 
 
+    def test_clone_pathlib(self) -> None:
+        from pathlib import Path
+
+        f1_1 = make_object(Blob, data=b"f1")
+        commit_spec = [[1]]
+        trees = {1: [(b"f1", f1_1)]}
+
+        c1 = build_commit_graph(self.repo.object_store, commit_spec, trees)[0]
+        self.repo.refs[b"refs/heads/master"] = c1.id
+
+        target_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, target_dir)
+        target_path = Path(target_dir) / "clone_repo"
+
+        errstream = BytesIO()
+        r = porcelain.clone(
+            self.repo.path, target_path, checkout=False, errstream=errstream
+        )
+        self.addCleanup(r.close)
+        self.assertEqual(r.path, str(target_path))
+        self.assertTrue(os.path.exists(str(target_path)))
+
 
 
 class InitTests(TestCase):
 class InitTests(TestCase):
     def test_non_bare(self) -> None:
     def test_non_bare(self) -> None:
@@ -895,6 +917,30 @@ class InitTests(TestCase):
         self.addCleanup(shutil.rmtree, repo_dir)
         self.addCleanup(shutil.rmtree, repo_dir)
         porcelain.init(repo_dir, bare=True)
         porcelain.init(repo_dir, bare=True)
 
 
+    def test_init_pathlib(self) -> None:
+        from pathlib import Path
+
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        repo_path = Path(repo_dir)
+
+        # Test non-bare repo with pathlib
+        repo = porcelain.init(repo_path)
+        self.assertTrue(os.path.exists(os.path.join(repo_dir, ".git")))
+        repo.close()
+
+    def test_init_bare_pathlib(self) -> None:
+        from pathlib import Path
+
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        repo_path = Path(repo_dir)
+
+        # Test bare repo with pathlib
+        repo = porcelain.init(repo_path, bare=True)
+        self.assertTrue(os.path.exists(os.path.join(repo_dir, "refs")))
+        repo.close()
+
 
 
 class AddTests(PorcelainTestCase):
 class AddTests(PorcelainTestCase):
     def test_add_default_paths(self) -> None:
     def test_add_default_paths(self) -> None:

+ 43 - 0
tests/test_refs.py

@@ -757,6 +757,49 @@ _TEST_REFS_SERIALIZED = (
 )
 )
 
 
 
 
+class DiskRefsContainerPathlibTests(TestCase):
+    def test_pathlib_init(self) -> None:
+        from pathlib import Path
+
+        from dulwich.refs import DiskRefsContainer
+
+        # Create a temporary directory
+        temp_dir = tempfile.mkdtemp()
+        self.addCleanup(os.rmdir, temp_dir)
+
+        # Test with pathlib.Path
+        path_obj = Path(temp_dir)
+        refs = DiskRefsContainer(path_obj)
+        self.assertEqual(refs.path, temp_dir.encode())
+
+        # Test refpath with pathlib initialized container
+        ref_path = refs.refpath(b"HEAD")
+        self.assertTrue(isinstance(ref_path, bytes))
+        self.assertEqual(ref_path, os.path.join(temp_dir.encode(), b"HEAD"))
+
+    def test_pathlib_worktree_path(self) -> None:
+        from pathlib import Path
+
+        from dulwich.refs import DiskRefsContainer
+
+        # Create temporary directories
+        temp_dir = tempfile.mkdtemp()
+        worktree_dir = tempfile.mkdtemp()
+        self.addCleanup(os.rmdir, temp_dir)
+        self.addCleanup(os.rmdir, worktree_dir)
+
+        # Test with pathlib.Path for both paths
+        path_obj = Path(temp_dir)
+        worktree_obj = Path(worktree_dir)
+        refs = DiskRefsContainer(path_obj, worktree_path=worktree_obj)
+        self.assertEqual(refs.path, temp_dir.encode())
+        self.assertEqual(refs.worktree_path, worktree_dir.encode())
+
+        # Test refpath returns worktree path for HEAD
+        ref_path = refs.refpath(b"HEAD")
+        self.assertEqual(ref_path, os.path.join(worktree_dir.encode(), b"HEAD"))
+
+
 class InfoRefsContainerTests(TestCase):
 class InfoRefsContainerTests(TestCase):
     def test_invalid_refname(self) -> None:
     def test_invalid_refname(self) -> None:
         text = _TEST_REFS_SERIALIZED + b"00" * 20 + b"\trefs/stash\n"
         text = _TEST_REFS_SERIALIZED + b"00" * 20 + b"\trefs/stash\n"

+ 49 - 0
tests/test_repository.py

@@ -122,6 +122,55 @@ class CreateRepositoryTests(TestCase):
         self.assertEqual(target_dir, repo._controldir)
         self.assertEqual(target_dir, repo._controldir)
         self._check_repo_contents(repo, True)
         self._check_repo_contents(repo, True)
 
 
+    def test_create_disk_bare_pathlib(self) -> None:
+        from pathlib import Path
+
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+        repo_path = Path(tmp_dir)
+        repo = Repo.init_bare(repo_path)
+        self.assertEqual(tmp_dir, repo._controldir)
+        self._check_repo_contents(repo, True)
+        # Test that refpath works with pathlib
+        ref_path = repo.refs.refpath(b"refs/heads/master")
+        self.assertTrue(isinstance(ref_path, bytes))
+        self.assertEqual(ref_path, os.path.join(tmp_dir.encode(), b"refs/heads/master"))
+
+    def test_create_disk_non_bare_pathlib(self) -> None:
+        from pathlib import Path
+
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+        repo_path = Path(tmp_dir)
+        repo = Repo.init(repo_path)
+        self.assertEqual(os.path.join(tmp_dir, ".git"), repo._controldir)
+        self._check_repo_contents(repo, False)
+
+    def test_open_repo_pathlib(self) -> None:
+        from pathlib import Path
+
+        tmp_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+        # First create a repo
+        repo = Repo.init_bare(tmp_dir)
+        repo.close()
+        # Now open it with pathlib
+        repo_path = Path(tmp_dir)
+        repo2 = Repo(repo_path)
+        self.assertEqual(tmp_dir, repo2._controldir)
+        self.assertTrue(repo2.bare)
+        repo2.close()
+
+    def test_create_disk_bare_mkdir_pathlib(self) -> None:
+        from pathlib import Path
+
+        tmp_dir = tempfile.mkdtemp()
+        target_path = Path(tmp_dir) / "target"
+        self.addCleanup(shutil.rmtree, tmp_dir)
+        repo = Repo.init_bare(target_path, mkdir=True)
+        self.assertEqual(str(target_path), repo._controldir)
+        self._check_repo_contents(repo, True)
+
 
 
 class MemoryRepoTests(TestCase):
 class MemoryRepoTests(TestCase):
     def test_set_description(self) -> None:
     def test_set_description(self) -> None: