99import ntpath
1010import os
1111import os .path as osp
12+ from pathlib import Path
1213import shlex
1314import stat
1415import sys
@@ -255,7 +256,7 @@ def _config_parser(
255256 # END handle parent_commit
256257 fp_module : Union [str , BytesIO ]
257258 if not repo .bare and parent_matches_head and repo .working_tree_dir :
258- fp_module = osp . join (repo .working_tree_dir , cls .k_modules_file )
259+ fp_module = cls . _checked_abspath (repo .working_tree_dir , cls .k_modules_file )
259260 else :
260261 assert parent_commit is not None , "need valid parent_commit in bare repositories"
261262 try :
@@ -322,7 +323,7 @@ def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> Path
322323 if cls ._need_gitfile_submodules (parent_repo .git ):
323324 return osp .join (parent_repo .git_dir , "modules" , name )
324325 if parent_repo .working_tree_dir :
325- return osp . join (parent_repo .working_tree_dir , path )
326+ return cls . _checked_abspath (parent_repo .working_tree_dir , cls . _to_relative_path ( parent_repo , path ) )
326327 raise NotADirectoryError ()
327328
328329 @classmethod
@@ -361,8 +362,11 @@ def _clone_repo(
361362 :param kwargs:
362363 Additional arguments given to :manpage:`git-clone(1)`.
363364 """
365+ path = cls ._to_relative_path (repo , path )
366+ if repo .working_tree_dir is None :
367+ raise NotADirectoryError ("Submodules require a working tree" )
368+ module_checkout_path = cls ._checked_abspath (repo .working_tree_dir , path )
364369 module_abspath = cls ._module_abspath (repo , path , name )
365- module_checkout_path = module_abspath
366370 if cls ._need_gitfile_submodules (repo .git ):
367371 if not allow_unsafe_options :
368372 Git .check_unsafe_options (Git ._option_candidates ([], kwargs ), repo .unsafe_git_clone_options )
@@ -373,11 +377,22 @@ def _clone_repo(
373377 repo .unsafe_git_clone_options ,
374378 )
375379 allow_unsafe_options = True
380+ if osp .islink (module_abspath ):
381+ # Clone into the target while retaining the metadata alias. Git for
382+ # Windows cannot initialize through a dangling directory symlink.
383+ # Read the link explicitly for Python 3.7, and remove the Windows
384+ # namespace prefix returned by newer Python versions for Git.
385+ target = os .readlink (module_abspath )
386+ if sys .platform == "win32" :
387+ if target .startswith ("\\ \\ ?\\ UNC\\ " ):
388+ target = "\\ \\ " + target [8 :]
389+ elif target .startswith ("\\ \\ ?\\ " ):
390+ target = target [4 :]
391+ module_abspath = to_native_path_linux (osp .join (osp .dirname (module_abspath ), target ))
376392 kwargs ["separate_git_dir" ] = module_abspath
377393 module_abspath_dir = osp .dirname (module_abspath )
378394 if not osp .isdir (module_abspath_dir ):
379395 os .makedirs (module_abspath_dir )
380- module_checkout_path = osp .join (repo .working_tree_dir , path ) # type: ignore[arg-type]
381396
382397 if url .startswith ("../" ):
383398 remote_name = cast ("RemoteReference" , repo .active_branch .tracking_branch ()).remote_name
@@ -419,13 +434,43 @@ def abspath(self) -> PathLike:
419434 root = self .repo .working_tree_dir
420435 if root is None :
421436 return super ().abspath
422- path = root
423- for component in os .fspath (self ._to_relative_path (self .repo , self .path )).split ("/" ):
424- path = join_path_native (path , component )
437+ return self ._checkout_abspath (self ._to_relative_path (self .repo , self .path ))
438+
439+ def _checkout_abspath (self , relative_path : PathLike , allow_final_symlink : bool = False ) -> PathLike :
440+ """Check a checkout path already normalized by :meth:`_to_relative_path`."""
441+ return self ._checked_abspath (self .repo .working_tree_dir , relative_path , allow_final_symlink )
442+
443+ @classmethod
444+ def _checked_abspath (
445+ cls , root : Union [PathLike , None ], relative_path : PathLike , allow_final_symlink : bool = False
446+ ) -> str :
447+ """Reject symlinks below a trusted root before accessing submodule paths."""
448+ if root is None :
449+ raise NotADirectoryError ("Submodules require a working tree" )
450+ path = os .fspath (root )
451+ components = to_native_path_linux (relative_path ).split ("/" )
452+ for index , component in enumerate (components ):
453+ path = os .fspath (join_path_native (path , component ))
454+ if allow_final_symlink and index == len (components ) - 1 :
455+ break
425456 if osp .islink (path ):
426- raise ValueError ("Submodule checkout path %r contains a symbolic link" % self . path )
457+ raise ValueError ("Submodule path %r contains a symbolic link" % relative_path )
427458 return path
428459
460+ @staticmethod
461+ def _renames (source : PathLike , destination : PathLike ) -> None :
462+ os .makedirs (osp .dirname (destination ), exist_ok = True )
463+ os .rename (source , destination )
464+ # Match renames() cleanup, but stop before directory symlinks: Windows
465+ # rmdir() removes the link even when its target is nonempty.
466+ parent = osp .dirname (source )
467+ while parent and not osp .islink (parent ):
468+ try :
469+ os .rmdir (parent )
470+ except OSError :
471+ break
472+ parent = osp .dirname (parent )
473+
429474 @classmethod
430475 def _write_git_file_and_module_config (cls , working_tree_dir : PathLike , module_abspath : PathLike ) -> None :
431476 """Write a ``.git`` file containing a (preferably) relative path to the actual
@@ -449,14 +494,19 @@ def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_ab
449494 :param module_abspath:
450495 Absolute path to the bare repository.
451496 """
497+ # Git resolves metadata symlinks before interpreting core.worktree.
498+ # Path.resolve() also handles Windows symlinks on Python 3.7.
499+ module_abspath = str (Path (module_abspath ).resolve ())
500+ working_tree_dir = str (Path (working_tree_dir ).resolve ())
452501 git_file = osp .join (working_tree_dir , ".git" )
502+ module_config = osp .join (module_abspath , "config" )
453503 rela_path = osp .relpath (module_abspath , start = working_tree_dir )
454504 if sys .platform == "win32" and osp .isfile (git_file ):
455505 os .remove (git_file )
456506 with open (git_file , "wb" ) as fp :
457507 fp .write (("gitdir: %s" % rela_path ).encode (defenc ))
458508
459- with GitConfigParser (osp . join ( module_abspath , "config" ) , read_only = False , merge_includes = False ) as writer :
509+ with GitConfigParser (module_config , read_only = False , merge_includes = False ) as writer :
460510 writer .set_value (
461511 "core" ,
462512 "worktree" ,
@@ -567,6 +617,8 @@ def add(
567617 name ,
568618 url = "invalid-temporary" ,
569619 )
620+ cls ._checked_abspath (repo .working_tree_dir , cls .k_modules_file )
621+ sm ._checkout_abspath (path )
570622 if sm .exists ():
571623 # Reretrieve submodule from tree.
572624 try :
@@ -661,7 +713,9 @@ def add(
661713
662714 # We deliberately assume that our head matches our index!
663715 if mrepo :
664- sm .binsha = mrepo .head .commit .binsha
716+ # Release cat-file processes before callers move the checkout on Windows.
717+ with mrepo :
718+ sm .binsha = mrepo .head .commit .binsha
665719 index .add ([sm ], write = True )
666720
667721 return sm
@@ -1039,7 +1093,8 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10391093 self
10401094
10411095 :raise ValueError:
1042- If the module path existed and was not empty, or was a file.
1096+ If the module path existed and was not empty, was a file, or had a
1097+ symbolic link in an intermediate component.
10431098
10441099 :note:
10451100 Currently the method is not atomic, and it could leave the repository in an
@@ -1057,7 +1112,11 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10571112 return self
10581113 # END handle no change
10591114
1060- module_checkout_abspath = join_path_native (str (self .repo .working_tree_dir ), module_checkout_path )
1115+ if configuration :
1116+ self ._checked_abspath (self .repo .working_tree_dir , self .k_modules_file )
1117+ # Validate the source before removing the destination.
1118+ cur_path = self .abspath
1119+ module_checkout_abspath = self ._checkout_abspath (module_checkout_path , allow_final_symlink = True )
10611120 if osp .isfile (module_checkout_abspath ):
10621121 raise ValueError ("Cannot move repository onto a file: %s" % module_checkout_abspath )
10631122 # END handle target files
@@ -1089,10 +1148,9 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10891148 # END handle module
10901149
10911150 # Move the module into place if possible.
1092- cur_path = self .abspath
10931151 renamed_module = False
10941152 if module and osp .exists (cur_path ):
1095- os . renames (cur_path , module_checkout_abspath )
1153+ self . _renames (cur_path , module_checkout_abspath )
10961154 renamed_module = True
10971155
10981156 if osp .isfile (osp .join (module_checkout_abspath , ".git" )):
@@ -1123,7 +1181,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
11231181 # END handle configuration flag
11241182 except Exception :
11251183 if renamed_module :
1126- os . renames (module_checkout_abspath , cur_path )
1184+ self . _renames (module_checkout_abspath , cur_path )
11271185 # END undo module renaming
11281186 raise
11291187 # END handle undo rename
@@ -1180,6 +1238,12 @@ def remove(
11801238 Doesn't work atomically, as failure to remove any part of the submodule will
11811239 leave an inconsistent state.
11821240
1241+ :note:
1242+ Metadata-directory aliases under ``.git/modules`` are retained. A link
1243+ directly to the deleted repository becomes dangling; adding or initializing
1244+ the submodule again recreates its target. Linked parent directories remain
1245+ available to sibling submodules.
1246+
11831247 :raise git.exc.InvalidGitRepositoryError:
11841248 Thrown if the repository cannot be deleted.
11851249
@@ -1191,6 +1255,8 @@ def remove(
11911255 # END handle parameters
11921256
11931257 self ._validated_name (self .name )
1258+ if configuration :
1259+ self ._checked_abspath (self .repo .working_tree_dir , self .k_modules_file )
11941260 # Recursively remove children of this submodule.
11951261 nc = 0
11961262 for csm in self .children ():
@@ -1209,7 +1275,7 @@ def remove(
12091275 ################################
12101276 if module and self .module_exists ():
12111277 mod = self .module ()
1212- git_dir = mod .git_dir
1278+ git_dir = str ( Path ( mod .git_dir ). resolve ())
12131279 if force :
12141280 # Take the fast lane and just delete everything in our module path.
12151281 # TODO: If we run into permission problems, we have a highly
@@ -1450,6 +1516,9 @@ def rename(self, new_name: str) -> "Submodule":
14501516
14511517 self ._validated_name (self .name )
14521518 self ._validated_name (new_name )
1519+ destination_module_abspath = self ._module_abspath (self .repo , self .path , new_name )
1520+ mod = self .module ()
1521+ self ._checked_abspath (self .repo .working_tree_dir , self .k_modules_file )
14531522
14541523 # .git/config
14551524 with self .repo .config_writer () as pw :
@@ -1466,17 +1535,15 @@ def rename(self, new_name: str) -> "Submodule":
14661535 self ._name = new_name
14671536
14681537 # .git/modules
1469- mod = self .module ()
14701538 if mod .has_separate_working_tree ():
1471- destination_module_abspath = self ._module_abspath (self .repo , self .path , new_name )
14721539 source_dir = mod .git_dir
14731540 # Let's be sure the submodule name is not so obviously tied to a directory.
14741541 if str (destination_module_abspath ).startswith (str (mod .git_dir )):
14751542 tmp_dir = self ._module_abspath (self .repo , self .path , str (uuid .uuid4 ()))
1476- os . renames (source_dir , tmp_dir )
1543+ self . _renames (source_dir , tmp_dir )
14771544 source_dir = tmp_dir
14781545 # END handle self-containment
1479- os . renames (source_dir , destination_module_abspath )
1546+ self . _renames (source_dir , destination_module_abspath )
14801547 if mod .working_tree_dir :
14811548 self ._write_git_file_and_module_config (mod .working_tree_dir , destination_module_abspath )
14821549 # END move separate git repository
0 commit comments