Skip to content

Commit b9d82c0

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 b9d82c0

2 files changed

Lines changed: 255 additions & 100 deletions

File tree

git/objects/submodule/base.py

Lines changed: 110 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -739,122 +739,132 @@ 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+
if osp.lexists(checkout_module_abspath) and (
798+
osp.islink(checkout_module_abspath)
799+
or not osp.isdir(checkout_module_abspath)
800+
or os.listdir(checkout_module_abspath)
801+
):
802+
raise OSError(
803+
"Module directory at %r does already exist and is non-empty" % checkout_module_abspath
804+
)
805+
os.makedirs(checkout_module_abspath, exist_ok=True)
806+
self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)
807+
mrepo = git.Repo(checkout_module_abspath)
808+
mrepo.head.reset(mrepo.head.commit, index=True, working_tree=True)
809+
fetch_remotes(mrepo)
810+
with self.repo.config_writer() as writer:
811+
writer.set_value(sm_section(self.name), "url", self.url)
812+
813+
if mrepo is None:
814+
# There is no git-repository yet - but delete empty paths.
815+
if not dry_run and osp.isdir(checkout_module_abspath):
816+
try:
817+
os.rmdir(checkout_module_abspath)
818+
except OSError as e:
819+
raise OSError(
820+
"Module directory at %r does already exist and is non-empty" % checkout_module_abspath
821+
) from e
822+
# END handle directory removal
823+
824+
# Don't check it out at first - nonetheless it will create a local
825+
# branch according to the remote-HEAD if possible.
826+
progress.update(
827+
BEGIN | CLONE,
828+
0,
829+
1,
830+
prefix
831+
+ "Cloning url '%s' to '%s' in submodule %r" % (self.url, checkout_module_abspath, self.name),
818832
)
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,
833+
if not dry_run:
834+
if self.url.startswith("."):
835+
url = urllib.parse.urljoin(self.repo.remotes.origin.url + "/", self.url)
836+
else:
837+
url = self.url
838+
mrepo = self._clone_repo(
839+
self.repo,
840+
url,
841+
self.path,
842+
self.name,
843+
n=True,
844+
env=env,
845+
multi_options=clone_multi_options,
846+
allow_unsafe_options=allow_unsafe_options,
847+
allow_unsafe_protocols=allow_unsafe_protocols,
844848
)
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
849+
progress.update(END | CLONE, 0, 1, prefix + "Done cloning to %s" % checkout_module_abspath)
850+
851+
if not dry_run:
852+
# See whether we have a valid branch to check out.
853+
try:
854+
mrepo = cast("Repo", mrepo)
855+
remote_branch = find_first_remote_branch(mrepo.remotes, self.branch_name)
856+
local_branch = mkhead(mrepo, self.branch_path)
857+
local_branch.set_object(Object(mrepo, self.NULL_BIN_SHA))
858+
mrepo.head.set_reference(
859+
local_branch,
860+
logmsg="submodule: attaching head to %s" % local_branch,
861+
)
862+
mrepo.head.reference.set_tracking_branch(remote_branch)
863+
except (IndexError, InvalidGitRepositoryError):
864+
_logger.warning("Failed to checkout tracking branch %s", self.branch_path)
865+
866+
with self.repo.config_writer() as writer:
867+
writer.set_value(sm_section(self.name), "url", self.url)
858868
# END handle initialization
859869

860870
# DETERMINE SHAS TO CHECK OUT

test/test_submodule.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,151 @@ 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_refuses_non_empty_checkout(self, rwdir):
802+
source_path = osp.join(rwdir, "source")
803+
source_repo = git.Repo.init(source_path)
804+
tracked_file = osp.join(source_path, "file")
805+
with open(tracked_file, "w") as fp:
806+
fp.write("submodule content")
807+
source_repo.index.add(["file"])
808+
source_repo.index.commit("initial commit")
809+
810+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
811+
submodule = parent_repo.create_submodule("module", "module", source_path)
812+
parent_repo.index.commit("add submodule")
813+
submodule.deinit()
814+
815+
checkout_file = osp.join(submodule.abspath, "file")
816+
with open(checkout_file, "w") as fp:
817+
fp.write("user content")
818+
819+
with pytest.raises(OSError, match="does already exist and is non-empty"):
820+
submodule.update()
821+
822+
with open(checkout_file) as fp:
823+
assert fp.read() == "user content"
824+
assert not osp.exists(osp.join(submodule.abspath, ".git"))
825+
826+
@with_rw_directory
827+
def test_update_after_deinit_without_init_does_not_restore_checkout(self, rwdir):
828+
source_path = osp.join(rwdir, "source")
829+
source_repo = git.Repo.init(source_path)
830+
source_repo.git.commit(m="initial commit", allow_empty=True)
831+
832+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
833+
submodule = parent_repo.create_submodule("module", "module", source_path)
834+
parent_repo.index.commit("add submodule")
835+
submodule.deinit()
836+
837+
assert submodule.update(init=False) is submodule
838+
assert not submodule.module_exists()
839+
assert not osp.exists(osp.join(submodule.abspath, ".git"))
840+
841+
@with_rw_directory
842+
def test_dry_run_update_after_deinit_does_not_restore_checkout(self, rwdir):
843+
source_path = osp.join(rwdir, "source")
844+
source_repo = git.Repo.init(source_path)
845+
source_repo.git.commit(m="initial commit", allow_empty=True)
846+
847+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
848+
submodule = parent_repo.create_submodule("module", "module", source_path)
849+
parent_repo.index.commit("add submodule")
850+
submodule.deinit()
851+
852+
assert submodule.update(dry_run=True) is submodule
853+
assert not submodule.module_exists()
854+
assert not osp.exists(osp.join(submodule.abspath, ".git"))
855+
856+
@with_rw_directory
857+
def test_update_after_deinit_restores_nested_checkout(self, rwdir):
858+
source_path = osp.join(rwdir, "source")
859+
source_repo = git.Repo.init(source_path)
860+
touch(osp.join(source_path, "file"))
861+
source_repo.index.add(["file"])
862+
source_repo.index.commit("initial commit")
863+
864+
parent_repo = git.Repo.init(osp.join(rwdir, "parent"))
865+
submodule = parent_repo.create_submodule("nested/module", "deps/module", source_path)
866+
parent_repo.index.commit("add nested submodule")
867+
submodule.deinit()
868+
869+
submodule.update()
870+
871+
module_repo = submodule.module()
872+
assert module_repo.head.commit == source_repo.head.commit
873+
assert osp.isfile(osp.join(submodule.abspath, "file"))
874+
assert osp.samefile(module_repo.git_dir, osp.join(parent_repo.git_dir, "modules", submodule.name))
875+
731876
@with_rw_repo(k_no_subm_tag, bare=False)
732877
def test_first_submodule(self, rwrepo):
733878
assert len(list(rwrepo.iter_submodules())) == 0

0 commit comments

Comments
 (0)