Commit Diff


commit - 3971b9ccd9e783bdb978185ca7ceb03db3da3bf6
commit + 64a1ae22714899eb4237f620a15fa642de198f87
blob - e4fe1c2f831459919e5aff2f7d79553ba64152f5
blob + b348b90793c0825266c1719ceb82d678912668b6
--- NEWS
+++ NEWS
@@ -1,5 +1,8 @@
 0.20.45	UNRELEASED
 
+ * Add basic ``dulwich.porcelain.submodule_list`` and ``dulwich.porcelain.submodule_add``
+  (Jelmer Vernooij)
+
 0.20.44	2022-06-30
 
  * Fix reading of chunks in server. (Jelmer Vernooij, #977)
blob - 235f80b996fbd0254f44349709de633d1769f7a9
blob + f95b9f261153cd7fe558ac18c14ddd56b29492f8
--- dulwich/cli.py
+++ dulwich/cli.py
@@ -321,6 +321,14 @@ class cmd_rev_list(Command):
         porcelain.rev_list(".", args)
 
 
+class cmd_submodule(Command):
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        for path, sha in porcelain.submodule_list("."):
+            sys.stdout.write(' %s %s\n' % (sha, path))
+
+
 class cmd_tag(Command):
     def run(self, args):
         parser = optparse.OptionParser()
@@ -721,6 +729,7 @@ commands = {
     "stash": cmd_stash,
     "status": cmd_status,
     "symbolic-ref": cmd_symbolic_ref,
+    "submodule": cmd_submodule,
     "tag": cmd_tag,
     "update-server-info": cmd_update_server_info,
     "upload-pack": cmd_upload_pack,
blob - d34dac7e8a6f3a6113e2b30e97f9f4f27dbe1a1a
blob + e79e90df8ea2fce01ac99c78fa64ceac09c21890
--- dulwich/porcelain.py
+++ dulwich/porcelain.py
@@ -43,6 +43,7 @@ Currently implemented:
  * remote{_add}
  * receive-pack
  * reset
+ * submodule_list
  * rev-list
  * tag{_create,_delete,_list}
  * upload-pack
@@ -86,6 +87,7 @@ from dulwich.client import (
     get_transport_and_path,
 )
 from dulwich.config import (
+    ConfigFile,
     StackedConfig,
 )
 from dulwich.diff_tree import (
@@ -856,8 +858,53 @@ def rev_list(repo, commits, outstream=sys.stdout):
     with open_repo_closing(repo) as r:
         for entry in r.get_walker(include=[r[c].id for c in commits]):
             outstream.write(entry.commit.id + b"\n")
+
+
+def _canonical_part(url: str) -> str:
+    name = url.rsplit('/', 1)[-1]
+    if name.endswith('.git'):
+        name = name[:-4]
+    return name
 
 
+def submodule_add(repo, url, path=None, name=None):
+    """Add a new submodule.
+
+    Args:
+      repo: Path to repository
+      url: URL of repository to add as submodule
+      path: Path where submodule should live
+    """
+    with open_repo_closing(repo) as r:
+        if path is None:
+            path = os.path.relpath(_canonical_part(url), r.path)
+        if name is None:
+            name = path
+
+        # TODO(jelmer): Move this logic to dulwich.submodule
+        gitmodules_path = os.path.join(r.path, ".gitmodules")
+        try:
+            config = ConfigFile.from_path(gitmodules_path)
+        except FileNotFoundError:
+            config = ConfigFile()
+            config.path = gitmodules_path
+        config.set(("submodule", name), "url", url)
+        config.set(("submodule", name), "path", path)
+        config.write_to_path()
+
+
+def submodule_list(repo):
+    """List submodules.
+
+    Args:
+      repo: Path to repository
+    """
+    from .submodule import iter_cached_submodules
+    with open_repo_closing(repo) as r:
+        for path, sha in iter_cached_submodules(r.object_store, r[r.head()].tree):
+            yield path.decode(DEFAULT_ENCODING), sha.decode(DEFAULT_ENCODING)
+
+
 def tag(*args, **kwargs):
     import warnings
 
@@ -1456,7 +1503,7 @@ def branch_create(repo, name, objectish=None, force=Fa
             objectish = "HEAD"
         object = parse_object(r, objectish)
         refname = _make_branch_ref(name)
-        ref_message = b"branch: Created from " + objectish.encode("utf-8")
+        ref_message = b"branch: Created from " + objectish.encode(DEFAULT_ENCODING)
         if force:
             r.refs.set_if_equals(refname, None, object.id, message=ref_message)
         else:
@@ -1541,7 +1588,7 @@ def fetch(
     with open_repo_closing(repo) as r:
         (remote_name, remote_location) = get_remote_repo(r, remote_location)
         if message is None:
-            message = b"fetch: from " + remote_location.encode("utf-8")
+            message = b"fetch: from " + remote_location.encode(DEFAULT_ENCODING)
         client, path = get_transport_and_path(
             remote_location, config=r.get_config_stack(), **kwargs
         )
blob - 6ed9cac2c3b9e595b3eba070fd0ccef979c56148
blob + acd8a6b468cbea39edb1d8a0ac2821978ee490aa
--- dulwich/tests/test_porcelain.py
+++ dulwich/tests/test_porcelain.py
@@ -1406,6 +1406,28 @@ class ResetFileTests(PorcelainTestCase):
         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 SubmoduleTests(PorcelainTestCase):
+
+    def test_empty(self):
+        porcelain.commit(
+            repo=self.repo.path,
+            message=b"init",
+            author=b"author <email>",
+            committer=b"committer <email>",
+        )
+
+        self.assertEqual([], list(porcelain.submodule_list(self.repo)))
+
+    def test_add(self):
+        porcelain.submodule_add(self.repo, "../bar.git", "bar")
+        with open('%s/.gitmodules' % self.repo.path, 'r') as f:
+            self.assertEqual("""\
+[submodule "bar"]
+\turl = ../bar.git
+\tpath = bar
+""", f.read())
 
 
 class PushTests(PorcelainTestCase):
blob - /dev/null
blob + dc98d8792450299ff187445bbf197eda758a6580 (mode 644)
--- /dev/null
+++ dulwich/submodule.py
@@ -0,0 +1,40 @@
+# config.py - Reading and writing Git config files
+# Copyright (C) 2011-2013 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Working with Git submodules.
+"""
+
+from typing import Iterator, Tuple
+from .objects import S_ISGITLINK
+
+
+def iter_cached_submodules(store, root_tree_id: bytes) -> Iterator[Tuple[str, bytes]]:
+    """iterate over cached submodules.
+
+    Args:
+      store: Object store to iterate
+      root_tree_id: SHA of root tree
+
+    Returns:
+      Iterator over over (path, sha) tuples
+    """
+    for entry in store.iter_tree_contents(root_tree_id):
+        if S_ISGITLINK(entry.mode):
+            yield entry.path, entry.sha