Commit Diff


commit - 662b2117d16044d0c348a48e316229a2da046adf
commit + 0078a59e44b767f958279733ef22bc7003d623e1
blob - /dev/null
blob + 7ba030843e34b0f252f25fd96db5b09f75882c1a (mode 644)
--- /dev/null
+++ .github/FUNDING.yml
@@ -0,0 +1 @@
+github: jelmer
blob - 41df77cb18f9fe41ee34b2ea91f62eb998ccbe15
blob + c51ea2312778272fecff47edc8015963042d288b
--- NEWS
+++ NEWS
@@ -1,3 +1,25 @@
+0.20.30	2022-01-08
+
+0.20.29	2022-01-08
+
+ * Support staging submodules.
+   (Jelmer Vernooij)
+
+ * Drop deprecated Index.iterblobs and iter_fresh_blobs.
+   (Jelmer Vernooij)
+
+ * Unify clone behaviour of ``Repo.clone`` and
+   ``porcelain.clone``, and add branch parameter for
+   clone. (Peter Rowlands, #851)
+
+0.20.28	2022-01-05
+
+ * Fix hook test on Mac OSX / Linux when dulwich is
+   not installed system-wide. (Jelmer Vernooij, #919)
+
+ * Cope with gecos being unset.
+   (Jelmer Vernooij, #917)
+
 0.20.27	2022-01-04
 
  * Allow adding files to repository in pre-commit hook.
blob - 3f3f472b7348f4aa816b6ebe9616187914f8c6b9
blob + fc2a42b75edd1cae3f05952f180f61368145a86c
--- PKG-INFO
+++ PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: dulwich
-Version: 0.20.27
+Version: 0.20.30
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij
blob - cb4d90567e8c0bd5de88570241991fe0bf84be73
blob + b6d263798a69926bced09fe3528edf516a10fed5
--- dulwich/__init__.py
+++ dulwich/__init__.py
@@ -22,4 +22,4 @@
 
 """Python implementation of the Git file formats and protocols."""
 
-__version__ = (0, 20, 27)
+__version__ = (0, 20, 30)
blob - 700b2d4eb1dcc569354df45d0c7f0a374dd9b077
blob + 2c5a1735a5a870714373032bc92e38bb63c090fd
--- dulwich/index.py
+++ dulwich/index.py
@@ -378,12 +378,6 @@ class Index(object):
         for path in self:
             entry = self[path]
             yield path, entry.sha, cleanup_mode(entry.mode)
-
-    def iterblobs(self):
-        import warnings
-
-        warnings.warn("Use iterobjects() instead.", PendingDeprecationWarning)
-        return self.iterobjects()
 
     def clear(self):
         """Remove all contents from this index."""
@@ -876,6 +870,15 @@ def _fs_to_tree_path(fs_path):
     else:
         tree_path = fs_path_bytes
     return tree_path
+
+
+def index_entry_from_directory(st, path):
+    if os.path.exists(os.path.join(path, b".git")):
+        head = read_submodule_head(path)
+        if head is None:
+            return None
+        return index_entry_from_stat(st, head, 0, mode=S_IFGITLINK)
+    return None
 
 
 def index_entry_from_path(path, object_store=None):
@@ -894,12 +897,7 @@ def index_entry_from_path(path, object_store=None):
     assert isinstance(path, bytes)
     st = os.lstat(path)
     if stat.S_ISDIR(st.st_mode):
-        if os.path.exists(os.path.join(path, b".git")):
-            head = read_submodule_head(path)
-            if head is None:
-                return None
-            return index_entry_from_stat(st, head, 0, mode=S_IFGITLINK)
-        return None
+        return index_entry_from_directory(st, path)
 
     if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode):
         blob = blob_from_path_and_stat(path, st)
@@ -930,28 +928,6 @@ def iter_fresh_entries(
         yield path, entry
 
 
-def iter_fresh_blobs(index, root_path):
-    """Iterate over versions of blobs on disk referenced by index.
-
-    Don't use this function; it removes missing entries from index.
-
-    Args:
-      index: Index file
-      root_path: Root path to access from
-      include_deleted: Include deleted entries with sha and
-        mode set to None
-    Returns: Iterator over path, sha, mode
-    """
-    import warnings
-
-    warnings.warn(PendingDeprecationWarning, "Use iter_fresh_objects instead.")
-    for entry in iter_fresh_objects(index, root_path, include_deleted=True):
-        if entry[1] is None:
-            del index[entry[0]]
-        else:
-            yield entry
-
-
 def iter_fresh_objects(paths, root_path, include_deleted=False, object_store=None):
     """Iterate over versions of objecs on disk referenced by index.
 
blob - 0749f4449fc9b4353974b45f1e15004fd731402c
blob + 8d4ab0eb4f9731223253ae61659977f5a5308aac
--- dulwich/porcelain.py
+++ dulwich/porcelain.py
@@ -70,7 +70,6 @@ import datetime
 import os
 from pathlib import Path
 import posixpath
-import shutil
 import stat
 import sys
 import time
@@ -140,6 +139,7 @@ from dulwich.protocol import (
 from dulwich.refs import (
     ANNOTATED_TAG_SUFFIX,
     LOCAL_BRANCH_PREFIX,
+    LOCAL_TAG_PREFIX,
     strip_peeled_refs,
     RefsContainer,
 )
@@ -403,6 +403,7 @@ def clone(
     outstream=None,
     origin=b"origin",
     depth=None,
+    branch=None,
     **kwargs
 ):
     """Clone a local or remote git repository.
@@ -416,9 +417,10 @@ def clone(
       outstream: Optional stream to write progress to (deprecated)
       origin: Name of remote from the repository used to clone
       depth: Depth to fetch at
+      branch: Optional branch or tag to be used as HEAD in the new repository
+        instead of the cloned repository's HEAD.
     Returns: The new repository
     """
-    # TODO(jelmer): This code overlaps quite a bit with Repo.clone
     if outstream is not None:
         import warnings
 
@@ -427,7 +429,7 @@ def clone(
             DeprecationWarning,
             stacklevel=3,
         )
-        errstream = outstream
+        # TODO(jelmer): Capture logging output and stream to errstream
 
     if checkout is None:
         checkout = not bare
@@ -437,51 +439,17 @@ def clone(
     if target is None:
         target = source.split("/")[-1]
 
-    if not os.path.exists(target):
-        os.mkdir(target)
+    mkdir = not os.path.exists(target)
 
-    if bare:
-        r = Repo.init_bare(target)
-    else:
-        r = Repo.init(target)
-
-    reflog_message = b"clone: from " + source.encode("utf-8")
-    try:
-        target_config = r.get_config()
-        if not isinstance(source, bytes):
-            source = source.encode(DEFAULT_ENCODING)
-        target_config.set((b"remote", origin), b"url", source)
-        target_config.set(
-            (b"remote", origin),
-            b"fetch",
-            b"+refs/heads/*:refs/remotes/" + origin + b"/*",
-        )
-        target_config.write_to_path()
-        fetch_result = fetch(
-            r,
-            origin,
-            errstream=errstream,
-            message=reflog_message,
-            depth=depth,
-            **kwargs
+    with open_repo_closing(source) as r:
+        return r.clone(
+            target,
+            mkdir=mkdir,
+            bare=bare,
+            origin=origin,
+            checkout=checkout,
+            branch=branch,
         )
-        for key, target in fetch_result.symrefs.items():
-            r.refs.set_symbolic_ref(key, target)
-        try:
-            head = r[fetch_result.refs[b"HEAD"]]
-        except KeyError:
-            head = None
-        else:
-            r[b"HEAD"] = head.id
-        if checkout and not bare and head is not None:
-            errstream.write(b"Checking out " + head.id + b"\n")
-            r.reset_index(head.tree)
-    except BaseException:
-        shutil.rmtree(target)
-        r.close()
-        raise
-
-    return r
 
 
 def add(repo=".", paths=None):
@@ -1430,7 +1398,7 @@ def _make_branch_ref(name):
 def _make_tag_ref(name):
     if getattr(name, "encode", None):
         name = name.encode(DEFAULT_ENCODING)
-    return b"refs/tags/" + name
+    return LOCAL_TAG_PREFIX + name
 
 
 def branch_delete(repo, name):
blob - bda6bdc5f13ec88942e195d350fc65e9136844a4
blob + 9d022f44cc481abfb0c44e6923387c0d398ebe0e
--- dulwich/refs.py
+++ dulwich/refs.py
@@ -32,6 +32,7 @@ from dulwich.objects import (
     git_line,
     valid_hexsha,
     ZERO_SHA,
+    Tag,
 )
 from dulwich.file import (
     GitFile,
@@ -1203,3 +1204,68 @@ def strip_peeled_refs(refs):
         for (ref, sha) in refs.items()
         if not ref.endswith(ANNOTATED_TAG_SUFFIX)
     }
+
+
+def _set_origin_head(refs, origin, origin_head):
+    # set refs/remotes/origin/HEAD
+    origin_base = b"refs/remotes/" + origin + b"/"
+    if origin_head and origin_head.startswith(LOCAL_BRANCH_PREFIX):
+        origin_ref = origin_base + b"HEAD"
+        target_ref = origin_base + origin_head[len(LOCAL_BRANCH_PREFIX) :]
+        if target_ref in refs:
+            refs.set_symbolic_ref(origin_ref, target_ref)
+
+
+def _set_default_branch(refs, origin, origin_head, branch, ref_message):
+    origin_base = b"refs/remotes/" + origin + b"/"
+    if branch:
+        origin_ref = origin_base + branch
+        if origin_ref in refs:
+            local_ref = LOCAL_BRANCH_PREFIX + branch
+            refs.add_if_new(
+                local_ref, refs[origin_ref], ref_message
+            )
+            head_ref = local_ref
+        elif LOCAL_TAG_PREFIX + branch in refs:
+            head_ref = LOCAL_TAG_PREFIX + branch
+        else:
+            raise ValueError(
+                "%s is not a valid branch or tag" % os.fsencode(branch)
+            )
+    elif origin_head:
+        head_ref = origin_head
+        if origin_head.startswith(LOCAL_BRANCH_PREFIX):
+            origin_ref = origin_base + origin_head[len(LOCAL_BRANCH_PREFIX) :]
+        else:
+            origin_ref = origin_head
+        try:
+            refs.add_if_new(
+                head_ref, refs[origin_ref], ref_message
+            )
+        except KeyError:
+            pass
+    return head_ref
+
+
+def _set_head(refs, head_ref, ref_message):
+    if head_ref.startswith(LOCAL_TAG_PREFIX):
+        # detach HEAD at specified tag
+        head = refs[head_ref]
+        if isinstance(head, Tag):
+            _cls, obj = head.object
+            head = obj.get_object(obj).id
+        del refs[b"HEAD"]
+        refs.set_if_equals(
+            b"HEAD", None, head, message=ref_message
+        )
+    else:
+        # set HEAD to specific branch
+        try:
+            head = refs[head_ref]
+            refs.set_symbolic_ref(b"HEAD", head_ref)
+            refs.set_if_equals(
+                b"HEAD", None, head, message=ref_message
+            )
+        except KeyError:
+            head = None
+    return head
blob - 29b0e6694b1e27e3cf4ba3de92cdd5f95f3ed00b
blob + e0362ef44e7fc68e42b391ea3ee532c0619d0c7b
--- dulwich/repo.py
+++ dulwich/repo.py
@@ -87,6 +87,8 @@ from dulwich.line_ending import BlobNormalizer, TreeBl
 
 from dulwich.refs import (  # noqa: F401
     ANNOTATED_TAG_SUFFIX,
+    LOCAL_BRANCH_PREFIX,
+    LOCAL_TAG_PREFIX,
     check_ref_format,
     RefsContainer,
     DictRefsContainer,
@@ -96,6 +98,9 @@ from dulwich.refs import (  # noqa: F401
     read_packed_refs_with_peeled,
     write_packed_refs,
     SYMREF,
+    _set_default_branch,
+    _set_head,
+    _set_origin_head,
 )
 
 
@@ -146,7 +151,10 @@ def _get_default_identity() -> Tuple[str, str]:
         except KeyError:
             fullname = None
         else:
-            fullname = gecos.split(",")[0]
+            if gecos:
+                fullname = gecos.split(",")[0]
+            else:
+                fullname = None
     if not fullname:
         fullname = username
     email = os.environ.get("EMAIL")
@@ -1268,6 +1276,7 @@ class Repo(BaseRepo):
         from dulwich.index import (
             blob_from_path_and_stat,
             index_entry_from_stat,
+            index_entry_from_directory,
             _fs_to_tree_path,
         )
 
@@ -1292,7 +1301,16 @@ class Repo(BaseRepo):
                 except KeyError:
                     pass  # already removed
             else:
-                if not stat.S_ISREG(st.st_mode) and not stat.S_ISLNK(st.st_mode):
+                if stat.S_ISDIR(st.st_mode):
+                    entry = index_entry_from_directory(st, full_path)
+                    if entry:
+                        index[tree_path] = entry
+                    else:
+                        try:
+                            del index[tree_path]
+                        except KeyError:
+                            pass
+                elif not stat.S_ISREG(st.st_mode) and not stat.S_ISLNK(st.st_mode):
                     try:
                         del index[tree_path]
                     except KeyError:
@@ -1370,6 +1388,7 @@ class Repo(BaseRepo):
         bare=False,
         origin=b"origin",
         checkout=None,
+        branch=None,
     ):
         """Clone this repository.
 
@@ -1377,56 +1396,78 @@ class Repo(BaseRepo):
           target_path: Target path
           mkdir: Create the target directory
           bare: Whether to create a bare repository
+          checkout: Whether or not to check-out HEAD after cloning
           origin: Base name for refs in target repository
             cloned from this repository
+          branch: Optional branch or tag to be used as HEAD in the new repository
+            instead of this repository's HEAD.
         Returns: Created repository as `Repo`
         """
-        if not bare:
-            target = self.init(target_path, mkdir=mkdir)
-        else:
-            if checkout:
-                raise ValueError("checkout and bare are incompatible")
-            target = self.init_bare(target_path, mkdir=mkdir)
-        self.fetch(target)
+
         encoded_path = self.path
         if not isinstance(encoded_path, bytes):
             encoded_path = os.fsencode(encoded_path)
-        ref_message = b"clone: from " + encoded_path
-        target.refs.import_refs(
-            b"refs/remotes/" + origin,
-            self.refs.as_dict(b"refs/heads"),
-            message=ref_message,
-        )
-        target.refs.import_refs(
-            b"refs/tags", self.refs.as_dict(b"refs/tags"), message=ref_message
-        )
-        try:
-            target.refs.add_if_new(
-                DEFAULT_REF, self.refs[DEFAULT_REF], message=ref_message
-            )
-        except KeyError:
-            pass
-        target_config = target.get_config()
-        target_config.set(("remote", "origin"), "url", encoded_path)
-        target_config.set(
-            ("remote", "origin"),
-            "fetch",
-            "+refs/heads/*:refs/remotes/origin/*",
-        )
-        target_config.write_to_path()
 
-        # Update target head
-        head_chain, head_sha = self.refs.follow(b"HEAD")
-        if head_chain and head_sha is not None:
-            target.refs.set_symbolic_ref(b"HEAD", head_chain[-1], message=ref_message)
-            target[b"HEAD"] = head_sha
-
-            if checkout is None:
-                checkout = not bare
-            if checkout:
-                # Checkout HEAD to target dir
-                target.reset_index()
+        if mkdir:
+            os.mkdir(target_path)
+
+        try:
+            target = None
+            if not bare:
+                target = Repo.init(target_path)
+                if checkout is None:
+                    checkout = True
+            else:
+                if checkout:
+                    raise ValueError("checkout and bare are incompatible")
+                target = Repo.init_bare(target_path)
+
+            target_config = target.get_config()
+            target_config.set((b"remote", origin), b"url", encoded_path)
+            target_config.set(
+                (b"remote", origin),
+                b"fetch",
+                b"+refs/heads/*:refs/remotes/" + origin + b"/*",
+            )
+            target_config.write_to_path()
+
+            ref_message = b"clone: from " + encoded_path
+            self.fetch(target)
+            target.refs.import_refs(
+                b"refs/remotes/" + origin,
+                self.refs.as_dict(b"refs/heads"),
+                message=ref_message,
+            )
+            target.refs.import_refs(
+                b"refs/tags", self.refs.as_dict(b"refs/tags"), message=ref_message
+            )
+
+            head_chain, origin_sha = self.refs.follow(b"HEAD")
+            origin_head = head_chain[-1] if head_chain else None
+            if origin_sha and not origin_head:
+                # set detached HEAD
+                target.refs[b"HEAD"] = origin_sha
+
+            _set_origin_head(target.refs, origin, origin_head)
+            head_ref = _set_default_branch(
+                target.refs, origin, origin_head, branch, ref_message
+            )
+
+            # Update target head
+            if head_ref:
+                head = _set_head(target.refs, head_ref, ref_message)
+            else:
+                head = None
 
+            if checkout and head is not None:
+                target.reset_index()
+        except BaseException:
+            if target is not None:
+                target.close()
+            if mkdir:
+                import shutil
+                shutil.rmtree(target_path)
+            raise
         return target
 
     def reset_index(self, tree=None):
@@ -1442,7 +1483,11 @@ class Repo(BaseRepo):
         )
 
         if tree is None:
-            tree = self[b"HEAD"].tree
+            head = self[b"HEAD"]
+            if isinstance(head, Tag):
+                _cls, obj = head.object
+                head = self.get_object(obj)
+            tree = head.tree
         config = self.get_config()
         honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
         if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
blob - fc9cf75eea7397ab392858a0ea83c7c0e2e48398
blob + aa6731815124912c8a1ef32acd6911c633d9f21b
--- dulwich/tests/test_index.py
+++ dulwich/tests/test_index.py
@@ -30,7 +30,6 @@ import stat
 import struct
 import sys
 import tempfile
-import warnings
 
 from dulwich.index import (
     Index,
@@ -64,9 +63,6 @@ from dulwich.tests import (
     TestCase,
     skipIf,
 )
-from dulwich.tests.utils import (
-    setup_warning_catcher,
-)
 
 
 def can_symlink():
@@ -107,28 +103,8 @@ class SimpleIndexTestCase(IndexTestCase):
         self.assertEqual(
             [(b"bla", b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", 33188)],
             list(self.get_simple_index("index").iterobjects()),
-        )
-
-    def test_iterblobs(self):
-        warnings.simplefilter("always", UserWarning)
-        self.addCleanup(warnings.resetwarnings)
-        warnings_list, restore_warnings = setup_warning_catcher()
-        self.addCleanup(restore_warnings)
-
-        self.assertEqual(
-            [(b"bla", b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", 33188)],
-            list(self.get_simple_index("index").iterblobs()),
         )
 
-        expected_warning = PendingDeprecationWarning("Use iterobjects() instead.")
-        for w in warnings_list:
-            if type(w) == type(expected_warning) and w.args == expected_warning.args:
-                break
-        else:
-            raise AssertionError(
-                "Expected warning %r not in %r" % (expected_warning, warnings_list)
-            )
-
     def test_getitem(self):
         self.assertEqual(
             (
blob - 0334f192e879227d61a3fe1641117cf9a6e824e1
blob + 5618066e8105401fe014c7b5e104a5d1e64f9d30
--- dulwich/tests/test_porcelain.py
+++ dulwich/tests/test_porcelain.py
@@ -630,9 +630,12 @@ class CloneTests(PorcelainTestCase):
         r.close()
 
     def test_source_broken(self):
-        target_path = tempfile.mkdtemp()
-        self.assertRaises(Exception, porcelain.clone, "/nonexistant/repo", target_path)
-        self.assertFalse(os.path.exists(target_path))
+        with tempfile.TemporaryDirectory() as parent:
+            target_path = os.path.join(parent, "target")
+            self.assertRaises(
+                Exception, porcelain.clone, "/nonexistant/repo", target_path
+            )
+            self.assertFalse(os.path.exists(target_path))
 
     def test_fetch_symref(self):
         f1_1 = make_object(Blob, data=b"f1")
@@ -652,7 +655,10 @@ class CloneTests(PorcelainTestCase):
         self.assertEqual(0, len(target_repo.open_index()))
         self.assertEqual(c1.id, target_repo.refs[b"refs/heads/else"])
         self.assertEqual(c1.id, target_repo.refs[b"HEAD"])
-        self.assertEqual({b"HEAD": b"refs/heads/else"}, target_repo.refs.get_symrefs())
+        self.assertEqual(
+            {b"HEAD": b"refs/heads/else", b"refs/remotes/origin/HEAD": b"refs/remotes/origin/else"},
+            target_repo.refs.get_symrefs(),
+        )
 
 
 class InitTests(TestCase):
@@ -2385,6 +2391,8 @@ class FetchTests(PorcelainTestCase):
             for k, v in remote_refs.items()
             if k.startswith(local_ref_prefix)
         }
+        if b"HEAD" in locally_known_remote_refs and b"HEAD" in remote_refs:
+            normalized_remote_refs[b"HEAD"] = remote_refs[b"HEAD"]
 
         self.assertEqual(locally_known_remote_refs, normalized_remote_refs)
 
blob - f8975cad544709687eb17bcaf16907b184744b56
blob + ee328013b16ac8a38bcb848a4eb1455d8453b1dd
--- dulwich/tests/test_repository.py
+++ dulwich/tests/test_repository.py
@@ -385,6 +385,7 @@ class RepositoryRootTests(TestCase):
                 {
                     b"HEAD": b"a90fa2d900a17e99b433217e988c4eb4a2e9a097",
                     b"refs/remotes/origin/master": b"a90fa2d900a17e99b433217e988c4eb4a2e9a097",
+                    b"refs/remotes/origin/HEAD": b"a90fa2d900a17e99b433217e988c4eb4a2e9a097",
                     b"refs/heads/master": b"a90fa2d900a17e99b433217e988c4eb4a2e9a097",
                     b"refs/tags/mytag": b"28237f4dc30d0d462658d6b937b08a0f0b6ef55a",
                     b"refs/tags/mytag-packed": b"b0931cadc54336e78a1d980420e3268903b57a50",
@@ -450,7 +451,49 @@ class RepositoryRootTests(TestCase):
         self.assertRaises(
             ValueError, r.clone, tmp_dir, mkdir=False, checkout=True, bare=True
         )
+
+    def test_clone_branch(self):
+        r = self.open_repo("a.git")
+        r.refs[b"refs/heads/mybranch"] = b"28237f4dc30d0d462658d6b937b08a0f0b6ef55a"
+        tmp_dir = self.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+        with r.clone(tmp_dir, mkdir=False, branch=b"mybranch") as t:
+            # HEAD should point to specified branch and not origin HEAD
+            chain, sha = t.refs.follow(b"HEAD")
+            self.assertEqual(chain[-1], b"refs/heads/mybranch")
+            self.assertEqual(sha, b"28237f4dc30d0d462658d6b937b08a0f0b6ef55a")
+            self.assertEqual(
+                t.refs[b"refs/remotes/origin/HEAD"],
+                b"a90fa2d900a17e99b433217e988c4eb4a2e9a097",
+            )
 
+    def test_clone_tag(self):
+        r = self.open_repo("a.git")
+        tmp_dir = self.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+        with r.clone(tmp_dir, mkdir=False, branch=b"mytag") as t:
+            # HEAD should be detached (and not a symbolic ref) at tag
+            self.assertEqual(
+                t.refs.read_ref(b"HEAD"),
+                b"28237f4dc30d0d462658d6b937b08a0f0b6ef55a",
+            )
+            self.assertEqual(
+                t.refs[b"refs/remotes/origin/HEAD"],
+                b"a90fa2d900a17e99b433217e988c4eb4a2e9a097",
+            )
+
+    def test_clone_invalid_branch(self):
+        r = self.open_repo("a.git")
+        tmp_dir = self.mkdtemp()
+        self.addCleanup(shutil.rmtree, tmp_dir)
+        self.assertRaises(
+            ValueError,
+            r.clone,
+            tmp_dir,
+            mkdir=False,
+            branch=b"mybranch",
+        )
+
     def test_merge_history(self):
         r = self.open_repo("simple_merge.git")
         shas = [e.commit.id for e in r.get_walker()]
@@ -648,7 +691,7 @@ exit 0
 
         pre_commit_contents = """#!%(executable)s
 import sys
-sys.path.extend(':'.join(%(path)s))
+sys.path.extend(%(path)r)
 from dulwich.repo import Repo
 
 with open('foo', 'w') as f:
@@ -656,7 +699,9 @@ with open('foo', 'w') as f:
 
 r = Repo('.')
 r.stage(['foo'])
-""" % {'executable': sys.executable, 'path': repr(sys.path)}
+""" % {
+            'executable': sys.executable,
+            'path': [os.path.join(os.path.dirname(__file__), '..', '..')] + sys.path}
 
         repo_dir = os.path.join(self.mkdtemp())
         self.addCleanup(shutil.rmtree, repo_dir)
@@ -1266,6 +1311,7 @@ class BuildRepoRootTests(TestCase):
         os.remove(os.path.join(r.path, "a"))
         r.stage(["a"])
         r.stage(["a"])  # double-stage a deleted path
+        self.assertEqual([], list(r.open_index()))
 
     def test_stage_directory(self):
         r = self._repo
@@ -1273,6 +1319,13 @@ class BuildRepoRootTests(TestCase):
         r.stage(["c"])
         self.assertEqual([b"a"], list(r.open_index()))
 
+    def test_stage_submodule(self):
+        r = self._repo
+        s = Repo.init(os.path.join(r.path, "sub"), mkdir=True)
+        s.do_commit(b'message')
+        r.stage(["sub"])
+        self.assertEqual([b"a", b"sub"], 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')
blob - 3f3f472b7348f4aa816b6ebe9616187914f8c6b9
blob + fc2a42b75edd1cae3f05952f180f61368145a86c
--- dulwich.egg-info/PKG-INFO
+++ dulwich.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: dulwich
-Version: 0.20.27
+Version: 0.20.30
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij
blob - a9f8ea6e10a235c8784b00f27a4bd695767ba248
blob + cb1509bcd238b2846f70be01ca0e2cd58bfb5f67
--- dulwich.egg-info/SOURCES.txt
+++ dulwich.egg-info/SOURCES.txt
@@ -23,6 +23,7 @@ setup.cfg
 setup.py
 status.yaml
 tox.ini
+.github/FUNDING.yml
 .github/workflows/pythonpackage.yml
 .github/workflows/pythonpublish.yml
 bin/dul-receive-pack
blob - bcad72df255a46d39eb9f694dbc8467fb8880de4
blob + 4d8177fc36e4b011df20e44ae387cdc9b50ec33e
--- releaser.conf
+++ releaser.conf
@@ -2,7 +2,7 @@
 news_file: "NEWS"
 timeout_days: 5
 tag_name: "dulwich-$VERSION"
-verify_command: "make check"
+verify_command: "flake8 && make check"
 update_version {
   path: "setup.py"
   match: "^dulwich_version_string = '(.*)'$"
blob - 8ca2a2fb253b8e6c74e80d5499feec21834c7deb
blob + e0ee7431ac4e6b4f1f0ec9fe5766e68c227588b9
--- setup.py
+++ setup.py
@@ -23,7 +23,7 @@ if sys.version_info < (3, 6):
         'For 2.7 support, please install a version prior to 0.20')
 
 
-dulwich_version_string = '0.20.27'
+dulwich_version_string = '0.20.30'
 
 
 class DulwichDistribution(Distribution):