Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
Unreleased
==========
Release 0.13.0 (unreleased)
====================================

* Add archive (``vcs: archive``) support for fetching dependencies from ``.tar.gz``, ``.tgz``, ``.tar.bz2``, ``.tar.xz`` and ``.zip`` files via HTTP, HTTPS or file URLs (#1058)
* Fix path-traversal check using character-based prefix comparison instead of path-component comparison (#1058)
* Fix directory hash being non-deterministic across filesystem traversal orders, causing false local-change detection (#1058)
* Fix ``dfetch freeze`` not capturing branch information for SVN projects when only the revision matched (#1058)
* Rename child-manifests to sub-manifests in documentation and code (#1027)
* Fetch git submodules in git subproject at pinned revision (#1013)
* Add nested projects in subprojects to project report (#1017)
* Make ``dfetch report`` output more yaml-like (#1017)
* Don't break when importing submodules with space in path (#1017)
* Warn when ``src:`` glob pattern matches multiple directories (#1017)

Release 0.12.1 (released 2026-02-24)
====================================

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

Release 0.12.0 (released 2026-02-21)
====================================
Expand Down
20 changes: 20 additions & 0 deletions dfetch/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@

.. scenario-include:: ../features/updated-project-has-dependencies.feature

Git submodules
~~~~~~~~~~~~~~

When a git dependency itself contains git submodules, *Dfetch* fetches and resolves
them automatically, no extra manifest entries or ``git submodule`` commands are needed.

Each submodule is checked out at the exact revision pinned by the parent repository.
*Dfetch* reports every resolved submodule in the update output::

Dfetch (0.12.1)
my-project:
> Found & fetched submodule "./ext/vendor-lib" (https://github.com/example/vendor-lib @ master - 79698c9…)
> Fetched master - e1fda19…

Nested submodules (submodules of submodules) are resolved recursively. The pinned
details for each submodule are recorded in the ``.dfetch_data.yaml`` metadata file
and are visible in :ref:`Report`.

.. scenario-include:: ../features/fetch-git-repo-with-submodule.feature

"""

import argparse
Expand Down
5 changes: 3 additions & 2 deletions dfetch/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ def print_info_line(self, name: str, info: str) -> None:
self.info(f" [bold][bright_green]{safe_name}:[/bright_green][/bold]")
DLogger._printed_projects.add(name)

line = markup_escape(info).replace("\n", "\n ")
self.info(f" [bold blue]> {line}[/bold blue]")
if info:
line = markup_escape(info).replace("\n", "\n ")
self.info(f" [bold blue]> {line}[/bold blue]")

def print_warning_line(self, name: str, info: str) -> None:
"""Print a warning line: green name, yellow value."""
Expand Down
7 changes: 6 additions & 1 deletion dfetch/manifest/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ def get_submanifests(skip: list[str] | None = None) -> list[Manifest]:
with prefix_runtime_exceptions(
pathlib.Path(path).relative_to(os.path.dirname(os.getcwd())).as_posix()
):
submanifests += [parse(path)]
try:
submanifests += [parse(path)]
except FileNotFoundError:
logger.warning(
f"Sub-manifest {path} was found but no longer exists"
)

return submanifests
5 changes: 3 additions & 2 deletions dfetch/project/archivesubproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from dfetch.log import get_logger
from dfetch.manifest.project import ProjectEntry
from dfetch.manifest.version import Version
from dfetch.project.metadata import Dependency
from dfetch.project.subproject import SubProject
from dfetch.vcs.archive import (
ARCHIVE_EXTENSIONS,
Expand Down Expand Up @@ -166,7 +167,7 @@ def wanted_version(self) -> Version:
return Version(revision=self._project_entry.hash)
return Version(revision=self.remote)

def _fetch_impl(self, version: Version) -> Version:
def _fetch_impl(self, version: Version) -> tuple[Version, list[Dependency]]:
"""Download and extract the archive to the local destination.

1. Download the archive to a temporary file.
Expand Down Expand Up @@ -211,7 +212,7 @@ def _fetch_impl(self, version: Version) -> Version:
except OSError:
pass

return version
return version, []

def freeze_project(self, project: ProjectEntry) -> str | None:
"""Pin *project* to a cryptographic hash of the archive.
Expand Down
35 changes: 28 additions & 7 deletions dfetch/project/gitsubproject.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""Git specific implementation."""

import os
import pathlib
from functools import lru_cache

from dfetch.log import get_logger
from dfetch.manifest.project import ProjectEntry
from dfetch.manifest.version import Version
from dfetch.project.metadata import Dependency
from dfetch.project.subproject import SubProject
from dfetch.util.util import LICENSE_GLOBS, safe_rmtree
from dfetch.util.util import LICENSE_GLOBS, safe_rm
from dfetch.vcs.git import GitLocalRepo, GitRemote, get_git_version

logger = get_logger(__name__)
Expand Down Expand Up @@ -57,7 +57,7 @@ def list_tool_info() -> None:
)
SubProject._log_tool("git", "<not found in PATH>")

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

Expand All @@ -69,17 +69,38 @@ def _fetch_impl(self, version: Version) -> Version:
]

local_repo = GitLocalRepo(self.local_path)
fetched_sha = local_repo.checkout_version(
fetched_sha, submodules = local_repo.checkout_version(
remote=self.remote,
version=rev_or_branch_or_tag,
src=self.source,
must_keeps=license_globs,
must_keeps=license_globs + [".gitmodules"],
ignore=self.ignore,
)

safe_rmtree(os.path.join(self.local_path, local_repo.METADATA_DIR))
vcs_deps = []
for submodule in submodules:
self._log_project(
f'Found & fetched submodule "./{submodule.path}" '
f" ({submodule.url} @ {Version(tag=submodule.tag, branch=submodule.branch, revision=submodule.sha)})",
)
vcs_deps.append(
Dependency(
remote_url=submodule.url,
destination=submodule.path,
branch=submodule.branch,
tag=submodule.tag,
revision=submodule.sha,
source_type="git-submodule",
)
)

targets = {local_repo.METADATA_DIR, local_repo.GIT_MODULES_FILE}

for path in pathlib.Path(self.local_path).rglob("*"):
if path.name in targets:
safe_rm(path)

return self._determine_fetched_version(version, fetched_sha)
return self._determine_fetched_version(version, fetched_sha), vcs_deps

def _determine_what_to_fetch(self, version: Version) -> str:
"""Based on asked version, target to fetch."""
Expand Down
33 changes: 31 additions & 2 deletions dfetch/project/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
"""


class Dependency(TypedDict):
"""Argument types for dependency class construction."""

branch: str
tag: str
revision: str
remote_url: str
destination: str
source_type: str


class Options(TypedDict): # pylint: disable=too-many-ancestors
"""Argument types for Metadata class construction."""

Expand All @@ -27,6 +38,7 @@ class Options(TypedDict): # pylint: disable=too-many-ancestors
destination: str
hash: str
patch: str | list[str]
dependencies: list["Dependency"]


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

self._dependencies: list[Dependency] = kwargs.get("dependencies", [])

@classmethod
def from_project_entry(cls, project: ProjectEntry) -> "Metadata":
"""Create a metadata object from a project entry."""
Expand All @@ -66,6 +80,7 @@ def from_project_entry(cls, project: ProjectEntry) -> "Metadata":
"last_fetch": datetime.datetime(2000, 1, 1, 0, 0, 0),
"hash": "",
"patch": project.patch,
"dependencies": [],
}
return cls(data)

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

def fetched(
self, version: Version, hash_: str = "", patch_: list[str] | None = None
self,
version: Version,
hash_: str = "",
patch_: list[str] | None = None,
dependencies: list[Dependency] | None = None,
) -> None:
"""Update metadata."""
self._last_fetch = datetime.datetime.now()
self._version = version
self._hash = hash_
self._patch = patch_ or []
self._dependencies = dependencies or []

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

@property
def dependencies(self) -> list[Dependency]:
"""The list of dependency projects as stored in the metadata."""
return self._dependencies

@property
def path(self) -> str:
"""Path to metadata file."""
Expand All @@ -152,12 +177,13 @@ def __eq__(self, other: object) -> bool:
other._version.revision == self._version.revision,
other.hash == self.hash,
other.patch == self.patch,
other.dependencies == self.dependencies,
]
)

def dump(self) -> None:
"""Dump metadata file to correct path."""
metadata = {
metadata: dict[str, dict[str, str | list[str] | list[Dependency]]] = {
"dfetch": {
"remote_url": self.remote_url,
"branch": self._version.branch,
Expand All @@ -169,6 +195,9 @@ def dump(self) -> None:
}
}

if self.dependencies:
metadata["dfetch"]["dependencies"] = self.dependencies

with open(self.path, "w+", encoding="utf-8") as metadata_file:
metadata_file.write(DONT_EDIT_WARNING)
yaml.dump(metadata, metadata_file)
7 changes: 4 additions & 3 deletions dfetch/project/subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from dfetch.manifest.project import ProjectEntry
from dfetch.manifest.version import Version
from dfetch.project.abstract_check_reporter import AbstractCheckReporter
from dfetch.project.metadata import Metadata
from dfetch.project.metadata import Dependency, Metadata
from dfetch.util.util import hash_directory, safe_rm
from dfetch.util.versions import latest_tag_from_list
from dfetch.vcs.patch import Patch
Expand Down Expand Up @@ -129,7 +129,7 @@ def update(
f"Fetching {to_fetch}",
enabled=self._show_animations,
):
actually_fetched = self._fetch_impl(to_fetch)
actually_fetched, dependency = self._fetch_impl(to_fetch)
self._log_project(f"Fetched {actually_fetched}")

applied_patches = self._apply_patches(patch_count)
Expand All @@ -145,6 +145,7 @@ def update(
skiplist=[self.__metadata.FILENAME] + post_fetch_ignored,
),
patch_=applied_patches,
dependencies=list(dependency),
)

logger.debug(f"Writing repo metadata to: {self.__metadata.path}")
Expand Down Expand Up @@ -392,7 +393,7 @@ def _are_there_local_changes(self, files_to_ignore: Sequence[str]) -> bool:
)

@abstractmethod
def _fetch_impl(self, version: Version) -> Version:
def _fetch_impl(self, version: Version) -> tuple[Version, list[Dependency]]:
"""Fetch the given version of the subproject, should be implemented by the child class."""

@abstractmethod
Expand Down
5 changes: 3 additions & 2 deletions dfetch/project/svnsubproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from dfetch.log import get_logger
from dfetch.manifest.project import ProjectEntry
from dfetch.manifest.version import Version
from dfetch.project.metadata import Dependency
from dfetch.project.subproject import SubProject
from dfetch.util.util import (
find_matching_files,
Expand Down Expand Up @@ -107,7 +108,7 @@ def _remove_ignored_files(self) -> None:
if not (file_or_dir.is_file() and is_license_file(file_or_dir.name)):
safe_rm(file_or_dir)

def _fetch_impl(self, version: Version) -> Version:
def _fetch_impl(self, version: Version) -> tuple[Version, list[Dependency]]:
"""Get the revision of the remote and place it at the local path."""
branch, branch_path, revision = self._determine_what_to_fetch(version)
rev_arg = f"--revision {revision}" if revision else ""
Expand Down Expand Up @@ -148,7 +149,7 @@ def _fetch_impl(self, version: Version) -> Version:
if self.ignore:
self._remove_ignored_files()

return Version(tag=version.tag, branch=branch, revision=revision)
return Version(tag=version.tag, branch=branch, revision=revision), []

@staticmethod
def _parse_file_pattern(complete_path: str) -> tuple[str, str]:
Expand Down
Loading
Loading