Skip to content

Commit fb5e11f

Browse files
committed
review
<!-- agent --> - [P2] Fall back when a relative URL has no tracking remote — git/objects/submodule/base.py:432-435 For a locally initialized superproject without a tracking branch, `create_submodule(..., url='./source')` now enters this block and dereferences `None`, raising `AttributeError` even when `./source` is a valid repository. This addition succeeded before the patch; detached checkouts also fail at `active_branch`. Handle missing tracking branches by falling back to `origin` or the superproject directory, as Git does. - [P2] Resolve relative submodule URLs only once — git/objects/submodule/base.py:986-988 When `origin` is a local relative URL such as `../repos/super.git` and the branch tracks that remote, `_resolved_url()` turns `../child.git` into `../repos/child.git`. Because that result still starts with `../`, `_clone_repo()` resolves it again, producing `../repos/repos/child.git`. This can fail cloning or select the wrong repository. Ensure URLs already resolved by `update()` bypass the second resolution. - [P2] Defer old-URL resolution until a remote change is needed — git/objects/submodule/root.py:269-270 For an initialized superproject without `origin`, changing `.gitmodules` from `../source` to the absolute URL already used by the submodule's remote now raises `AttributeError` in `psm._resolved_url()`. Previously, the existing-remote check skipped URL handling and the update completed. Resolve the previous URL only after checking whether the new URL already matches a remote, so updates requiring no remote adjustment do not fail.
1 parent 833c8d8 commit fb5e11f

3 files changed

Lines changed: 93 additions & 18 deletions

File tree

‎git/objects/submodule/base.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
if TYPE_CHECKING:
7070
from git.index import IndexFile
7171
from git.objects.commit import Commit
72-
from git.refs import Head, RemoteReference
72+
from git.refs import Head
7373
from git.repo import Repo
7474

7575
# -----------------------------------------------------------------------------
@@ -429,13 +429,8 @@ def _clone_repo(
429429
if not osp.isdir(module_abspath_dir):
430430
os.makedirs(module_abspath_dir)
431431

432-
if is_relative_url(url):
433-
remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name
434-
repo_remote_url = repo.remote(remote_name).url
435-
url = resolve_relative_url(repo_remote_url, url)
436-
437432
clone = git.Repo.clone_from(
438-
url,
433+
cls._resolve_url(repo, url),
439434
module_checkout_path,
440435
allow_unsafe_options=allow_unsafe_options,
441436
allow_unsafe_protocols=allow_unsafe_protocols,
@@ -985,7 +980,7 @@ def fetch_remotes(module_repo: "Repo") -> None:
985980
raise ValueError("Missing module at %r but fetching is disabled" % self.path) from None
986981
mrepo = self._clone_repo(
987982
self.repo,
988-
self._resolved_url(),
983+
self.url,
989984
self.path,
990985
self.name,
991986
n=True,
@@ -1735,12 +1730,25 @@ def url(self) -> str:
17351730
""":return: The url to the repository our submodule's repository refers to"""
17361731
return self._url
17371732

1733+
@staticmethod
1734+
def _resolve_url(repo: "Repo", url: str) -> str:
1735+
"""Resolve relative URLs against the tracking remote, then ``origin``,
1736+
falling back to the superproject's directory the way git does."""
1737+
if not is_relative_url(url):
1738+
return url
1739+
tracking_branch = None if repo.head.is_detached else repo.active_branch.tracking_branch()
1740+
remote_name = tracking_branch.remote_name if tracking_branch is not None else "origin"
1741+
try:
1742+
remote_url = repo.remote(remote_name).url
1743+
except ValueError:
1744+
remote_url = os.fspath(repo.working_dir)
1745+
if remote_url == ".":
1746+
remote_url = os.fspath(repo.working_dir)
1747+
return resolve_relative_url(remote_url, url)
1748+
17381749
def _resolved_url(self) -> str:
1739-
""":return: :attr:`url`, with a ``./`` or ``../`` prefix resolved against the
1740-
url of the superproject's ``origin`` remote the way git does"""
1741-
if not is_relative_url(self.url):
1742-
return self.url
1743-
return resolve_relative_url(self.repo.remotes.origin.url, self.url)
1750+
""":return: :attr:`url` resolved against the superproject's default remote"""
1751+
return self._resolve_url(self.repo, self.url)
17441752

17451753
@property
17461754
def parent_commit(self) -> "Commit":

‎git/objects/submodule/root.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,6 @@ def update( # type: ignore[override]
267267
# Relative urls are resolved against the superproject's remote,
268268
# which is what the module's remotes hold.
269269
sm_url = sm._resolved_url()
270-
psm_url = psm._resolved_url()
271270

272271
# Don't do anything if we already have the url we search in
273272
# place.
@@ -280,6 +279,7 @@ def update( # type: ignore[override]
280279
)
281280

282281
if not dry_run:
282+
psm_url = psm._resolved_url()
283283
previous_remote = None
284284
for remote in rmts:
285285
if remote.url == psm_url:

‎test/test_submodule.py‎

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,72 @@ def _patch_git_config(name, value):
5353
yield
5454

5555

56+
@pytest.mark.parametrize("state", ["unborn", "untracked", "detached"])
57+
@pytest.mark.parametrize("remote", [None, ".", "origin"], ids=["working-tree", "local-origin", "origin"])
58+
def test_add_relative_submodule_without_tracking_remote(tmp_path, state, remote):
59+
with git.Repo.init(tmp_path / "parent") as parent:
60+
if state != "unborn":
61+
parent.index.commit("Initial commit")
62+
if state == "detached":
63+
parent.head.reference = parent.head.commit
64+
base = tmp_path / "remote" if remote == "origin" else tmp_path / "parent"
65+
if remote is not None:
66+
parent.create_remote("origin", str(base) if remote == "origin" else ".")
67+
with git.Repo.init(base / "source") as source:
68+
source.index.commit("Initial commit")
69+
submodule = parent.create_submodule("module", "module", "./source")
70+
assert submodule.url == "./source"
71+
with submodule.module() as module:
72+
assert module.head.commit == source.head.commit
73+
assert Path(module.remotes.origin.url).samefile(source.working_tree_dir)
74+
75+
76+
def test_update_resolves_local_relative_remote_once(tmp_path):
77+
with git.Repo.init(tmp_path / "parent") as parent, git.Repo.init(tmp_path / "repos" / "child.git") as source:
78+
source.git.symbolic_ref("HEAD", "refs/heads/master")
79+
source.index.commit("Initial commit")
80+
parent.create_remote("origin", "../repos/super.git")
81+
parent.head.reference.set_tracking_branch(git.RemoteReference(parent, "refs/remotes/origin/master"))
82+
submodule = Submodule(
83+
parent,
84+
source.head.commit.binsha,
85+
name="module",
86+
path="module",
87+
url="../child.git",
88+
branch_path="refs/heads/master",
89+
)
90+
with cwd(parent.working_tree_dir):
91+
submodule.update(init=True)
92+
with submodule.module() as module:
93+
assert module.head.commit == source.head.commit
94+
assert Path(module.remotes.origin.url).samefile(source.working_tree_dir)
95+
96+
97+
@pytest.mark.parametrize("dry_run", [False, True])
98+
def test_root_update_skips_old_url_resolution_when_remote_matches(tmp_path, dry_run):
99+
with git.Repo.init(tmp_path / "source") as source, git.Repo.init(tmp_path / "parent") as parent:
100+
source.index.commit("Initial commit")
101+
submodule = parent.create_submodule("module", "module", source.working_tree_dir)
102+
with submodule.config_writer() as writer:
103+
writer.set_value("url", "../source")
104+
previous = parent.index.commit("Record relative submodule URL")
105+
with submodule.config_writer() as writer:
106+
writer.set_value("url", source.working_tree_dir)
107+
parent.index.commit("Use existing remote URL")
108+
109+
resolve_url = Submodule._resolved_url
110+
111+
def resolve_current_url(sm):
112+
assert sm.url != "../source", "Previous URL does not need resolving"
113+
return resolve_url(sm)
114+
115+
with mock.patch.object(Submodule, "_resolved_url", autospec=True, side_effect=resolve_current_url):
116+
RootModule(parent).update(previous_commit=previous, no_fetch=True, dry_run=dry_run)
117+
with submodule.module() as module:
118+
assert module.remotes.origin.url == Git.polish_url(source.working_tree_dir)
119+
assert module.head.commit == source.head.commit
120+
121+
56122
@pytest.mark.parametrize(
57123
"name", ["module", "$GITPYTHON_TEST_SECRET", "prefix-${GITPYTHON_TEST_SECRET}-suffix", "%GITPYTHON_TEST_SECRET%"]
58124
)
@@ -2459,17 +2525,18 @@ def test_update_resolves_relative_url_against_remote(self, rwdir):
24592525
("../planted", "ssh://git@host/org/planted"),
24602526
]:
24612527
submodule = Submodule(parent, Submodule.NULL_BIN_SHA, name="module", path="module", url=url)
2462-
with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("stop")) as clone_repo:
2528+
with mock.patch.object(git.Repo, "clone_from", side_effect=AssertionError("stop")) as clone_from:
24632529
with pytest.raises(AssertionError, match="stop"):
24642530
submodule.update(init=True)
2465-
assert clone_repo.call_args.args[1] == expected
2531+
assert clone_from.call_args.args[0] == expected
24662532

24672533
@with_rw_directory
24682534
def test_clone_repo_resolves_relative_url_against_tracking_remote(self, rwdir):
24692535
parent = git.Repo.init(osp.join(rwdir, "parent"))
24702536
parent.index.commit("initial")
2471-
parent.create_remote("origin", "git@host:org/super.git")
2472-
parent.head.reference.set_tracking_branch(git.RemoteReference(parent, "refs/remotes/origin/main"))
2537+
parent.create_remote("origin", "git@host:other/super.git")
2538+
parent.create_remote("upstream", "git@host:org/super.git")
2539+
parent.head.reference.set_tracking_branch(git.RemoteReference(parent, "refs/remotes/upstream/main"))
24732540

24742541
for url, expected in [
24752542
("./planted", "git@host:org/super.git/planted"),

0 commit comments

Comments
 (0)