Skip to content

Commit eb7bf7b

Browse files
codexByron
andcommitted
fix: make submodule.update() after submodule.deinit() work
Git's `submodule deinit` command removes the submodule checkout but retains its repository under the parent repository's `.git/modules` directory. Submodule.update() previously treated the missing checkout as a completely uninitialized submodule and attempted to clone it again. The clone could not reuse the existing module repository, preventing a deinitialized submodule from being initialized again through GitPython. Detect a valid retained repository before entering the clone path. Restore the checkout's `.git` file and the module repository's worktree configuration, reset the retained repository to recreate its index and working tree, and restore the submodule URL in the parent configuration. Continue using the existing clone behavior when no valid retained repository is available. Co-authored-by: Sebastian Thiel <sebastian.thiel@icloud.com>
1 parent f89e18c commit eb7bf7b

2 files changed

Lines changed: 221 additions & 100 deletions

File tree

git/objects/submodule/base.py

Lines changed: 102 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -739,122 +739,124 @@ def update(
739739
mrepo = None
740740
# END init mrepo
741741

742+
def fetch_remotes(module_repo: "Repo") -> None:
743+
rmts = module_repo.remotes
744+
len_rmts = len(rmts)
745+
for i, remote in enumerate(rmts):
746+
op = FETCH
747+
if i == 0:
748+
op |= BEGIN
749+
# END handle start
750+
751+
progress.update(
752+
op,
753+
i,
754+
len_rmts,
755+
prefix + "Fetching remote %s of submodule %r" % (remote, self.name),
756+
)
757+
# ===============================
758+
if not dry_run:
759+
remote.fetch(progress=progress)
760+
# END handle dry-run
761+
# ===============================
762+
if i == len_rmts - 1:
763+
op |= END
764+
# END handle end
765+
progress.update(
766+
op,
767+
i,
768+
len_rmts,
769+
prefix + "Done fetching remote of submodule %r" % self.name,
770+
)
771+
# END fetch new data
772+
742773
try:
743774
# ENSURE REPO IS PRESENT AND UP-TO-DATE
744775
#######################################
745776
try:
746777
mrepo = self.module()
747-
rmts = mrepo.remotes
748-
len_rmts = len(rmts)
749-
for i, remote in enumerate(rmts):
750-
op = FETCH
751-
if i == 0:
752-
op |= BEGIN
753-
# END handle start
754-
755-
progress.update(
756-
op,
757-
i,
758-
len_rmts,
759-
prefix + "Fetching remote %s of submodule %r" % (remote, self.name),
760-
)
761-
# ===============================
762-
if not dry_run:
763-
remote.fetch(progress=progress)
764-
# END handle dry-run
765-
# ===============================
766-
if i == len_rmts - 1:
767-
op |= END
768-
# END handle end
769-
progress.update(
770-
op,
771-
i,
772-
len_rmts,
773-
prefix + "Done fetching remote of submodule %r" % self.name,
774-
)
775-
# END fetch new data
778+
fetch_remotes(mrepo)
776779
except InvalidGitRepositoryError:
777780
mrepo = None
778781
if not init:
779782
return self
780783
# END early abort if init is not allowed
781784

782-
# There is no git-repository yet - but delete empty paths.
783785
checkout_module_abspath = self.abspath
784-
if not dry_run and osp.isdir(checkout_module_abspath):
786+
module_abspath = self._module_abspath(self.repo, self.path, self.name)
787+
788+
# ``git submodule deinit`` leaves the repository in
789+
# ``.git/modules`` and empties the checkout. Reconnect that retained
790+
# repository instead of trying to clone over it.
791+
if not dry_run and osp.isdir(module_abspath):
785792
try:
786-
os.rmdir(checkout_module_abspath)
787-
except OSError as e:
788-
raise OSError(
789-
"Module directory at %r does already exist and is non-empty" % checkout_module_abspath
790-
) from e
791-
# END handle OSError
792-
# END handle directory removal
793-
794-
# Don't check it out at first - nonetheless it will create a local
795-
# branch according to the remote-HEAD if possible.
796-
progress.update(
797-
BEGIN | CLONE,
798-
0,
799-
1,
800-
prefix
801-
+ "Cloning url '%s' to '%s' in submodule %r" % (self.url, checkout_module_abspath, self.name),
802-
)
803-
if not dry_run:
804-
if self.url.startswith("."):
805-
url = urllib.parse.urljoin(self.repo.remotes.origin.url + "/", self.url)
793+
git.Repo(module_abspath)
794+
except InvalidGitRepositoryError:
795+
pass
806796
else:
807-
url = self.url
808-
mrepo = self._clone_repo(
809-
self.repo,
810-
url,
811-
self.path,
812-
self.name,
813-
n=True,
814-
env=env,
815-
multi_options=clone_multi_options,
816-
allow_unsafe_options=allow_unsafe_options,
817-
allow_unsafe_protocols=allow_unsafe_protocols,
797+
os.makedirs(checkout_module_abspath, exist_ok=True)
798+
self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)
799+
mrepo = git.Repo(checkout_module_abspath)
800+
mrepo.head.reset(mrepo.head.commit, index=True, working_tree=True)
801+
fetch_remotes(mrepo)
802+
with self.repo.config_writer() as writer:
803+
writer.set_value(sm_section(self.name), "url", self.url)
804+
805+
if mrepo is None:
806+
# There is no git-repository yet - but delete empty paths.
807+
if not dry_run and osp.isdir(checkout_module_abspath):
808+
try:
809+
os.rmdir(checkout_module_abspath)
810+
except OSError as e:
811+
raise OSError(
812+
"Module directory at %r does already exist and is non-empty" % checkout_module_abspath
813+
) from e
814+
# END handle directory removal
815+
816+
# Don't check it out at first - nonetheless it will create a local
817+
# branch according to the remote-HEAD if possible.
818+
progress.update(
819+
BEGIN | CLONE,
820+
0,
821+
1,
822+
prefix
823+
+ "Cloning url '%s' to '%s' in submodule %r" % (self.url, checkout_module_abspath, self.name),
818824
)
819-
# END handle dry-run
820-
progress.update(
821-
END | CLONE,
822-
0,
823-
1,
824-
prefix + "Done cloning to %s" % checkout_module_abspath,
825-
)
826-
827-
if not dry_run:
828-
# See whether we have a valid branch to check out.
829-
try:
830-
mrepo = cast("Repo", mrepo)
831-
# Find a remote which has our branch - we try to be flexible.
832-
remote_branch = find_first_remote_branch(mrepo.remotes, self.branch_name)
833-
local_branch = mkhead(mrepo, self.branch_path)
834-
835-
# Have a valid branch, but no checkout - make sure we can figure
836-
# that out by marking the commit with a null_sha.
837-
local_branch.set_object(Object(mrepo, self.NULL_BIN_SHA))
838-
# END initial checkout + branch creation
839-
840-
# Make sure HEAD is not detached.
841-
mrepo.head.set_reference(
842-
local_branch,
843-
logmsg="submodule: attaching head to %s" % local_branch,
825+
if not dry_run:
826+
if self.url.startswith("."):
827+
url = urllib.parse.urljoin(self.repo.remotes.origin.url + "/", self.url)
828+
else:
829+
url = self.url
830+
mrepo = self._clone_repo(
831+
self.repo,
832+
url,
833+
self.path,
834+
self.name,
835+
n=True,
836+
env=env,
837+
multi_options=clone_multi_options,
838+
allow_unsafe_options=allow_unsafe_options,
839+
allow_unsafe_protocols=allow_unsafe_protocols,
844840
)
845-
mrepo.head.reference.set_tracking_branch(remote_branch)
846-
except (IndexError, InvalidGitRepositoryError):
847-
_logger.warning("Failed to checkout tracking branch %s", self.branch_path)
848-
# END handle tracking branch
849-
850-
# NOTE: Have to write the repo config file as well, otherwise the
851-
# default implementation will be offended and not update the
852-
# repository. Maybe this is a good way to ensure it doesn't get into
853-
# our way, but we want to stay backwards compatible too... It's so
854-
# redundant!
855-
with self.repo.config_writer() as writer:
856-
writer.set_value(sm_section(self.name), "url", self.url)
857-
# END handle dry_run
841+
progress.update(END | CLONE, 0, 1, prefix + "Done cloning to %s" % checkout_module_abspath)
842+
843+
if not dry_run:
844+
# See whether we have a valid branch to check out.
845+
try:
846+
mrepo = cast("Repo", mrepo)
847+
remote_branch = find_first_remote_branch(mrepo.remotes, self.branch_name)
848+
local_branch = mkhead(mrepo, self.branch_path)
849+
local_branch.set_object(Object(mrepo, self.NULL_BIN_SHA))
850+
mrepo.head.set_reference(
851+
local_branch,
852+
logmsg="submodule: attaching head to %s" % local_branch,
853+
)
854+
mrepo.head.reference.set_tracking_branch(remote_branch)
855+
except (IndexError, InvalidGitRepositoryError):
856+
_logger.warning("Failed to checkout tracking branch %s", self.branch_path)
857+
858+
with self.repo.config_writer() as writer:
859+
writer.set_value(sm_section(self.name), "url", self.url)
858860
# END handle initialization
859861

860862
# DETERMINE SHAS TO CHECK OUT

test/test_submodule.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,125 @@ def test_deinit_calls_git_submodule(self, rwdir):
728728

729729
git_submodule.assert_called_once_with("deinit", "--force", "--", submodule.path)
730730

731+
@with_rw_directory
732+
def test_update_after_deinit(self, rwdir):
733+
source_path = osp.join(rwdir, "source")
734+
source_repo = git.Repo.init(source_path)
735+
touch(osp.join(source_path, "file"))
736+
source_repo.index.add(["file"])
737+
source_repo.index.commit("initial commit")
738+
739+
parent_path = osp.join(rwdir, "parent")
740+
parent_repo = git.Repo.init(parent_path)
741+
submodule = parent_repo.create_submodule("module", "module", source_path)
742+
parent_repo.index.commit("add submodule")
743+
744+
submodule.deinit()
745+
assert not submodule.module_exists()
746+
assert osp.isdir(osp.join(parent_repo.git_dir, "modules", submodule.name))
747+
748+
submodule.update()
749+
750+
assert submodule.module_exists()
751+
assert submodule.module().head.commit == source_repo.head.commit
752+
assert osp.isfile(osp.join(submodule.abspath, "file"))
753+
754+
@with_rw_directory
755+
def test_update_to_latest_revision_after_deinit_fetches_remote(self, rwdir):
756+
source_path = osp.join(rwdir, "source")
757+
source_repo = git.Repo.init(source_path)
758+
source_repo.git.commit(m="initial commit", allow_empty=True)
759+
760+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
761+
submodule = parent_repo.create_submodule("module", "module", source_path)
762+
parent_repo.index.commit("add submodule")
763+
submodule.deinit()
764+
765+
touch(osp.join(source_path, "new-file"))
766+
source_repo.index.add(["new-file"])
767+
source_repo.index.commit("advance remote")
768+
769+
submodule.update(to_latest_revision=True)
770+
771+
assert submodule.module().head.commit == source_repo.head.commit
772+
assert osp.isfile(osp.join(submodule.abspath, "new-file"))
773+
774+
@with_rw_directory
775+
def test_update_after_deinit_fetches_new_gitlink_commit(self, rwdir):
776+
source_path = osp.join(rwdir, "source")
777+
source_repo = git.Repo.init(source_path)
778+
source_repo.git.commit(m="initial commit", allow_empty=True)
779+
780+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
781+
submodule = parent_repo.create_submodule("module", "module", source_path)
782+
parent_repo.index.commit("add submodule")
783+
submodule.deinit()
784+
785+
touch(osp.join(source_path, "new-file"))
786+
source_repo.index.add(["new-file"])
787+
source_repo.index.commit("advance remote")
788+
parent_repo.git.update_index(
789+
"--cacheinfo",
790+
f"160000,{source_repo.head.commit.hexsha},{submodule.path}",
791+
)
792+
parent_repo.index.commit("advance submodule")
793+
794+
submodule = parent_repo.submodule(submodule.name)
795+
submodule.update()
796+
797+
assert submodule.module().head.commit == source_repo.head.commit
798+
assert osp.isfile(osp.join(submodule.abspath, "new-file"))
799+
800+
@with_rw_directory
801+
def test_update_after_deinit_without_init_does_not_restore_checkout(self, rwdir):
802+
source_path = osp.join(rwdir, "source")
803+
source_repo = git.Repo.init(source_path)
804+
source_repo.git.commit(m="initial commit", allow_empty=True)
805+
806+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
807+
submodule = parent_repo.create_submodule("module", "module", source_path)
808+
parent_repo.index.commit("add submodule")
809+
submodule.deinit()
810+
811+
assert submodule.update(init=False) is submodule
812+
assert not submodule.module_exists()
813+
assert not osp.exists(osp.join(submodule.abspath, ".git"))
814+
815+
@with_rw_directory
816+
def test_dry_run_update_after_deinit_does_not_restore_checkout(self, rwdir):
817+
source_path = osp.join(rwdir, "source")
818+
source_repo = git.Repo.init(source_path)
819+
source_repo.git.commit(m="initial commit", allow_empty=True)
820+
821+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
822+
submodule = parent_repo.create_submodule("module", "module", source_path)
823+
parent_repo.index.commit("add submodule")
824+
submodule.deinit()
825+
826+
assert submodule.update(dry_run=True) is submodule
827+
assert not submodule.module_exists()
828+
assert not osp.exists(osp.join(submodule.abspath, ".git"))
829+
830+
@with_rw_directory
831+
def test_update_after_deinit_restores_nested_checkout(self, rwdir):
832+
source_path = osp.join(rwdir, "source")
833+
source_repo = git.Repo.init(source_path)
834+
touch(osp.join(source_path, "file"))
835+
source_repo.index.add(["file"])
836+
source_repo.index.commit("initial commit")
837+
838+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
839+
submodule = parent_repo.create_submodule("nested/module", "deps/module", source_path)
840+
parent_repo.index.commit("add nested submodule")
841+
submodule.deinit()
842+
843+
submodule.update()
844+
845+
module_repo = submodule.module()
846+
assert module_repo.head.commit == source_repo.head.commit
847+
assert osp.isfile(osp.join(submodule.abspath, "file"))
848+
assert osp.samefile(module_repo.git_dir, osp.join(parent_repo.git_dir, "modules", submodule.name))
849+
731850
@with_rw_repo(k_no_subm_tag, bare=False)
732851
def test_first_submodule(self, rwrepo):
733852
assert len(list(rwrepo.iter_submodules())) == 0

0 commit comments

Comments
 (0)