Skip to content

Commit 28ab8fb

Browse files
authored
Merge pull request #2289 from Keerthana-64/submodule-relative-url
fix(submodule): resolve relative submodule URLs the way git does
2 parents c6cb407 + 0a5938d commit 28ab8fb

4 files changed

Lines changed: 350 additions & 38 deletions

File tree

‎git/objects/submodule/base.py‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import shlex
1313
import stat
1414
import sys
15-
import urllib.parse
1615
import uuid
1716
from io import BytesIO
1817
from pathlib import Path
@@ -57,8 +56,12 @@
5756

5857
from .util import (
5958
SubmoduleConfigParser,
59+
_is_absolute_path,
60+
_url_is_local_not_ssh,
6061
find_first_remote_branch,
62+
is_relative_url,
6163
mkhead,
64+
resolve_relative_url,
6265
sm_name,
6366
sm_section,
6467
)
@@ -68,7 +71,7 @@
6871
if TYPE_CHECKING:
6972
from git.index import IndexFile
7073
from git.objects.commit import Commit
71-
from git.refs import Head, RemoteReference
74+
from git.refs import Head
7275
from git.repo import Repo
7376

7477
# -----------------------------------------------------------------------------
@@ -428,13 +431,8 @@ def _clone_repo(
428431
if not osp.isdir(module_abspath_dir):
429432
os.makedirs(module_abspath_dir)
430433

431-
if url.startswith("../"):
432-
remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name
433-
repo_remote_url = repo.remote(remote_name).url
434-
url = os.path.join(repo_remote_url, url)
435-
436434
clone = git.Repo.clone_from(
437-
url,
435+
cls._resolve_url(repo, url),
438436
module_checkout_path,
439437
allow_unsafe_options=allow_unsafe_options,
440438
allow_unsafe_protocols=allow_unsafe_protocols,
@@ -746,8 +744,9 @@ def add(
746744
)
747745
# END verify url
748746

749-
## See #525 for ensuring git URLs in config-files are valid under Windows.
750-
url = Git.polish_url(url, expand_vars=False)
747+
# Relative submodule URLs must keep their prefix to resolve against the parent remote.
748+
if not is_relative_url(url):
749+
url = Git.polish_url(url, expand_vars=False)
751750

752751
# It's important to add the URL to the parent config, to let `git submodule` know.
753752
# Otherwise there is a '-' character in front of the submodule listing:
@@ -982,13 +981,9 @@ def fetch_remotes(module_repo: "Repo") -> None:
982981
if not dry_run:
983982
if no_fetch:
984983
raise ValueError("Missing module at %r but fetching is disabled" % self.path) from None
985-
if self.url.startswith("."):
986-
url = urllib.parse.urljoin(self.repo.remotes.origin.url + "/", self.url)
987-
else:
988-
url = self.url
989984
mrepo = self._clone_repo(
990985
self.repo,
991-
url,
986+
self.url,
992987
self.path,
993988
self.name,
994989
n=True,
@@ -1738,6 +1733,31 @@ def url(self) -> str:
17381733
""":return: The url to the repository our submodule's repository refers to"""
17391734
return self._url
17401735

1736+
@staticmethod
1737+
def _resolve_url(repo: "Repo", url: str) -> str:
1738+
"""Resolve relative URLs against the tracking remote, then ``origin``,
1739+
falling back to the superproject's directory the way git does."""
1740+
if not is_relative_url(url):
1741+
return url
1742+
remote_name = "origin"
1743+
if not repo.head.is_detached:
1744+
with repo.active_branch.config_reader() as reader:
1745+
remote_name = reader.get("remote", fallback="origin")
1746+
try:
1747+
remote_url = repo.remote(remote_name).url
1748+
except ValueError:
1749+
remote_url = os.fspath(repo.working_dir)
1750+
if remote_url == ".":
1751+
remote_url = os.fspath(repo.working_dir)
1752+
resolved = resolve_relative_url(remote_url, url)
1753+
if resolved and _url_is_local_not_ssh(resolved) and not _is_absolute_path(resolved):
1754+
resolved = os.path.join(repo.working_dir, resolved)
1755+
return resolved
1756+
1757+
def _resolved_url(self) -> str:
1758+
""":return: :attr:`url` resolved against the superproject's default remote"""
1759+
return self._resolve_url(self.repo, self.url)
1760+
17411761
@property
17421762
def parent_commit(self) -> "Commit":
17431763
"""

‎git/objects/submodule/root.py‎

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -264,10 +264,13 @@ def update( # type: ignore[override]
264264
nn = "__new_origin__"
265265
smm = sm.module()
266266
rmts = smm.remotes
267+
# Relative urls are resolved against the superproject's remote,
268+
# which is what the module's remotes hold.
269+
sm_url = sm._resolved_url()
267270

268271
# Don't do anything if we already have the url we search in
269272
# place.
270-
if len([r for r in rmts if r.url == sm.url]) == 0:
273+
if len([r for r in rmts if r.url == sm_url]) == 0:
271274
progress.update(
272275
BEGIN | URLCHANGE,
273276
i,
@@ -277,38 +280,31 @@ def update( # type: ignore[override]
277280

278281
if not dry_run:
279282
previous_remote = None
280-
for remote in rmts:
281-
if remote.url == psm.url:
282-
previous_remote = remote
283-
break
284-
# END if urls match
285-
# END for each remote
283+
if len(rmts) == 1:
284+
previous_remote = rmts[0]
285+
else:
286+
psm_url = psm._resolved_url()
287+
for remote in rmts:
288+
if remote.url == psm_url:
289+
previous_remote = remote
290+
break
291+
# END if urls match
292+
# END for each remote
286293

287-
# If we didn't find a matching remote, but have exactly
288-
# one, we can safely use this one.
289294
if previous_remote is None:
290-
if len(rmts) == 1:
291-
previous_remote = rmts[0]
292-
else:
293-
# If we have not found any remote with the
294-
# original URL we may not have a name. This is a
295-
# special case, and its okay to fail here.
296-
# Alternatively we could just generate a unique
297-
# name and leave all existing ones in place.
298-
raise InvalidGitRepositoryError(
299-
"Couldn't find original remote-repo at url %r" % psm.url
300-
)
301-
# END handle one single remote
295+
raise InvalidGitRepositoryError(
296+
"Couldn't find original remote-repo at url %r" % psm.url
297+
)
302298
# END handle check we found a remote
303299

304300
if no_fetch:
305301
# A new remote would have no cached refs. Preserve
306302
# the existing refs and tracking configuration for
307303
# offline updates instead of replacing the remote.
308-
previous_remote.set_url(git.Git.polish_url(sm.url, expand_vars=False))
304+
previous_remote.set_url(git.Git.polish_url(sm_url, expand_vars=False))
309305
else:
310306
assert nn not in [r.name for r in rmts]
311-
smr = smm.create_remote(nn, sm.url)
307+
smr = smm.create_remote(nn, sm_url)
312308
smr.fetch(progress=progress)
313309

314310
# If we have a tracking branch, it should be available

‎git/objects/submodule/util.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
"sm_name",
77
"mkhead",
88
"find_first_remote_branch",
9+
"is_relative_url",
10+
"resolve_relative_url",
911
"SubmoduleConfigParser",
1012
]
1113

1214
from io import BytesIO
15+
import sys
1316
import weakref
1417

1518
import git
@@ -63,6 +66,97 @@ def find_first_remote_branch(remotes: Sequence["Remote"], branch_name: str) -> "
6366
raise InvalidGitRepositoryError("Didn't find remote branch '%r' in any of the given remotes" % branch_name)
6467

6568

69+
def _is_dir_sep(char: str) -> bool:
70+
return char == "/" or (sys.platform == "win32" and char == "\\")
71+
72+
73+
def _starts_with_dot_slash(url: str) -> bool:
74+
return len(url) >= 2 and url[0] == "." and _is_dir_sep(url[1])
75+
76+
77+
def _starts_with_dot_dot_slash(url: str) -> bool:
78+
return len(url) >= 3 and url[0:2] == ".." and _is_dir_sep(url[2])
79+
80+
81+
def _has_dos_drive_prefix(url: str) -> bool:
82+
return sys.platform == "win32" and len(url) >= 2 and url[0].isalpha() and url[1] == ":"
83+
84+
85+
def _is_absolute_path(url: str) -> bool:
86+
return (len(url) >= 1 and _is_dir_sep(url[0])) or _has_dos_drive_prefix(url)
87+
88+
89+
def _url_is_local_not_ssh(url: str) -> bool:
90+
colon = url.find(":")
91+
slash = url.find("/")
92+
return colon == -1 or (slash != -1 and slash < colon) or _has_dos_drive_prefix(url)
93+
94+
95+
def _find_last_dir_sep(url: str) -> int:
96+
for index in range(len(url) - 1, -1, -1):
97+
if _is_dir_sep(url[index]):
98+
return index
99+
return -1
100+
101+
102+
def is_relative_url(url: str) -> bool:
103+
""":return: ``True`` if `url` starts with ``./`` or ``../``, which git resolves
104+
against the superproject's remote rather than treating as a path"""
105+
return _starts_with_dot_slash(url) or _starts_with_dot_dot_slash(url)
106+
107+
108+
def resolve_relative_url(remote_url: str, url: str) -> str:
109+
"""Resolve a submodule URL that starts with ``./`` or ``../`` against the
110+
superproject's remote URL, the way git's ``relative_url`` does.
111+
112+
Git treats a relative submodule URL like a relative directory: each leading
113+
``../`` removes the last component of `remote_url`, whether that is a path
114+
component, the host of a URL, or the host of an scp-like ``host:path`` address.
115+
The remainder is appended with ``/``, or with ``:`` once an scp-like host was
116+
reached. A URL that is not relative is returned unchanged.
117+
118+
:raise ValueError:
119+
If more components are stripped than `remote_url` has, which git rejects with
120+
"cannot strip one component off url".
121+
"""
122+
if not is_relative_url(url):
123+
return url
124+
125+
remote = remote_url
126+
if remote and _is_dir_sep(remote[-1]):
127+
remote = remote[:-1]
128+
129+
is_relative = _url_is_local_not_ssh(remote) and not _is_absolute_path(remote)
130+
if is_relative and not _starts_with_dot_slash(remote) and not _starts_with_dot_dot_slash(remote):
131+
remote = "./" + remote
132+
133+
colonsep = False
134+
while url:
135+
if _starts_with_dot_dot_slash(url):
136+
url = url[3:]
137+
separator = _find_last_dir_sep(remote)
138+
if separator != -1:
139+
remote = remote[:separator]
140+
elif ":" in remote:
141+
remote = remote[: remote.rfind(":")]
142+
colonsep = True
143+
elif is_relative or remote == ".":
144+
raise ValueError("cannot strip one component off url %r" % remote)
145+
else:
146+
remote = "."
147+
elif _starts_with_dot_slash(url):
148+
url = url[2:]
149+
else:
150+
break
151+
152+
resolved = remote + (":" if colonsep else "/") + url
153+
if url.endswith("/"):
154+
resolved = resolved[:-1]
155+
if _starts_with_dot_slash(resolved):
156+
resolved = resolved[2:]
157+
return resolved
158+
159+
66160
# } END utilities
67161

68162
# { Classes

0 commit comments

Comments
 (0)