From 61ca2788012035d447822fe26941d119764d955e Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 20 Apr 2026 06:11:16 -0700 Subject: [PATCH 1/2] feat(submodule): add deinit method to Submodule (#2014) Mirrors the pattern of `Submodule.add`, `Submodule.update`, and `Submodule.remove` by exposing `git submodule deinit` as a first-class method, so callers no longer need the `repo.git.submodule('deinit', ...)` workaround noted in the issue. The method delegates to `git submodule deinit [--force] -- ` via `self.repo.git.submodule`, decorated with `@unbare_repo` to match the other mutating Submodule methods. It unregisters the submodule from `.git/config` and clears the working-tree directory while leaving `.gitmodules` and `.git/modules/` intact, so a later `update()` can re-initialize. Closes #2014. --- git/objects/submodule/base.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index d183672db..d7e7c3cbb 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -49,6 +49,7 @@ Callable, Dict, Iterator, + List, Mapping, Sequence, TYPE_CHECKING, @@ -1267,6 +1268,36 @@ def remove( return self + @unbare_repo + def deinit(self, force: bool = False) -> "Submodule": + """Run ``git submodule deinit`` on this submodule. + + This is a thin wrapper around ``git submodule deinit ``, paralleling + :meth:`add`, :meth:`update`, and :meth:`remove`. It unregisters the + submodule (removes its entry from ``.git/config`` and empties the + working-tree directory) without deleting the submodule from + ``.gitmodules`` or its checked-out repository under ``.git/modules/``. + A subsequent :meth:`update` will re-initialize the submodule from the + retained contents. + + :param force: + If ``True``, pass ``--force`` to ``git submodule deinit``. This + allows deinitialization even when the submodule's working tree has + local modifications that would otherwise block the command. + + :return: + self + + :note: + Doesn't work in bare repositories. + """ + args: List[str] = [] + if force: + args.append("--force") + args.extend(["--", self.path]) + self.repo.git.submodule("deinit", *args) + return self + def set_parent_commit(self, commit: Union[Commit_ish, str, None], check: bool = True) -> "Submodule": """Set this instance to use the given commit whose tree is supposed to contain the ``.gitmodules`` blob. From 8e24b0fded5540e1a8be4fe4c60182df79412c44 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 5 May 2026 09:09:26 -0700 Subject: [PATCH 2/2] test(submodule): cover Submodule.deinit() arg ordering Adds a targeted unit test that mocks Git.submodule and verifies deinit() calls git submodule with ("deinit", "--", path) and deinit(force=True) with ("deinit", "--force", "--", path), per Copilot review feedback. --- test/test_submodule.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/test_submodule.py b/test/test_submodule.py index 47647f2a1..a3705f452 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -718,6 +718,21 @@ def test_iter_items_from_invalid_hash(self): next(it) self.assertIsNone(ctx.exception.value) + @with_rw_directory + def test_deinit_calls_git_submodule(self, rwdir): + repo = git.Repo.init(rwdir) + submodule = Submodule(repo, b"\0" * 20, name="module", path="module") + + with mock.patch.object(Git, "submodule", create=True) as git_submodule: + submodule.deinit() + + git_submodule.assert_called_once_with("deinit", "--", submodule.path) + git_submodule.reset_mock() + + submodule.deinit(force=True) + + git_submodule.assert_called_once_with("deinit", "--force", "--", submodule.path) + @with_rw_repo(k_no_subm_tag, bare=False) def test_first_submodule(self, rwrepo): assert len(list(rwrepo.iter_submodules())) == 0