Просмотр исходного кода

Add support for recursive submodule updates

Fixes #1813
Jelmer Vernooij 2 месяцев назад
Родитель
Сommit
a8e52774fc
5 измененных файлов с 144 добавлено и 6 удалено
  1. 5 0
      NEWS
  2. 8 1
      dulwich/cli.py
  3. 18 4
      dulwich/porcelain.py
  4. 1 1
      dulwich/repo.py
  5. 112 0
      tests/test_porcelain.py

+ 5 - 0
NEWS

@@ -8,6 +8,11 @@
    in CLI commands. When GIT_FLUSH=1, output is flushed after each write for
    in CLI commands. When GIT_FLUSH=1, output is flushed after each write for
    real-time visibility. (Jelmer Vernooij, #1810)
    real-time visibility. (Jelmer Vernooij, #1810)
 
 
+ * Add support for recursive submodule updates via ``--recursive`` flag in
+   ``dulwich submodule update`` command and ``recursive`` parameter in
+   ``porcelain.submodule_update()``.
+   (Jelmer Vernooij, #1813)
+
 0.24.7	2025-10-23
 0.24.7	2025-10-23
 
 
  * Add sparse index support for improved performance with large repositories.
  * Add sparse index support for improved performance with large repositories.

+ 8 - 1
dulwich/cli.py

@@ -3306,12 +3306,19 @@ class cmd_submodule_update(Command):
             action="store_true",
             action="store_true",
             help="Force update even if local changes exist",
             help="Force update even if local changes exist",
         )
         )
+        parser.add_argument(
+            "--recursive",
+            action="store_true",
+            help="Recursively update nested submodules",
+        )
         parser.add_argument(
         parser.add_argument(
             "paths", nargs="*", help="Specific submodule paths to update"
             "paths", nargs="*", help="Specific submodule paths to update"
         )
         )
         args = parser.parse_args(argv)
         args = parser.parse_args(argv)
         paths = args.paths if args.paths else None
         paths = args.paths if args.paths else None
-        porcelain.submodule_update(".", paths=paths, init=args.init, force=args.force)
+        porcelain.submodule_update(
+            ".", paths=paths, init=args.init, force=args.force, recursive=args.recursive
+        )
 
 
 
 
 class cmd_submodule(SuperCommand):
 class cmd_submodule(SuperCommand):

+ 18 - 4
dulwich/porcelain.py

@@ -987,7 +987,7 @@ def clone(
     if recurse_submodules and not bare:
     if recurse_submodules and not bare:
         try:
         try:
             submodule_init(repo)
             submodule_init(repo)
-            submodule_update(repo, init=True)
+            submodule_update(repo, init=True, recursive=True)
         except FileNotFoundError as e:
         except FileNotFoundError as e:
             # .gitmodules file doesn't exist - no submodules to process
             # .gitmodules file doesn't exist - no submodules to process
             logging.debug("No .gitmodules file found: %s", e)
             logging.debug("No .gitmodules file found: %s", e)
@@ -1962,6 +1962,7 @@ def submodule_update(
     paths: Optional[Sequence[Union[str, bytes, os.PathLike[str]]]] = None,
     paths: Optional[Sequence[Union[str, bytes, os.PathLike[str]]]] = None,
     init: bool = False,
     init: bool = False,
     force: bool = False,
     force: bool = False,
+    recursive: bool = False,
     errstream: Optional[BinaryIO] = None,
     errstream: Optional[BinaryIO] = None,
 ) -> None:
 ) -> None:
     """Update submodules.
     """Update submodules.
@@ -1971,6 +1972,7 @@ def submodule_update(
       paths: Optional list of specific submodule paths to update. If None, updates all.
       paths: Optional list of specific submodule paths to update. If None, updates all.
       init: If True, initialize submodules first
       init: If True, initialize submodules first
       force: Force update even if local changes exist
       force: Force update even if local changes exist
+      recursive: If True, recursively update nested submodules
       errstream: Error stream for error messages
       errstream: Error stream for error messages
     """
     """
     from .submodule import iter_cached_submodules
     from .submodule import iter_cached_submodules
@@ -2028,7 +2030,7 @@ def submodule_update(
 
 
             # Get or create the submodule repository paths
             # Get or create the submodule repository paths
             submodule_path = os.path.join(r.path, path_str)
             submodule_path = os.path.join(r.path, path_str)
-            submodule_git_dir = os.path.join(r.path, ".git", "modules", path_str)
+            submodule_git_dir = os.path.join(r.controldir(), "modules", path_str)
 
 
             # Clone or fetch the submodule
             # Clone or fetch the submodule
             if not os.path.exists(submodule_git_dir):
             if not os.path.exists(submodule_git_dir):
@@ -2044,8 +2046,7 @@ def submodule_update(
                     os.makedirs(submodule_path)
                     os.makedirs(submodule_path)
 
 
                 # Create .git file in the submodule directory
                 # Create .git file in the submodule directory
-                depth = path_str.count("/") + 1
-                relative_git_dir = "../" * depth + ".git/modules/" + path_str
+                relative_git_dir = os.path.relpath(submodule_git_dir, submodule_path)
                 git_file_path = os.path.join(submodule_path, ".git")
                 git_file_path = os.path.join(submodule_path, ".git")
                 with open(git_file_path, "w") as f:
                 with open(git_file_path, "w") as f:
                     f.write(f"gitdir: {relative_git_dir}\n")
                     f.write(f"gitdir: {relative_git_dir}\n")
@@ -2089,6 +2090,19 @@ def submodule_update(
                     # Reset the working directory
                     # Reset the working directory
                     reset(sub_repo, "hard", target_sha)
                     reset(sub_repo, "hard", target_sha)
 
 
+            # Recursively update nested submodules if requested
+            if recursive:
+                submodule_gitmodules = os.path.join(submodule_path, ".gitmodules")
+                if os.path.exists(submodule_gitmodules):
+                    submodule_update(
+                        submodule_path,
+                        paths=None,
+                        init=True,  # Always initialize nested submodules
+                        force=force,
+                        recursive=True,
+                        errstream=errstream,
+                    )
+
 
 
 def tag_create(
 def tag_create(
     repo: RepoPath,
     repo: RepoPath,

+ 1 - 1
dulwich/repo.py

@@ -1158,7 +1158,7 @@ def read_gitfile(f: BinaryIO) -> str:
     cs = f.read()
     cs = f.read()
     if not cs.startswith(b"gitdir: "):
     if not cs.startswith(b"gitdir: "):
         raise ValueError("Expected file to start with 'gitdir: '")
         raise ValueError("Expected file to start with 'gitdir: '")
-    return cs[len(b"gitdir: ") :].rstrip(b"\n").decode("utf-8")
+    return cs[len(b"gitdir: ") :].rstrip(b"\r\n").decode("utf-8")
 
 
 
 
 class UnsupportedVersion(Exception):
 class UnsupportedVersion(Exception):

+ 112 - 0
tests/test_porcelain.py

@@ -4859,6 +4859,118 @@ class SubmoduleTests(PorcelainTestCase):
         with open(submodule_file) as f:
         with open(submodule_file) as f:
             self.assertEqual(f.read(), "submodule content")
             self.assertEqual(f.read(), "submodule content")
 
 
+    def test_update_recursive(self) -> None:
+        # Create a nested (innermost) submodule repository
+        nested_repo_path = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, nested_repo_path)
+        nested_repo = Repo.init(nested_repo_path)
+        self.addCleanup(nested_repo.close)
+
+        # Add a file to the nested repo
+        nested_file = os.path.join(nested_repo_path, "nested.txt")
+        with open(nested_file, "w") as f:
+            f.write("nested submodule content")
+
+        porcelain.add(nested_repo, paths=[nested_file])
+        nested_commit = porcelain.commit(
+            nested_repo,
+            message=b"Initial nested commit",
+            author=b"Test Author <test@example.com>",
+            committer=b"Test Committer <test@example.com>",
+        )
+
+        # Create a middle submodule repository
+        middle_repo_path = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, middle_repo_path)
+        middle_repo = Repo.init(middle_repo_path)
+        self.addCleanup(middle_repo.close)
+
+        # Add a file to the middle repo
+        middle_file = os.path.join(middle_repo_path, "middle.txt")
+        with open(middle_file, "w") as f:
+            f.write("middle submodule content")
+
+        porcelain.add(middle_repo, paths=[middle_file])
+
+        # Add the nested submodule to the middle repository
+        porcelain.submodule_add(middle_repo, nested_repo_path, "nested")
+
+        # Manually add the nested submodule to the index
+        from dulwich.index import IndexEntry
+        from dulwich.objects import S_IFGITLINK
+
+        middle_index = middle_repo.open_index()
+        middle_index[b"nested"] = IndexEntry(
+            ctime=0,
+            mtime=0,
+            dev=0,
+            ino=0,
+            mode=S_IFGITLINK,
+            uid=0,
+            gid=0,
+            size=0,
+            sha=nested_commit,
+            flags=0,
+        )
+        middle_index.write()
+
+        porcelain.add(middle_repo, paths=[".gitmodules"])
+        middle_commit = porcelain.commit(
+            middle_repo,
+            message=b"Add nested submodule",
+            author=b"Test Author <test@example.com>",
+            committer=b"Test Committer <test@example.com>",
+        )
+
+        # Add the middle submodule to the main repository
+        porcelain.submodule_add(self.repo, middle_repo_path, "middle")
+
+        # Manually add the middle submodule to the index
+        main_index = self.repo.open_index()
+        main_index[b"middle"] = IndexEntry(
+            ctime=0,
+            mtime=0,
+            dev=0,
+            ino=0,
+            mode=S_IFGITLINK,
+            uid=0,
+            gid=0,
+            size=0,
+            sha=middle_commit,
+            flags=0,
+        )
+        main_index.write()
+
+        porcelain.add(self.repo, paths=[".gitmodules"])
+        porcelain.commit(
+            self.repo,
+            message=b"Add middle submodule",
+            author=b"Test Author <test@example.com>",
+            committer=b"Test Committer <test@example.com>",
+        )
+
+        # Initialize and recursively update the submodules
+        porcelain.submodule_init(self.repo)
+        porcelain.submodule_update(self.repo, recursive=True)
+
+        # Check that the middle submodule directory and file exist
+        middle_submodule_path = os.path.join(self.repo.path, "middle")
+        self.assertTrue(os.path.exists(middle_submodule_path))
+
+        middle_submodule_file = os.path.join(middle_submodule_path, "middle.txt")
+        self.assertTrue(os.path.exists(middle_submodule_file))
+        with open(middle_submodule_file) as f:
+            self.assertEqual(f.read(), "middle submodule content")
+
+        # Check that the nested submodule directory and file exist
+        nested_submodule_path = os.path.join(self.repo.path, "middle", "nested")
+        self.assertTrue(os.path.exists(nested_submodule_path))
+
+        nested_submodule_file = os.path.join(nested_submodule_path, "nested.txt")
+        self.assertTrue(os.path.exists(nested_submodule_file))
+        with open(nested_submodule_file) as f:
+            self.assertEqual(f.read(), "nested submodule content")
+
 
 
 class PushTests(PorcelainTestCase):
 class PushTests(PorcelainTestCase):
     def test_simple(self) -> None:
     def test_simple(self) -> None: