Skip to content

Commit e01a9a6

Browse files
committed
Fetching any submodule in a subproject with submodules
Fixes #1013
1 parent 32f83e3 commit e01a9a6

15 files changed

Lines changed: 297 additions & 87 deletions

CHANGELOG.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
Release 0.13.0 (unreleased)
2+
====================================
3+
4+
* Rename child-manifests to sub-manifests in documentation and code (#1027)
5+
* Fetch git submodules in git subproject at pinned revision (#1013)
6+
* Add nested projects in subprojects to project report (#1017)
7+
* Make `dfetch report` output more yaml-like (#1017)
8+
19
Release 0.12.1 (released 2026-02-24)
210
====================================
311

412
* Fix missing unicode data in standalone binaries (#1014)
5-
* Rename child-manifests to sub-manifests in documentation and code (#1027)
613

714
Release 0.12.0 (released 2026-02-21)
815
====================================

dfetch/log.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ def print_info_line(self, name: str, info: str) -> None:
6262
self.info(f" [bold][bright_green]{name}:[/bright_green][/bold]")
6363
DLogger._printed_projects.add(name)
6464

65-
line = info.replace("\n", "\n ")
66-
self.info(f" [bold blue]> {line}[/bold blue]")
65+
if info:
66+
line = info.replace("\n", "\n ")
67+
self.info(f" [bold blue]> {line}[/bold blue]")
6768

6869
def print_warning_line(self, name: str, info: str) -> None:
6970
"""Print a warning line: green name, yellow value."""

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 SubProject, VcsDependency
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[VcsDependency]]:
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+
vcs_deps = []
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+
vcs_deps.append(
87+
VcsDependency(
88+
remote_url=submodule.url,
89+
destination=submodule.path,
90+
branch=submodule.branch,
91+
tag=submodule.tag,
92+
revision=submodule.sha,
93+
source_type="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), vcs_deps
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 Dependency(TypedDict):
20+
"""Argument types for dependency class construction."""
21+
22+
branch: str
23+
tag: str
24+
revision: str
25+
remote_url: str
26+
destination: str
27+
source_type: 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+
dependencies: list["Dependency"]
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._dependencies: list[Dependency] = kwargs.get("dependencies", [])
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+
"dependencies": [],
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+
dependencies: list[Dependency] | 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._dependencies = dependencies 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 dependencies(self) -> list[Dependency]:
154+
"""The list of dependency projects as stored in the metadata."""
155+
return self._dependencies
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.dependencies == self.dependencies,
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[Dependency]]] = {
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.dependencies:
199+
metadata["dfetch"]["dependencies"] = self.dependencies
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 Dependency, Metadata
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 VcsDependency(NamedTuple):
23+
"""Information about a vcs dependency."""
24+
25+
destination: str
26+
remote_url: str
27+
branch: str
28+
tag: str
29+
revision: str
30+
source_type: str
31+
32+
def to_dependency(self) -> Dependency:
33+
"""Convert this vcs dependency to a Dependency object."""
34+
return Dependency(
35+
destination=self.destination,
36+
remote_url=self.remote_url,
37+
branch=self.branch,
38+
tag=self.tag,
39+
revision=self.revision,
40+
source_type=self.source_type,
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, dependency = 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+
dependencies=[dependency.to_dependency() for dependency in dependency],
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[VcsDependency]]:
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 SubProject, VcsDependency
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[VcsDependency]]:
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: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,36 @@ def add_project(
2626
) -> None:
2727
"""Add a project to the report."""
2828
del version
29-
logger.print_info_field("project", project.name)
30-
logger.print_info_field(" remote", project.remote)
29+
logger.print_info_line(project.name, "")
30+
logger.print_info_field("- remote", project.remote)
3131
try:
3232
metadata = Metadata.from_file(Metadata.from_project_entry(project).path)
33-
logger.print_info_field(" remote url", metadata.remote_url)
34-
logger.print_info_field(" branch", metadata.branch)
35-
logger.print_info_field(" tag", metadata.tag)
36-
logger.print_info_field(" last fetch", str(metadata.last_fetch))
37-
logger.print_info_field(" revision", metadata.revision)
38-
logger.print_info_field(" patch", ", ".join(metadata.patch))
33+
logger.print_info_field(" remote url", metadata.remote_url)
34+
logger.print_info_field(" branch", metadata.branch)
35+
logger.print_info_field(" tag", metadata.tag)
36+
logger.print_info_field(" last fetch", str(metadata.last_fetch))
37+
logger.print_info_field(" revision", metadata.revision)
38+
logger.print_info_field(" patch", ", ".join(metadata.patch))
3939
logger.print_info_field(
40-
" licenses", ",".join(license.name for license in licenses)
40+
" licenses", ",".join(license.name for license in licenses)
4141
)
4242

43+
if metadata.dependencies:
44+
logger.info("")
45+
logger.print_report_line(" dependencies", "")
46+
for dependency in metadata.dependencies:
47+
logger.print_info_field(" - path", dependency.get("destination", ""))
48+
logger.print_info_field(" url", dependency.get("remote_url", ""))
49+
logger.print_info_field(" branch", dependency.get("branch", ""))
50+
logger.print_info_field(" tag", dependency.get("tag", ""))
51+
logger.print_info_field(" revision", dependency.get("revision", ""))
52+
logger.print_info_field(
53+
" source-type", dependency.get("source_type", "")
54+
)
55+
logger.info("")
56+
4357
except FileNotFoundError:
44-
logger.print_info_field(" last fetch", "never")
58+
logger.print_info_field(" last fetch", "never")
4559

4660
def dump_to_file(self, outfile: str) -> bool:
4761
"""Do nothing."""

dfetch/util/util.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@ def find_matching_files(directory: str, patterns: Sequence[str]) -> Iterator[Pat
4343

4444

4545
def safe_rm(path: str | Path) -> None:
46-
"""Delete an file or directory safely."""
47-
if os.path.isdir(path):
48-
safe_rmtree(str(path))
49-
else:
50-
os.remove(path)
46+
"""Delete a file or directory safely."""
47+
if os.path.lexists(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:

0 commit comments

Comments
 (0)