Repository navigation
fix(submodule): resolve relative submodule URLs the way git does - #2289
Open
Keerthana-64 wants to merge 2 commits into
Open
Keerthana-64 wants to merge 2 commits into
Keerthana-64 wants to merge 2 commits into
Conversation
`Submodule.update` resolved a `.gitmodules` URL starting with `./` or `../` with `urllib.parse.urljoin` against the superproject's `origin` URL, and `Submodule._clone_repo` appended a `../` URL to the tracking remote's URL with `os.path.join`. Neither follows git's `relative_url`, and `urljoin` only resolves against the schemes it knows: for an `ssh://` or `git://` origin it returns the relative URL unchanged, and for an scp-like `host:path` origin more than one `../` collapses to a bare path. `git clone` then received `./planted`, a path inside the superproject's checkout, where git resolves the same entry to `ssh://host/org/super.git/planted`. A submodule was therefore cloned from a directory committed by whoever authored the superproject, through git's local clone path: `git upload-pack` ran inside that directory and its objects were copied without the `transfer.fsckObjects` checks a transport fetch applies (an object that `git clone file://...` rejects as `badDate` is accepted this way). Relative submodule URLs are common in multi-repository setups, and the superproject is untrusted input to any service that updates submodules of repositories it is given. Add `resolve_relative_url`, a port of git's `relative_url`: each leading `../` strips one component of the remote URL, a path component first, then the scp-like host before the colon, and running out of components is an error; `./` prefixes are dropped and the rest is appended. It agrees with what `git submodule init` records over 272 remote and URL pairs, including the error cases. Use it in `Submodule.update`, `Submodule._clone_repo` (which now also resolves `./` URLs, previously passed to `git clone` verbatim), and the URL change path of `RootModule.update`, which created a remote from the raw relative URL and fetched it as a path relative to the module's working tree. The remote used for resolution is unchanged: `origin` in `update`, the tracking branch's remote in `_clone_repo`. Adds regression tests in `test/test_submodule.py`; the two behavioral ones fail before the change with `./planted` reaching the clone unchanged.
Byron
pushed a commit
to GitoxideLabs/gitoxide
that referenced
this pull request
Oct 16, 2026
Relative submodule URLs in `.gitmodules` refer to the superproject's upstream, even when that upstream uses `ssh://`, `git://`, or SCP syntax. Passing the existing `Submodule::url()` result directly to a clone treats `./` and `../` entries as local paths instead. GitPython's corresponding fix prompted this investigation: gitpython-developers/GitPython#2289 Document `url()` as the raw configured accessor and add `resolved_url()` for cloning or fetching. Port Git's byte-oriented `relative_url()` rules from `remote.c`, select the current branch's remote or the default remote, and fall back to the superproject itself when the selected URL is missing. Make local paths absolute so the result does not depend on the caller's working directory. Honor configuration overrides as initialized URLs, avoiding a second remote-relative resolution of local URLs written by `git submodule init`. Validate transport and local URLs, traversal errors, default-remote selection, initialized overrides, unborn and bare repositories, and non-UTF-8 local paths in disposable repositories. Compare resolution with `git submodule init`; include native-separator coverage for Windows. Validation: 34 submodule tests pass, the four new tests pass with SHA-256 fixtures, focused Clippy passes with warnings denied, and the `sha256,attributes` build passes. Documentation builds; strict rustdoc checking encounters the existing broken `Self::with_fetch_options` link in `gix/src/clone/access.rs`.
I don't really know what I am doing, rubber-stamped the tests, and really just wanted to get it done, knowing that the next version removes the custom submodule implementation entirely in favor of calling out to Git. <!-- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Submodule.updateresolves a./or../submodule URL withurllib.parse.urljoinagainst theoriginURL, andSubmodule._clone_repojoins a../URL onto the tracking remote's URL withos.path.join.urljoinonly resolves against the schemes it knows, so for anssh://orgit://superproject the relative URL comes back unchanged andgit cloneis handed./planted, a path inside the superproject's checkout; with an scp-likehost:pathorigin,../../plantedcollapses to the bare pathplanted. For the same.gitmodulesentry,git submodule initrecordsssh://host/org/super.git/planted:The superproject's author controls both
.gitmodulesand the tree, so a committed directory laid out as a bare repository becomes the submodule's source. That is git's local clone path:git upload-packruns inside the attacker's directory, and the objects are copied without thetransfer.fsckObjectschecks a transport fetch applies (a commit thatgit clone file://...refuses asbadDateis accepted). Relative URLs are common in multi-repository setups, and anything that updates submodules of repositories it is given treats the superproject as untrusted.resolve_relative_urlingit/objects/submodule/util.pyis a port of git'srelative_url: each../strips one component of the remote URL, a path component first and then the scp-like host before the colon, running out of components is an error,./is dropped and the remainder appended. Checked against whatgit submodule initwrites tosubmodule.<name>.urlon git 2.54 for 272 remote and URL pairs (ssh://,host:path,https://,file://,//share, absolute and relative paths, trailing slashes,.sub,..//sub), with no differences including the error cases. It replacesurljoininSubmodule.update,os.path.joinin_clone_repo(which now also resolves./, previously passed togit cloneverbatim), and the raw relative URL that the URL change path ofRootModule.updategavecreate_remoteandset_url. The remote each site resolves against is unchanged. The two behavioral tests intest/test_submodule.pyfail before the change with./plantedreaching the clone unchanged;test_submodule.pypasses, andruff,mypyandbasedpyright --warningsreport nothing new.