Commit Diff


commit - e13cc98a4f8ca8afc857730719164e4608c28d3f
commit + 467a9f9e6cf87869b5f547253ba5148b1288b4b0
blob - c68de5a472fda8cf8f731d472dae6ec625d41c94
blob + 54939dc83a90ef3b2982fae4ffb4984afafb2ec3
--- .github/workflows/pythonpackage.yml
+++ .github/workflows/pythonpackage.yml
@@ -13,7 +13,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: [3.5, 3.6, 3.7, 3.8, 3.9, 3.10-dev, pypy3]
+        python-version: [3.6, 3.7, 3.8, 3.9, 3.10-dev, pypy3]
         exclude:
           # sqlite3 exit handling seems to get in the way
           - os: macos-latest
@@ -21,12 +21,6 @@ jobs:
           # doesn't support passing in bytestrings to os.scandir
           - os: windows-latest
             python-version: pypy3
-          # path encoding
-          - os: windows-latest
-            python-version: 3.5
-          # path encoding
-          - os: macos-latest
-            python-version: 3.5
       fail-fast: false
 
     steps:
blob - 20268fb77742a5f07eb15fc5fc2e5787cdb057fc
blob + faa4e95a3189e74e3468469d798ea3af5ff936ad
--- .github/workflows/pythonpublish.yml
+++ .github/workflows/pythonpublish.yml
@@ -12,16 +12,11 @@ jobs:
     strategy:
       matrix:
         os: [macos-latest, windows-latest]
-        python-version: ['3.5', '3.6', '3.7', '3.8', '3.9']
+        python-version: ['3.6', '3.7', '3.8', '3.9', '3.10']
         include:
           - os: ubuntu-latest
             python-version: '3.x'
           # path encoding
-        exclude:
-          - os: windows-latest
-            python-version: 3.5
-          - os: macos-latest
-            python-version: 3.5
       fail-fast: false
 
     steps:
@@ -56,12 +51,12 @@ jobs:
     - name: Build and publish (Linux aarch64)
       uses: RalfG/python-wheels-manylinux-build@v0.3.3-manylinux2014_aarch64
       with:
-        python-versions: 'cp36-cp36m cp37-cp37m cp38-cp38 cp39-cp39'
+        python-versions: 'cp36-cp36m cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310'
       if: "matrix.os == 'ubuntu-latest'"
     - name: Build and publish (Linux)
       uses: RalfG/python-wheels-manylinux-build@v0.3.1
       with:
-        python-versions: 'cp36-cp36m cp37-cp37m cp38-cp38 cp39-cp39'
+        python-versions: 'cp36-cp36m cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310'
       env:
         # Temporary fix for LD_LIBRARY_PATH issue. See
         # https://github.com/RalfG/python-wheels-manylinux-build/issues/26
blob - 953bbaf1c3d45903f5987567909163d9d9385983
blob + 29a4749f6eb7e1cfed1b60a5d90cd021e9fd10d1
--- NEWS
+++ NEWS
@@ -1,3 +1,15 @@
+0.20.26	2021-10-29
+
+ * Support os.PathLike arguments to Repo.stage().
+   (Jan Wiśniewski, #907)
+
+ * Drop support for Python 3.5.  (Jelmer Vernooij)
+
+ * Add ``dulwich.porcelain._reset_file``.
+   (Ded_Secer)
+
+ * Add ``Repo.unstage``. (Ded_Secer)
+
 0.20.25	2021-08-23
 
  * Fix ``dulwich`` script when installed via setup.py.
blob - f42454ceef107f4fb2a83f9dfedde4aad6197efb
blob + 4433a294d4ae9d8cc99984c45c134ee665cfa451
--- PKG-INFO
+++ PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: dulwich
-Version: 0.20.25
+Version: 0.20.26
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij
@@ -13,17 +13,17 @@ Keywords: git vcs
 Platform: UNKNOWN
 Classifier: Development Status :: 4 - Beta
 Classifier: License :: OSI Approved :: Apache Software License
-Classifier: Programming Language :: Python :: 3.5
 Classifier: Programming Language :: Python :: 3.6
 Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3.8
 Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
 Classifier: Programming Language :: Python :: Implementation :: CPython
 Classifier: Programming Language :: Python :: Implementation :: PyPy
 Classifier: Operating System :: POSIX
 Classifier: Operating System :: Microsoft :: Windows
 Classifier: Topic :: Software Development :: Version Control
-Requires-Python: >=3.5
+Requires-Python: >=3.6
 Provides-Extra: fastimport
 Provides-Extra: https
 Provides-Extra: pgp
@@ -121,10 +121,7 @@ file and `list of open issues <https://github.com/dulw
 Supported versions of Python
 ----------------------------
 
-At the moment, Dulwich supports (and is tested on) CPython 3.5 and later and
+At the moment, Dulwich supports (and is tested on) CPython 3.6 and later and
 Pypy.
 
-The latest release series to support Python 2.x was the 0.19 series. See
-the 0.19 branch in the Dulwich git repository.
 
-
blob - 80e693b349c7270055ada08a32da942c722d9034
blob + 8f4f0bab3b99458cfe1f6cfd991be82ceb5c38c7
--- README.rst
+++ README.rst
@@ -88,8 +88,5 @@ file and `list of open issues <https://github.com/dulw
 Supported versions of Python
 ----------------------------
 
-At the moment, Dulwich supports (and is tested on) CPython 3.5 and later and
+At the moment, Dulwich supports (and is tested on) CPython 3.6 and later and
 Pypy.
-
-The latest release series to support Python 2.x was the 0.19 series. See
-the 0.19 branch in the Dulwich git repository.
blob - 17705982feb817acf7ec992a221782feac8d18e6
blob + 888b48f0605589b966ee4e903a226ba8c5b54014
--- dulwich/__init__.py
+++ dulwich/__init__.py
@@ -22,4 +22,4 @@
 
 """Python implementation of the Git file formats and protocols."""
 
-__version__ = (0, 20, 25)
+__version__ = (0, 20, 26)
blob - 2527853c78580ce737d656858002f15513a3bc57
blob + 03e94f5d8c74c7e64b01a25526f9ac734615c965
--- dulwich/client.py
+++ dulwich/client.py
@@ -43,6 +43,7 @@ from io import BytesIO, BufferedReader
 import logging
 import os
 import select
+import shlex
 import socket
 import subprocess
 import sys
@@ -1467,6 +1468,7 @@ class SSHVendor(object):
         port=None,
         password=None,
         key_filename=None,
+        ssh_command=None,
     ):
         """Connect to an SSH server.
 
@@ -1480,6 +1482,7 @@ class SSHVendor(object):
           port: Optional SSH port to use
           password: Optional ssh password for login or private key
           key_filename: Optional path to private keyfile
+          ssh_command: Optional SSH command
 
         Returns:
 
@@ -1505,6 +1508,7 @@ class SubprocessSSHVendor(SSHVendor):
         port=None,
         password=None,
         key_filename=None,
+        ssh_command=None,
     ):
 
         if password is not None:
@@ -1512,7 +1516,10 @@ class SubprocessSSHVendor(SSHVendor):
                 "Setting password not supported by SubprocessSSHVendor."
             )
 
-        args = ["ssh", "-x"]
+        if ssh_command:
+            args = shlex.split(ssh_command) + ["-x"]
+        else:
+            args = ["ssh", "-x"]
 
         if port:
             args.extend(["-p", str(port)])
@@ -1547,9 +1554,12 @@ class PLinkSSHVendor(SSHVendor):
         port=None,
         password=None,
         key_filename=None,
+        ssh_command=None,
     ):
 
-        if sys.platform == "win32":
+        if ssh_command:
+            args = shlex.split(ssh_command) + ["-ssh"]
+        elif sys.platform == "win32":
             args = ["plink.exe", "-ssh"]
         else:
             args = ["plink", "-ssh"]
@@ -1611,6 +1621,7 @@ class SSHGitClient(TraditionalGitClient):
         config=None,
         password=None,
         key_filename=None,
+        ssh_command=None,
         **kwargs
     ):
         self.host = host
@@ -1618,6 +1629,9 @@ class SSHGitClient(TraditionalGitClient):
         self.username = username
         self.password = password
         self.key_filename = key_filename
+        self.ssh_command = ssh_command or os.environ.get(
+            "GIT_SSH_COMMAND", os.environ.get("GIT_SSH")
+        )
         super(SSHGitClient, self).__init__(**kwargs)
         self.alternative_paths = {}
         if vendor is not None:
@@ -1667,6 +1681,9 @@ class SSHGitClient(TraditionalGitClient):
             kwargs["password"] = self.password
         if self.key_filename is not None:
             kwargs["key_filename"] = self.key_filename
+        # GIT_SSH_COMMAND takes precendence over GIT_SSH
+        if self.ssh_command is not None:
+            kwargs["ssh_command"] = self.ssh_command
         con = self.ssh_vendor.run_command(
             self.host, argv, port=self.port, username=self.username, **kwargs
         )
blob - 8692843a13c3d11538e9bcf4e219a9e2b884a3c7
blob + 541d99b5e48559077b79173832a51595c66cd62f
--- dulwich/contrib/paramiko_vendor.py
+++ dulwich/contrib/paramiko_vendor.py
@@ -44,7 +44,7 @@ class _ParamikoWrapper(object):
 
     @property
     def stderr(self):
-        return self.channel.makefile_stderr()
+        return self.channel.makefile_stderr('rb')
 
     def can_read(self):
         return self.channel.recv_ready()
blob - 54022b05c69bd9505d32b6f2989249fb7a23702a
blob + 9a7a872778d8e95a6ad5db4d9fc6518af3533645
--- dulwich/errors.py
+++ dulwich/errors.py
@@ -21,6 +21,11 @@
 
 """Dulwich-related exception classes and utility functions."""
 
+
+# Please do not add more errors here, but instead add them close to the code
+# that raises the error.
+
+
 import binascii
 
 
blob - a927a6a359f52c7188b6e7f2c4b637a9e23b6f50
blob + 85de5f098d2cb3b9b1a346c45fd812372f2096d7
--- dulwich/hooks.py
+++ dulwich/hooks.py
@@ -193,12 +193,12 @@ class PostReceiveShellHook(ShellHook):
             )
 
             # client_refs is a list of (oldsha, newsha, ref)
-            in_data = "\n".join([" ".join(ref) for ref in client_refs])
+            in_data = b"\n".join([b" ".join(ref) for ref in client_refs])
 
             out_data, err_data = p.communicate(in_data)
 
             if (p.returncode != 0) or err_data:
-                err_fmt = "post-receive exit code: %d\n" + "stdout:\n%s\nstderr:\n%s"
+                err_fmt = b"post-receive exit code: %d\n" + b"stdout:\n%s\nstderr:\n%s"
                 err_msg = err_fmt % (p.returncode, out_data, err_data)
                 raise HookError(err_msg.decode('utf-8', 'backslashreplace'))
             return out_data
blob - 49d115183077d92f97888cf459f888da8724964f
blob + 5dbbaffbeda8e8cccfb1edefcde3273c583e9a9f
--- dulwich/objectspec.py
+++ dulwich/objectspec.py
@@ -54,6 +54,10 @@ def parse_tree(repo, treeish):
       KeyError: If the object can not be found
     """
     treeish = to_bytes(treeish)
+    try:
+        treeish = parse_ref(repo, treeish)
+    except KeyError:  # treeish is commit sha
+        pass
     o = repo[treeish]
     if o.type_name == b"commit":
         return repo[o.tree]
blob - 92e3f35868beed08666f617b34a469775e64d807
blob + 9b76a1d0c91629092479f242c1c2409c5a3276e0
--- dulwich/pack.py
+++ dulwich/pack.py
@@ -1439,7 +1439,8 @@ class DeltaChainIterator(object):
         # Unlike PackData.get_object_at, there is no need to cache offsets as
         # this approach by design inflates each object exactly once.
         todo = [(offset, obj_type_num, base_chunks)]
-        for offset, obj_type_num, base_chunks in todo:
+        while todo:
+            (offset, obj_type_num, base_chunks) = todo.pop()
             unpacked = self._resolve_object(offset, obj_type_num, base_chunks)
             yield self._result(unpacked)
 
blob - e3cf42621f37b8972edb77a7ab332d747c21ae62
blob + 0749f4449fc9b4353974b45f1e15004fd731402c
--- dulwich/porcelain.py
+++ dulwich/porcelain.py
@@ -53,6 +53,10 @@ Currently implemented:
 These functions are meant to behave similarly to the git subcommands.
 Differences in behaviour are considered bugs.
 
+Note: one of the consequences of this is that paths tend to be
+interpreted relative to the current working directory rather than relative
+to the repository root.
+
 Functions should generally accept both unicode strings and bytestrings
 """
 
@@ -104,6 +108,8 @@ from dulwich.ignore import IgnoreFilterManager
 from dulwich.index import (
     blob_from_path_and_stat,
     get_unstaged_changes,
+    build_file_from_blob,
+    _fs_to_tree_path,
 )
 from dulwich.object_store import (
     tree_lookup_path,
@@ -218,52 +224,36 @@ def path_to_tree_path(repopath, path, tree_encoding=DE
       path: A path, absolute or relative to the cwd
     Returns: A path formatted for use in e.g. an index
     """
-    # Pathlib resolve before Python 3.6 could raises FileNotFoundError in case
-    # there is no file matching the path so we reuse the old implementation for
-    # Python 3.5
-    if sys.version_info < (3, 6):
-        if not isinstance(path, bytes):
-            path = os.fsencode(path)
-        if not isinstance(repopath, bytes):
-            repopath = os.fsencode(repopath)
-        treepath = os.path.relpath(path, repopath)
-        if treepath.startswith(b".."):
-            err_msg = "Path %r not in repo path (%r)" % (path, repopath)
-            raise ValueError(err_msg)
-        if os.path.sep != "/":
-            treepath = treepath.replace(os.path.sep.encode("ascii"), b"/")
-        return treepath
-    else:
-        # Resolve might returns a relative path on Windows
-        # https://bugs.python.org/issue38671
-        if sys.platform == "win32":
-            path = os.path.abspath(path)
+    # Resolve might returns a relative path on Windows
+    # https://bugs.python.org/issue38671
+    if sys.platform == "win32":
+        path = os.path.abspath(path)
 
-        path = Path(path)
-        resolved_path = path.resolve()
+    path = Path(path)
+    resolved_path = path.resolve()
 
-        # Resolve and abspath seems to behave differently regarding symlinks,
-        # as we are doing abspath on the file path, we need to do the same on
-        # the repo path or they might not match
-        if sys.platform == "win32":
-            repopath = os.path.abspath(repopath)
+    # Resolve and abspath seems to behave differently regarding symlinks,
+    # as we are doing abspath on the file path, we need to do the same on
+    # the repo path or they might not match
+    if sys.platform == "win32":
+        repopath = os.path.abspath(repopath)
 
-        repopath = Path(repopath).resolve()
+    repopath = Path(repopath).resolve()
 
-        try:
-            relpath = resolved_path.relative_to(repopath)
-        except ValueError:
-            # If path is a symlink that points to a file outside the repo, we
-            # want the relpath for the link itself, not the resolved target
-            if path.is_symlink():
-                parent = path.parent.resolve()
-                relpath = (parent / path.name).relative_to(repopath)
-            else:
-                raise
-        if sys.platform == "win32":
-            return str(relpath).replace(os.path.sep, "/").encode(tree_encoding)
+    try:
+        relpath = resolved_path.relative_to(repopath)
+    except ValueError:
+        # If path is a symlink that points to a file outside the repo, we
+        # want the relpath for the link itself, not the resolved target
+        if path.is_symlink():
+            parent = path.parent.resolve()
+            relpath = (parent / path.name).relative_to(repopath)
         else:
-            return bytes(relpath)
+            raise
+    if sys.platform == "win32":
+        return str(relpath).replace(os.path.sep, "/").encode(tree_encoding)
+    else:
+        return bytes(relpath)
 
 
 class DivergedBranches(Error):
@@ -1752,7 +1742,25 @@ def update_head(repo, target, detached=False, new_bran
         if new_branch is not None:
             r.refs.set_symbolic_ref(b"HEAD", to_set)
 
+
+def reset_file(repo, file_path: str, target: bytes = b'HEAD'):
+    """Reset the file to specific commit or branch.
 
+    Args:
+      repo: dulwich Repo object
+      file_path: file to reset, relative to the repository path
+      target: branch or commit or b'HEAD' to reset
+    """
+    tree = parse_tree(repo, treeish=target)
+    file_path = _fs_to_tree_path(file_path)
+
+    file_entry = tree.lookup_path(repo.object_store.__getitem__, file_path)
+    full_path = os.path.join(repo.path.encode(), file_path)
+    blob = repo.object_store[file_entry[1]]
+    mode = file_entry[0]
+    build_file_from_blob(blob, mode, full_path)
+
+
 def check_mailmap(repo, contact):
     """Check canonical name and email of contact.
 
blob - 0d34658f1ee8cf20e73ecdb9bd36bad5a269f8ba
blob + 235e71774a5d60d12d4f964ccb185a5fb16d768e
--- dulwich/repo.py
+++ dulwich/repo.py
@@ -1253,7 +1253,7 @@ class Repo(BaseRepo):
         # missing index file, which is treated as empty.
         return not self.bare
 
-    def stage(self, fs_paths):
+    def stage(self, fs_paths: Union[str, bytes, os.PathLike, Iterable[Union[str, bytes, os.PathLike]]]) -> None:
         """Stage a set of paths.
 
         Args:
@@ -1262,7 +1262,7 @@ class Repo(BaseRepo):
 
         root_path_bytes = os.fsencode(self.path)
 
-        if isinstance(fs_paths, str):
+        if isinstance(fs_paths, (str, bytes, os.PathLike)):
             fs_paths = [fs_paths]
         fs_paths = list(fs_paths)
 
@@ -1305,6 +1305,65 @@ class Repo(BaseRepo):
                     index[tree_path] = index_entry_from_stat(st, blob.id, 0)
         index.write()
 
+    def unstage(self, fs_paths: List[str]):
+        """unstage specific file in the index
+        Args:
+          fs_paths: a list of files to unstage,
+            relative to the repository path
+        """
+        from dulwich.index import (
+            IndexEntry,
+            _fs_to_tree_path,
+            )
+
+        index = self.open_index()
+        try:
+            tree_id = self[b'HEAD'].tree
+        except KeyError:
+            # no head mean no commit in the repo
+            for fs_path in fs_paths:
+                tree_path = _fs_to_tree_path(fs_path)
+                del index[tree_path]
+            index.write()
+            return
+
+        for fs_path in fs_paths:
+            tree_path = _fs_to_tree_path(fs_path)
+            try:
+                tree_entry = self.object_store[tree_id].lookup_path(
+                    self.object_store.__getitem__, tree_path)
+            except KeyError:
+                # if tree_entry didnt exist, this file was being added, so
+                # remove index entry
+                try:
+                    del index[tree_path]
+                    continue
+                except KeyError:
+                    raise KeyError("file '%s' not in index" % (tree_path.decode()))
+
+            st = None
+            try:
+                st = os.lstat(os.path.join(self.path, fs_path))
+            except FileNotFoundError:
+                pass
+
+            index_entry = IndexEntry(
+                ctime=(self[b'HEAD'].commit_time, 0),
+                mtime=(self[b'HEAD'].commit_time, 0),
+                dev=st.st_dev if st else 0,
+                ino=st.st_ino if st else 0,
+                mode=tree_entry[0],
+                uid=st.st_uid if st else 0,
+                gid=st.st_gid if st else 0,
+                size=len(self[tree_entry[1]].data),
+                sha=tree_entry[1],
+                flags=0,
+                extended_flags=0
+            )
+
+            index[tree_path] = index_entry
+        index.write()
+
     def clone(
         self,
         target_path,
blob - 84f8fa6cd02939a09946a49ba1244e4e45829c64
blob + db5ed86a9b515476060843c85841e0be0751cc40
--- dulwich/tests/test_client.py
+++ dulwich/tests/test_client.py
@@ -705,6 +705,7 @@ class TestSSHVendor(object):
         port=None,
         password=None,
         key_filename=None,
+        ssh_command=None,
     ):
         self.host = host
         self.command = command
@@ -712,6 +713,7 @@ class TestSSHVendor(object):
         self.port = port
         self.password = password
         self.key_filename = key_filename
+        self.ssh_command = ssh_command
 
         class Subprocess:
             pass
@@ -785,7 +787,22 @@ class SSHGitClientTests(TestCase):
         client._connect(b"relative-command", b"/~/path/to/repo")
         self.assertEqual("git-relative-command '~/path/to/repo'", server.command)
 
+    def test_ssh_command_precedence(self):
+        os.environ["GIT_SSH"] = "/path/to/ssh"
+        test_client = SSHGitClient("git.samba.org")
+        self.assertEqual(test_client.ssh_command, "/path/to/ssh")
 
+        os.environ["GIT_SSH_COMMAND"] = "/path/to/ssh -o Option=Value"
+        test_client = SSHGitClient("git.samba.org")
+        self.assertEqual(test_client.ssh_command, "/path/to/ssh -o Option=Value")
+
+        test_client = SSHGitClient("git.samba.org", ssh_command="ssh -o Option1=Value1")
+        self.assertEqual(test_client.ssh_command, "ssh -o Option1=Value1")
+
+        del os.environ["GIT_SSH"]
+        del os.environ["GIT_SSH_COMMAND"]
+
+
 class ReportStatusParserTests(TestCase):
     def test_invalid_pack(self):
         parser = ReportStatusParser()
@@ -1230,7 +1247,27 @@ class SubprocessSSHVendorTests(TestCase):
 
         self.assertListEqual(expected, args[0])
 
+    def test_run_with_ssh_command(self):
+        expected = [
+            "/path/to/ssh",
+            "-o",
+            "Option=Value",
+            "-x",
+            "host",
+            "git-clone-url",
+        ]
 
+        vendor = SubprocessSSHVendor()
+        command = vendor.run_command(
+            "host",
+            "git-clone-url",
+            ssh_command="/path/to/ssh -o Option=Value",
+        )
+
+        args = command.proc.args
+        self.assertListEqual(expected, args[0])
+
+
 class PLinkSSHVendorTests(TestCase):
     def setUp(self):
         # Monkey Patch client subprocess popen
@@ -1353,7 +1390,25 @@ class PLinkSSHVendorTests(TestCase):
 
         self.assertListEqual(expected, args[0])
 
+    def test_run_with_ssh_command(self):
+        expected = [
+            "/path/to/plink",
+            "-x",
+            "host",
+            "git-clone-url",
+        ]
 
+        vendor = SubprocessSSHVendor()
+        command = vendor.run_command(
+            "host",
+            "git-clone-url",
+            ssh_command="/path/to/plink",
+        )
+
+        args = command.proc.args
+        self.assertListEqual(expected, args[0])
+
+
 class RsyncUrlTests(TestCase):
     def test_simple(self):
         self.assertEqual(parse_rsync_url("foo:bar/path"), (None, "foo", "bar/path"))
blob - 67c6049b82c7c5c86714ec80983fb177a3d824b4
blob + 22340eb6cf86efc545ce1edbce9b3784d5f065e7
--- dulwich/tests/test_objectspec.py
+++ dulwich/tests/test_objectspec.py
@@ -258,3 +258,9 @@ class ParseTreeTests(TestCase):
         c1, c2, c3 = build_commit_graph(r.object_store, [[1], [2, 1], [3, 1, 2]])
         self.assertEqual(r[c1.tree], parse_tree(r, c1.id))
         self.assertEqual(r[c1.tree], parse_tree(r, c1.tree))
+
+    def test_from_ref(self):
+        r = MemoryRepo()
+        c1, c2, c3 = build_commit_graph(r.object_store, [[1], [2, 1], [3, 1, 2]])
+        r.refs[b'refs/heads/foo'] = c1.id
+        self.assertEqual(r[c1.tree], parse_tree(r, b'foo'))
blob - 278259c3b4eb3afb8a8d9170cdf94b9786105bff
blob + 407893e9725ea126bc6c10657861381eb47b1c52
--- dulwich/tests/test_pack.py
+++ dulwich/tests/test_pack.py
@@ -1032,7 +1032,8 @@ class DeltaChainIteratorTests(TestCase):
                 (OFS_DELTA, (0, b"blob2")),
             ],
         )
-        self.assertEntriesMatch([0, 1, 2], entries, self.make_pack_iter(f))
+        # Delta resolution changed to DFS
+        self.assertEntriesMatch([0, 2, 1], entries, self.make_pack_iter(f))
 
     def test_ofs_deltas_chain(self):
         f = BytesIO()
@@ -1056,7 +1057,8 @@ class DeltaChainIteratorTests(TestCase):
                 (REF_DELTA, (1, b"blob2")),
             ],
         )
-        self.assertEntriesMatch([1, 0, 2], entries, self.make_pack_iter(f))
+        # Delta resolution changed to DFS
+        self.assertEntriesMatch([1, 2, 0], entries, self.make_pack_iter(f))
 
     def test_ref_deltas_chain(self):
         f = BytesIO()
@@ -1082,7 +1084,9 @@ class DeltaChainIteratorTests(TestCase):
                 (OFS_DELTA, (1, b"blob2")),
             ],
         )
-        self.assertEntriesMatch([1, 2, 0], entries, self.make_pack_iter(f))
+
+        # Delta resolution changed to DFS
+        self.assertEntriesMatch([1, 0, 2], entries, self.make_pack_iter(f))
 
     def test_mixed_chain(self):
         f = BytesIO()
@@ -1094,9 +1098,9 @@ class DeltaChainIteratorTests(TestCase):
                 (OFS_DELTA, (0, b"blob1")),
                 (OFS_DELTA, (1, b"blob3")),
                 (OFS_DELTA, (0, b"bob")),
-            ],
-        )
-        self.assertEntriesMatch([0, 2, 4, 1, 3], entries, self.make_pack_iter(f))
+            ])
+        # Delta resolution changed to DFS
+        self.assertEntriesMatch([0, 4, 2, 1, 3], entries, self.make_pack_iter(f))
 
     def test_long_chain(self):
         n = 100
@@ -1114,7 +1118,9 @@ class DeltaChainIteratorTests(TestCase):
             objects_spec.append((OFS_DELTA, (0, b"blob" + str(i).encode("ascii"))))
         f = BytesIO()
         entries = build_pack(f, objects_spec)
-        self.assertEntriesMatch(range(n + 1), entries, self.make_pack_iter(f))
+        # Delta resolution changed to DFS
+        indices = [0] + list(range(100, 0, -1))
+        self.assertEntriesMatch(indices, entries, self.make_pack_iter(f))
 
     def test_ext_ref(self):
         (blob,) = self.store_blobs([b"blob"])
blob - 7717701f4009b24b8b2fe7246c08490fa59c9b2c
blob + 0334f192e879227d61a3fe1641117cf9a6e824e1
--- dulwich/tests/test_porcelain.py
+++ dulwich/tests/test_porcelain.py
@@ -35,7 +35,9 @@ from unittest import skipIf
 
 from dulwich import porcelain
 from dulwich.diff_tree import tree_changes
-from dulwich.errors import CommitError
+from dulwich.errors import (
+    CommitError,
+)
 from dulwich.objects import (
     Blob,
     Tag,
@@ -1317,6 +1319,73 @@ class ResetTests(PorcelainTestCase):
         self.assertEqual([], changes)
 
 
+class ResetFileTests(PorcelainTestCase):
+
+    def test_reset_modify_file_to_commit(self):
+        file = 'foo'
+        full_path = os.path.join(self.repo.path, file)
+
+        with open(full_path, 'w') as f:
+            f.write('hello')
+        porcelain.add(self.repo, paths=[full_path])
+        sha = porcelain.commit(
+            self.repo,
+            message=b"unitest",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+        with open(full_path, 'a') as f:
+            f.write('something new')
+        porcelain.reset_file(self.repo, file, target=sha)
+
+        with open(full_path, 'r') as f:
+            self.assertEqual('hello', f.read())
+
+    def test_reset_remove_file_to_commit(self):
+        file = 'foo'
+        full_path = os.path.join(self.repo.path, file)
+
+        with open(full_path, 'w') as f:
+            f.write('hello')
+        porcelain.add(self.repo, paths=[full_path])
+        sha = porcelain.commit(
+            self.repo,
+            message=b"unitest",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+        os.remove(full_path)
+        porcelain.reset_file(self.repo, file, target=sha)
+
+        with open(full_path, 'r') as f:
+            self.assertEqual('hello', f.read())
+
+    def test_resetfile_with_dir(self):
+        os.mkdir(os.path.join(self.repo.path, 'new_dir'))
+        full_path = os.path.join(self.repo.path, 'new_dir', 'foo')
+
+        with open(full_path, 'w') as f:
+            f.write('hello')
+        porcelain.add(self.repo, paths=[full_path])
+        sha = porcelain.commit(
+            self.repo,
+            message=b"unitest",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+        with open(full_path, 'a') as f:
+            f.write('something new')
+        porcelain.commit(
+            self.repo,
+            message=b"unitest 2",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+        porcelain.reset_file(self.repo, os.path.join('new_dir', 'foo'), target=sha)
+        with open(full_path, 'r') as f:
+            self.assertEqual('hello', f.read())
+
+
 class PushTests(PorcelainTestCase):
     def test_simple(self):
         """
blob - 4b75e0431b816f9e47d5ac5f0fb206354d533542
blob + 05034888609904094a31020f069f235f8c6c016a
--- dulwich/tests/test_repository.py
+++ dulwich/tests/test_repository.py
@@ -31,6 +31,7 @@ import tempfile
 import warnings
 
 from dulwich import errors
+from dulwich import porcelain
 from dulwich.object_store import (
     tree_lookup_path,
 )
@@ -1225,7 +1226,88 @@ class BuildRepoRootTests(TestCase):
         os.mkdir(os.path.join(r.path, "c"))
         r.stage(["c"])
         self.assertEqual([b"a"], list(r.open_index()))
+
+    def test_unstage_midify_file_with_dir(self):
+        os.mkdir(os.path.join(self._repo.path, 'new_dir'))
+        full_path = os.path.join(self._repo.path, 'new_dir', 'foo')
+
+        with open(full_path, 'w') as f:
+            f.write('hello')
+        porcelain.add(self._repo, paths=[full_path])
+        porcelain.commit(
+            self._repo,
+            message=b"unitest",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+        with open(full_path, 'a') as f:
+            f.write('something new')
+        self._repo.unstage(['new_dir/foo'])
+        status = list(porcelain.status(self._repo))
+        self.assertEqual([{'add': [], 'delete': [], 'modify': []}, [b'new_dir/foo'], []], status)
+
+    def test_unstage_while_no_commit(self):
+        file = 'foo'
+        full_path = os.path.join(self._repo.path, file)
+        with open(full_path, 'w') as f:
+            f.write('hello')
+        porcelain.add(self._repo, paths=[full_path])
+        self._repo.unstage([file])
+        status = list(porcelain.status(self._repo))
+        self.assertEqual([{'add': [], 'delete': [], 'modify': []}, [], ['foo']], status)
+
+    def test_unstage_add_file(self):
+        file = 'foo'
+        full_path = os.path.join(self._repo.path, file)
+        porcelain.commit(
+            self._repo,
+            message=b"unitest",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+        with open(full_path, 'w') as f:
+            f.write('hello')
+        porcelain.add(self._repo, paths=[full_path])
+        self._repo.unstage([file])
+        status = list(porcelain.status(self._repo))
+        self.assertEqual([{'add': [], 'delete': [], 'modify': []}, [], ['foo']], status)
 
+    def test_unstage_modify_file(self):
+        file = 'foo'
+        full_path = os.path.join(self._repo.path, file)
+        with open(full_path, 'w') as f:
+            f.write('hello')
+        porcelain.add(self._repo, paths=[full_path])
+        porcelain.commit(
+            self._repo,
+            message=b"unitest",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+        with open(full_path, 'a') as f:
+            f.write('broken')
+        porcelain.add(self._repo, paths=[full_path])
+        self._repo.unstage([file])
+        status = list(porcelain.status(self._repo))
+        self.assertEqual([{'add': [], 'delete': [], 'modify': []}, [b'foo'], []], status)
+
+    def test_unstage_remove_file(self):
+        file = 'foo'
+        full_path = os.path.join(self._repo.path, file)
+        with open(full_path, 'w') as f:
+            f.write('hello')
+        porcelain.add(self._repo, paths=[full_path])
+        porcelain.commit(
+            self._repo,
+            message=b"unitest",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+        os.remove(full_path)
+        self._repo.unstage([file])
+        status = list(porcelain.status(self._repo))
+        self.assertEqual([{'add': [], 'delete': [], 'modify': []}, [b'foo'], []], status)
+
     @skipIf(
         sys.platform in ("win32", "darwin"),
         "tries to implicitly decode as utf8",
blob - f42454ceef107f4fb2a83f9dfedde4aad6197efb
blob + 4433a294d4ae9d8cc99984c45c134ee665cfa451
--- dulwich.egg-info/PKG-INFO
+++ dulwich.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: dulwich
-Version: 0.20.25
+Version: 0.20.26
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij
@@ -13,17 +13,17 @@ Keywords: git vcs
 Platform: UNKNOWN
 Classifier: Development Status :: 4 - Beta
 Classifier: License :: OSI Approved :: Apache Software License
-Classifier: Programming Language :: Python :: 3.5
 Classifier: Programming Language :: Python :: 3.6
 Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3.8
 Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
 Classifier: Programming Language :: Python :: Implementation :: CPython
 Classifier: Programming Language :: Python :: Implementation :: PyPy
 Classifier: Operating System :: POSIX
 Classifier: Operating System :: Microsoft :: Windows
 Classifier: Topic :: Software Development :: Version Control
-Requires-Python: >=3.5
+Requires-Python: >=3.6
 Provides-Extra: fastimport
 Provides-Extra: https
 Provides-Extra: pgp
@@ -121,10 +121,7 @@ file and `list of open issues <https://github.com/dulw
 Supported versions of Python
 ----------------------------
 
-At the moment, Dulwich supports (and is tested on) CPython 3.5 and later and
+At the moment, Dulwich supports (and is tested on) CPython 3.6 and later and
 Pypy.
 
-The latest release series to support Python 2.x was the 0.19 series. See
-the 0.19 branch in the Dulwich git repository.
 
-
blob - 8168ef69730e5fec94de224eeac1c970f189fe58
blob + b1b6e5e3283945b58ca84f7e567f5baf3212060b
--- setup.py
+++ setup.py
@@ -17,13 +17,13 @@ import sys
 from typing import Dict, Any
 
 
-if sys.version_info < (3, 5):
+if sys.version_info < (3, 6):
     raise Exception(
-        'Dulwich only supports Python 3.5 and later. '
+        'Dulwich only supports Python 3.6 and later. '
         'For 2.7 support, please install a version prior to 0.20')
 
 
-dulwich_version_string = '0.20.25'
+dulwich_version_string = '0.20.26'
 
 
 class DulwichDistribution(Distribution):
@@ -88,7 +88,7 @@ if has_setuptools:
         "console_scripts": [
             "dulwich=dulwich.cli:main",
         ]}
-    setup_kwargs['python_requires'] = '>=3.5'
+    setup_kwargs['python_requires'] = '>=3.6'
 else:
     scripts.append('bin/dulwich')
 
@@ -121,11 +121,11 @@ setup(name='dulwich',
       classifiers=[
           'Development Status :: 4 - Beta',
           'License :: OSI Approved :: Apache Software License',
-          'Programming Language :: Python :: 3.5',
           'Programming Language :: Python :: 3.6',
           'Programming Language :: Python :: 3.7',
           'Programming Language :: Python :: 3.8',
           'Programming Language :: Python :: 3.9',
+          'Programming Language :: Python :: 3.10',
           'Programming Language :: Python :: Implementation :: CPython',
           'Programming Language :: Python :: Implementation :: PyPy',
           'Operating System :: POSIX',