Skip to content
Open
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
9 changes: 8 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
Release 0.13.0 (unreleased)
====================================

* 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)

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

line = info.replace("\n", "\n ")
self.info(f" [bold blue]> {line}[/bold blue]")
if info:
line = 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
30 changes: 24 additions & 6 deletions dfetch/project/gitsubproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from dfetch.log import get_logger
from dfetch.manifest.project import ProjectEntry
from dfetch.manifest.version import Version
from dfetch.project.subproject import SubProject
from dfetch.util.util import safe_rmtree
from dfetch.project.subproject import SubProject, VcsDependency
from dfetch.util.util import safe_rm, safe_rmtree
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[VcsDependency]]:
"""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,35 @@ 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"],
Comment on lines +72 to +76
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

git init "$tmp/child" >/dev/null
git -C "$tmp/child" config user.email review@example.com
git -C "$tmp/child" config user.name review
touch "$tmp/child/README"
git -C "$tmp/child" add README
git -C "$tmp/child" commit -m init >/dev/null

git init "$tmp/parent" >/dev/null
git -C "$tmp/parent" config user.email review@example.com
git -C "$tmp/parent" config user.name review
touch "$tmp/parent/.keep"
git -C "$tmp/parent" add .keep
git -C "$tmp/parent" commit -m init >/dev/null

git -C "$tmp/parent" -c protocol.file.allow=always submodule add "$tmp/child" deps/child >/dev/null
git -C "$tmp/parent" commit -am add-submodule >/dev/null

test -f "$tmp/parent/.gitmodules" && echo ".gitmodules is a tracked checkout file"
test -e "$tmp/parent/deps/child/.git" && echo "submodule worktree has its own .git entry"

rm -rf "$tmp/parent/.git"
test -e "$tmp/parent/deps/child/.git" && echo "nested .git entry still exists after deleting only the top-level .git"

Repository: dfetch-org/dfetch

Length of output: 1446


🏁 Script executed:

cat -n dfetch/project/gitsubproject.py | sed -n '70,105p'

Repository: dfetch-org/dfetch

Length of output: 1732


🏁 Script executed:

fd -t f "gitsubproject.py" --exec wc -l {} \;

Repository: dfetch-org/dfetch

Length of output: 98


🏁 Script executed:

rg -A 5 -B 5 "safe_rmtree.*METADATA_DIR" dfetch/project/gitsubproject.py

Repository: dfetch-org/dfetch

Length of output: 484


🏁 Script executed:

rg "METADATA_DIR|GIT_MODULES_FILE" dfetch/ -t py

Repository: dfetch-org/dfetch

Length of output: 476


🏁 Script executed:

rg "def safe_rmtree|def safe_rm" dfetch/ -A 3 -t py

Repository: dfetch-org/dfetch

Length of output: 547


🏁 Script executed:

head -30 dfetch/project/gitsubproject.py

Repository: dfetch-org/dfetch

Length of output: 857


Remove all nested .git directories after checkout, not just the superproject metadata.

The code keeps .gitmodules during checkout to initialize submodules, then deletes it along with only the top-level .git. This leaves each submodule's .git entry orphaned and pointing to now-deleted superproject metadata.

🛠️ Suggested fix
         safe_rmtree(os.path.join(self.local_path, local_repo.METADATA_DIR))
-        safe_rm(os.path.join(self.local_path, local_repo.GIT_MODULES_FILE))
+        for nested_git_metadata in pathlib.Path(self.local_path).rglob(
+            local_repo.METADATA_DIR
+        ):
+            safe_rm(nested_git_metadata)

Also applies to: 97-98

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dfetch/project/gitsubproject.py` around lines 72 - 76, The checkout currently
preserves ".gitmodules" via the must_keeps argument in
local_repo.checkout_version and then removes only the top-level ".git", leaving
each submodule's .git entries orphaned; modify the post-checkout cleanup (around
the local_repo.checkout_version call and the later cleanup block that deletes
the superproject .git) to recursively find and remove any nested ".git" files or
directories under the checked-out working tree (including submodule paths)
rather than only deleting the top-level .git, ensuring you still keep
".gitmodules" long enough to initialize submodules before removing all nested
.git metadata.

ignore=self.ignore,
)

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(
VcsDependency(
remote_url=submodule.url,
destination=submodule.path,
branch=submodule.branch,
tag=submodule.tag,
revision=submodule.sha,
source_type="git-submodule",
)
)

safe_rmtree(os.path.join(self.local_path, local_repo.METADATA_DIR))
safe_rm(os.path.join(self.local_path, local_repo.GIT_MODULES_FILE))

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)
30 changes: 27 additions & 3 deletions dfetch/project/subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,42 @@
import pathlib
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import NamedTuple

from dfetch.log import get_logger
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

logger = get_logger(__name__)


class VcsDependency(NamedTuple):
"""Information about a vcs dependency."""

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

def to_dependency(self) -> Dependency:
"""Convert this vcs dependency to a Dependency object."""
return Dependency(
destination=self.destination,
remote_url=self.remote_url,
branch=self.branch,
tag=self.tag,
revision=self.revision,
source_type=self.source_type,
)


class SubProject(ABC):
"""Abstract SubProject object.

Expand Down Expand Up @@ -125,7 +148,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 @@ -134,6 +157,7 @@ def update(
actually_fetched,
hash_=hash_directory(self.local_path, skiplist=[self.__metadata.FILENAME]),
patch_=applied_patches,
dependencies=[dependency.to_dependency() for dependency in dependency],
)

logger.debug(f"Writing repo metadata to: {self.__metadata.path}")
Expand Down Expand Up @@ -381,7 +405,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[VcsDependency]]:
"""Fetch the given version of the subproject, should be implemented by the child class."""

@abstractmethod
Expand Down
6 changes: 3 additions & 3 deletions dfetch/project/svnsubproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dfetch.log import get_logger
from dfetch.manifest.project import ProjectEntry
from dfetch.manifest.version import Version
from dfetch.project.subproject import SubProject
from dfetch.project.subproject import SubProject, VcsDependency
from dfetch.util.util import (
find_matching_files,
find_non_matching_files,
Expand Down Expand Up @@ -106,7 +106,7 @@ def _remove_ignored_files(self) -> None:
if not (file_or_dir.is_file() and self.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[VcsDependency]]:
"""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 @@ -147,7 +147,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
34 changes: 24 additions & 10 deletions dfetch/reporting/stdout_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,36 @@ def add_project(
) -> None:
"""Add a project to the report."""
del version
logger.print_info_field("project", project.name)
logger.print_info_field(" remote", project.remote)
logger.print_info_line(project.name, "")
logger.print_info_field("- remote", project.remote)
try:
metadata = Metadata.from_file(Metadata.from_project_entry(project).path)
logger.print_info_field(" remote url", metadata.remote_url)
logger.print_info_field(" branch", metadata.branch)
logger.print_info_field(" tag", metadata.tag)
logger.print_info_field(" last fetch", str(metadata.last_fetch))
logger.print_info_field(" revision", metadata.revision)
logger.print_info_field(" patch", ", ".join(metadata.patch))
logger.print_info_field(" remote url", metadata.remote_url)
logger.print_info_field(" branch", metadata.branch)
logger.print_info_field(" tag", metadata.tag)
logger.print_info_field(" last fetch", str(metadata.last_fetch))
logger.print_info_field(" revision", metadata.revision)
logger.print_info_field(" patch", ", ".join(metadata.patch))
logger.print_info_field(
" licenses", ",".join(license.name for license in licenses)
" licenses", ",".join(license.name for license in licenses)
)

if metadata.dependencies:
logger.info("")
logger.print_report_line(" dependencies", "")
for dependency in metadata.dependencies:
logger.print_info_field(" - path", dependency.get("destination", ""))
logger.print_info_field(" url", dependency.get("remote_url", ""))
logger.print_info_field(" branch", dependency.get("branch", ""))
logger.print_info_field(" tag", dependency.get("tag", ""))
logger.print_info_field(" revision", dependency.get("revision", ""))
logger.print_info_field(
" source-type", dependency.get("source_type", "")
)
logger.info("")

except FileNotFoundError:
logger.print_info_field(" last fetch", "never")
logger.print_info_field(" last fetch", "never")

def dump_to_file(self, outfile: str) -> bool:
"""Do nothing."""
Expand Down
11 changes: 6 additions & 5 deletions dfetch/util/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ def find_matching_files(directory: str, patterns: Sequence[str]) -> Iterator[Pat


def safe_rm(path: str | Path) -> None:
"""Delete an file or directory safely."""
if os.path.isdir(path):
safe_rmtree(str(path))
else:
os.remove(path)
"""Delete a file or directory safely."""
if os.path.lexists(path):
if os.path.isdir(path):
safe_rmtree(str(path))
else:
os.remove(path)


def safe_rmtree(path: str) -> None:
Expand Down
Loading
Loading