Skip to content

Commit 3badd46

Browse files
committed
Fetching any submodule in a subproject with submodules
Fixes #1013
1 parent a35f838 commit 3badd46

12 files changed

Lines changed: 234 additions & 32 deletions

File tree

CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
Release 0.13.0 (unreleased)
2+
====================================
3+
4+
* Fetch git submodules in git subproject at pinned revision (#1015)
5+
* Add nested projects in subprojects to project report (#1015)
6+
17
Release 0.12.1 (released 2026-02-24)
28
====================================
39

dfetch/project/gitsubproject.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from dfetch.log import get_logger
88
from dfetch.manifest.project import ProjectEntry
99
from dfetch.manifest.version import Version
10-
from dfetch.project.subproject import SubProject
11-
from dfetch.util.util import safe_rmtree
10+
from dfetch.project.subproject import NestedProject, SubProject
11+
from dfetch.util.util import safe_rm, safe_rmtree
1212
from dfetch.vcs.git import GitLocalRepo, GitRemote, get_git_version
1313

1414
logger = get_logger(__name__)
@@ -57,7 +57,7 @@ def list_tool_info() -> None:
5757
)
5858
SubProject._log_tool("git", "<not found in PATH>")
5959

60-
def _fetch_impl(self, version: Version) -> Version:
60+
def _fetch_impl(self, version: Version) -> tuple[Version, list[NestedProject]]:
6161
"""Get the revision of the remote and place it at the local path."""
6262
rev_or_branch_or_tag = self._determine_what_to_fetch(version)
6363

@@ -69,17 +69,35 @@ def _fetch_impl(self, version: Version) -> Version:
6969
]
7070

7171
local_repo = GitLocalRepo(self.local_path)
72-
fetched_sha = local_repo.checkout_version(
72+
fetched_sha, submodules = local_repo.checkout_version(
7373
remote=self.remote,
7474
version=rev_or_branch_or_tag,
7575
src=self.source,
76-
must_keeps=license_globs,
76+
must_keeps=license_globs + [".gitmodules"],
7777
ignore=self.ignore,
7878
)
7979

80+
nested = []
81+
for submodule in submodules:
82+
self._log_project(
83+
f'Found & fetched submodule "./{submodule.path}" '
84+
f" ({submodule.url} @ {Version(tag=submodule.tag, branch=submodule.branch, revision=submodule.sha)})",
85+
)
86+
nested.append(
87+
NestedProject(
88+
remote_url=submodule.url,
89+
destination=submodule.path,
90+
branch=submodule.branch,
91+
tag=submodule.tag,
92+
revision=submodule.sha,
93+
kind="git-submodule",
94+
)
95+
)
96+
8097
safe_rmtree(os.path.join(self.local_path, local_repo.METADATA_DIR))
98+
safe_rm(os.path.join(self.local_path, local_repo.GIT_MODULES_FILE))
8199

82-
return self._determine_fetched_version(version, fetched_sha)
100+
return self._determine_fetched_version(version, fetched_sha), nested
83101

84102
def _determine_what_to_fetch(self, version: Version) -> str:
85103
"""Based on asked version, target to fetch."""

dfetch/project/metadata.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@
1616
"""
1717

1818

19+
class Nested(TypedDict): # pylint: disable=too-many-ancestors
20+
"""Argument types for Metadata class construction."""
21+
22+
branch: str
23+
tag: str
24+
revision: str
25+
remote_url: str
26+
destination: str
27+
kind: str
28+
29+
1930
class Options(TypedDict): # pylint: disable=too-many-ancestors
2031
"""Argument types for Metadata class construction."""
2132

@@ -27,6 +38,7 @@ class Options(TypedDict): # pylint: disable=too-many-ancestors
2738
destination: str
2839
hash: str
2940
patch: str | list[str]
41+
nested: list["Nested"]
3042

3143

3244
class Metadata:
@@ -54,6 +66,8 @@ def __init__(self, kwargs: Options) -> None:
5466
# Historically only a single patch was allowed
5567
self._patch: list[str] = always_str_list(kwargs.get("patch", []))
5668

69+
self._nested: list[Nested] = kwargs.get("nested", [])
70+
5771
@classmethod
5872
def from_project_entry(cls, project: ProjectEntry) -> "Metadata":
5973
"""Create a metadata object from a project entry."""
@@ -66,6 +80,7 @@ def from_project_entry(cls, project: ProjectEntry) -> "Metadata":
6680
"last_fetch": datetime.datetime(2000, 1, 1, 0, 0, 0),
6781
"hash": "",
6882
"patch": project.patch,
83+
"nested": [],
6984
}
7085
return cls(data)
7186

@@ -77,13 +92,18 @@ def from_file(cls, path: str) -> "Metadata":
7792
return cls(data)
7893

7994
def fetched(
80-
self, version: Version, hash_: str = "", patch_: list[str] | None = None
95+
self,
96+
version: Version,
97+
hash_: str = "",
98+
patch_: list[str] | None = None,
99+
nested: list[Nested] | None = None,
81100
) -> None:
82101
"""Update metadata."""
83102
self._last_fetch = datetime.datetime.now()
84103
self._version = version
85104
self._hash = hash_
86105
self._patch = patch_ or []
106+
self._nested = nested or []
87107

88108
@property
89109
def version(self) -> Version:
@@ -129,6 +149,11 @@ def patch(self) -> list[str]:
129149
"""The list of applied patches as stored in the metadata."""
130150
return self._patch
131151

152+
@property
153+
def nested(self) -> list[Nested]:
154+
"""The list of nested projects as stored in the metadata."""
155+
return self._nested
156+
132157
@property
133158
def path(self) -> str:
134159
"""Path to metadata file."""
@@ -152,12 +177,13 @@ def __eq__(self, other: object) -> bool:
152177
other._version.revision == self._version.revision,
153178
other.hash == self.hash,
154179
other.patch == self.patch,
180+
other.nested == self.nested,
155181
]
156182
)
157183

158184
def dump(self) -> None:
159185
"""Dump metadata file to correct path."""
160-
metadata = {
186+
metadata: dict[str, dict[str, str | list[str] | list[Nested]]] = {
161187
"dfetch": {
162188
"remote_url": self.remote_url,
163189
"branch": self._version.branch,
@@ -169,6 +195,9 @@ def dump(self) -> None:
169195
}
170196
}
171197

198+
if self.nested:
199+
metadata["dfetch"]["nested"] = self.nested
200+
172201
with open(self.path, "w+", encoding="utf-8") as metadata_file:
173202
metadata_file.write(DONT_EDIT_WARNING)
174203
yaml.dump(metadata, metadata_file)

dfetch/project/subproject.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,42 @@
55
import pathlib
66
from abc import ABC, abstractmethod
77
from collections.abc import Sequence
8+
from typing import NamedTuple
89

910
from dfetch.log import get_logger
1011
from dfetch.manifest.project import ProjectEntry
1112
from dfetch.manifest.version import Version
1213
from dfetch.project.abstract_check_reporter import AbstractCheckReporter
13-
from dfetch.project.metadata import Metadata
14+
from dfetch.project.metadata import Metadata, Nested
1415
from dfetch.util.util import hash_directory, safe_rm
1516
from dfetch.util.versions import latest_tag_from_list
1617
from dfetch.vcs.patch import Patch
1718

1819
logger = get_logger(__name__)
1920

2021

22+
class NestedProject(NamedTuple):
23+
"""Information about a nested project."""
24+
25+
destination: str
26+
remote_url: str
27+
branch: str
28+
tag: str
29+
revision: str
30+
kind: str
31+
32+
def to_nested(self) -> Nested:
33+
"""Convert this nested project to a Nested object."""
34+
return Nested(
35+
destination=self.destination,
36+
remote_url=self.remote_url,
37+
branch=self.branch,
38+
tag=self.tag,
39+
revision=self.revision,
40+
kind=self.kind,
41+
)
42+
43+
2144
class SubProject(ABC):
2245
"""Abstract SubProject object.
2346
@@ -125,7 +148,7 @@ def update(
125148
f"Fetching {to_fetch}",
126149
enabled=self._show_animations,
127150
):
128-
actually_fetched = self._fetch_impl(to_fetch)
151+
actually_fetched, nested = self._fetch_impl(to_fetch)
129152
self._log_project(f"Fetched {actually_fetched}")
130153

131154
applied_patches = self._apply_patches(patch_count)
@@ -134,6 +157,7 @@ def update(
134157
actually_fetched,
135158
hash_=hash_directory(self.local_path, skiplist=[self.__metadata.FILENAME]),
136159
patch_=applied_patches,
160+
nested=[nested.to_nested() for nested in nested],
137161
)
138162

139163
logger.debug(f"Writing repo metadata to: {self.__metadata.path}")
@@ -381,7 +405,7 @@ def _are_there_local_changes(self, files_to_ignore: Sequence[str]) -> bool:
381405
)
382406

383407
@abstractmethod
384-
def _fetch_impl(self, version: Version) -> Version:
408+
def _fetch_impl(self, version: Version) -> tuple[Version, list[NestedProject]]:
385409
"""Fetch the given version of the subproject, should be implemented by the child class."""
386410

387411
@abstractmethod

dfetch/project/svnsubproject.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dfetch.log import get_logger
88
from dfetch.manifest.project import ProjectEntry
99
from dfetch.manifest.version import Version
10-
from dfetch.project.subproject import SubProject
10+
from dfetch.project.subproject import NestedProject, SubProject
1111
from dfetch.util.util import (
1212
find_matching_files,
1313
find_non_matching_files,
@@ -106,7 +106,7 @@ def _remove_ignored_files(self) -> None:
106106
if not (file_or_dir.is_file() and self.is_license_file(file_or_dir.name)):
107107
safe_rm(file_or_dir)
108108

109-
def _fetch_impl(self, version: Version) -> Version:
109+
def _fetch_impl(self, version: Version) -> tuple[Version, list[NestedProject]]:
110110
"""Get the revision of the remote and place it at the local path."""
111111
branch, branch_path, revision = self._determine_what_to_fetch(version)
112112
rev_arg = f"--revision {revision}" if revision else ""
@@ -147,7 +147,7 @@ def _fetch_impl(self, version: Version) -> Version:
147147
if self.ignore:
148148
self._remove_ignored_files()
149149

150-
return Version(tag=version.tag, branch=branch, revision=revision)
150+
return Version(tag=version.tag, branch=branch, revision=revision), []
151151

152152
@staticmethod
153153
def _parse_file_pattern(complete_path: str) -> tuple[str, str]:

dfetch/reporting/stdout_reporter.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ def add_project(
4040
" licenses", ",".join(license.name for license in licenses)
4141
)
4242

43+
if metadata.nested:
44+
logger.info("")
45+
logger.info(" nested:")
46+
for nested in metadata.nested:
47+
logger.print_info_field(" - path", nested.get("destination", ""))
48+
logger.print_info_field(" url", nested.get("remote_url", ""))
49+
logger.print_info_field(" branch", nested.get("branch", ""))
50+
logger.print_info_field(" tag", nested.get("tag", ""))
51+
logger.print_info_field(" revision", nested.get("revision", ""))
52+
logger.print_info_field(" kind", nested.get("kind", ""))
53+
logger.info("")
54+
4355
except FileNotFoundError:
4456
logger.print_info_field(" last fetch", "never")
4557

dfetch/util/util.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ def find_matching_files(directory: str, patterns: Sequence[str]) -> Iterator[Pat
4444

4545
def safe_rm(path: str | Path) -> None:
4646
"""Delete an file or directory safely."""
47-
if os.path.isdir(path):
48-
safe_rmtree(str(path))
49-
else:
50-
os.remove(path)
47+
if os.path.exists(path):
48+
if os.path.isdir(path):
49+
safe_rmtree(str(path))
50+
else:
51+
os.remove(path)
5152

5253

5354
def safe_rmtree(path: str) -> None:

dfetch/vcs/git.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ class GitLocalRepo:
233233
"""A git repository."""
234234

235235
METADATA_DIR = ".git"
236+
GIT_MODULES_FILE = ".gitmodules"
236237

237238
def __init__(self, path: str | Path = ".") -> None:
238239
"""Create a local git repo."""
@@ -258,7 +259,7 @@ def checkout_version( # pylint: disable=too-many-arguments
258259
src: str | None = None,
259260
must_keeps: list[str] | None = None,
260261
ignore: Sequence[str] | None = None,
261-
) -> str:
262+
) -> tuple[str, list[Submodule]]:
262263
"""Checkout a specific version from a given remote.
263264
264265
Args:
@@ -295,6 +296,14 @@ def checkout_version( # pylint: disable=too-many-arguments
295296
)
296297
run_on_cmdline(logger, ["git", "reset", "--hard", "FETCH_HEAD"])
297298

299+
run_on_cmdline(
300+
logger,
301+
["git", "submodule", "update", "--init", "--recursive"],
302+
env=_extend_env_for_non_interactive_mode(),
303+
)
304+
305+
submodules = self.submodules()
306+
298307
current_sha = (
299308
run_on_cmdline(logger, ["git", "rev-parse", "HEAD"])
300309
.stdout.decode()
@@ -304,7 +313,7 @@ def checkout_version( # pylint: disable=too-many-arguments
304313
if src:
305314
self.move_src_folder_up(remote, src)
306315

307-
return str(current_sha)
316+
return str(current_sha), submodules
308317

309318
def move_src_folder_up(self, remote: str, src: str) -> None:
310319
"""Move the files from the src folder into the root of the project.

0 commit comments

Comments
 (0)