Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Release 0.12.0 (unreleased)
* Skip patches outside manifest dir (#942)
* Make patch path in metadata platform independent (#937)
* Fix extra newlines in patch for new files (#945)
* Introduce new ``update-patch`` command (#614)

Release 0.11.0 (released 2026-01-03)
====================================
Expand Down
2 changes: 1 addition & 1 deletion dfetch.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"module": "dfetch.__main__",
"justMyCode": false,
"args": [
"update"
"update-patch", "sphinxcontrib.asciinema"
]
}
]
Expand Down
2 changes: 2 additions & 0 deletions dfetch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import dfetch.commands.init
import dfetch.commands.report
import dfetch.commands.update
import dfetch.commands.update_patch
import dfetch.commands.validate
import dfetch.log
import dfetch.util.cmdline
Expand Down Expand Up @@ -45,6 +46,7 @@ def create_parser() -> argparse.ArgumentParser:
dfetch.commands.init.Init.create_menu(subparsers)
dfetch.commands.report.Report.create_menu(subparsers)
dfetch.commands.update.Update.create_menu(subparsers)
dfetch.commands.update_patch.UpdatePatch.create_menu(subparsers)
dfetch.commands.validate.Validate.create_menu(subparsers)

return parser
Expand Down
9 changes: 8 additions & 1 deletion dfetch/commands/command.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""A generic command."""

import argparse
import re
import sys
from abc import ABC, abstractmethod
from argparse import ArgumentParser # pylint: disable=unused-import
Expand All @@ -18,6 +19,12 @@
)


def pascal_to_kebab(name: str) -> str:
"""Insert a dash before each uppercase letter (except the first) and lowercase everything."""
s1 = re.sub(r"(?<!^)(?=[A-Z])", "-", name)
return s1.lower()


class Command(ABC):
"""An abstract command that dfetch can perform.

Expand Down Expand Up @@ -78,7 +85,7 @@ def parser(
help_str, epilog = command.__doc__.split("\n", 1)

parser = subparsers.add_parser(
command.__name__.lower(),
pascal_to_kebab(command.__name__),
description=help_str,
help=help_str,
epilog=epilog,
Expand Down
32 changes: 0 additions & 32 deletions dfetch/commands/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,38 +62,6 @@

``git apply --verbose --directory='Core/MyModule/MySubmodule' MySubmodule.patch``

Updating the patch
======================
If you have further changes to a project with a patch, you can create an additional patch if the changes are
unrelated, or you can update the patch as described below.

First step is to reverse the original patch, using
``apply`` and with the ``--directory`` argument as shown above. To reverse the patch you also require the ``-R``.
This will return the project to the state before the patch was applied. You can then stage the project and re-apply
the patch to the project to have an editable patch.

.. code-block:: sh

# First apply the reverse patch
git apply --verbose --directory='some-project' -R some-project.patch

# Stage the project in its unpatched state
git add some-project

# Re-apply the patch
git apply --verbose --directory='some-project' some-project.patch

Now you can make further changes to the project, and re-generate the patch using the raw git command as shown below.

.. code-block:: sh

# Generate the patch from the directory again
cd some-project
git diff --relative --binary --no-ext-diff --no-color . > ../some-project.patch

# Force a fetch and update the patch to test your changes
dfetch update -f some-project

"""

import argparse
Expand Down
152 changes: 152 additions & 0 deletions dfetch/commands/update_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Updating patches.

*Dfetch* allows you to keep local changes to external projects in the form of
patch files. When those local changes evolve over time, an existing patch can
be updated to reflect the new state of the project.

The ``update-patch`` command automates the otherwise manual process of
refreshing a patch. It safely regenerates the last patch of a project based on
the current working tree, while keeping the upstream revision unchanged.

This command operates on projects defined in the :ref:`Manifest` and requires
that the manifest itself is located inside a version-controlled repository
(the *superproject*). The version control system of the superproject is used to
calculate and regenerate the patch.

The existing patch is backed up before being overwritten.

The below statement will update the patch for ``some-project`` from your manifest.

.. code-block:: sh

dfetch update-patch some-project

.. tabs::

.. tab:: Git

.. scenario-include:: ../features/update-patch-in-git.feature

.. tab:: SVN

.. scenario-include:: ../features/update-patch-in-svn.feature
"""

import argparse
import pathlib
import shutil

import dfetch.commands.command
import dfetch.manifest.project
import dfetch.project
from dfetch.log import get_logger
from dfetch.project.superproject import SuperProject
from dfetch.util.util import catch_runtime_exceptions, in_directory

logger = get_logger(__name__)


class UpdatePatch(dfetch.commands.command.Command):
"""Update a patch to reflect the last changes.

The ``update-patch`` command regenerates the last patch of one or
more projects based on the current working tree. This is useful
when you have modified a project after applying a patch and want
to record those changes in an updated patch file. If there is no
patch yet, use ``dfetch diff`` instead.
"""

@staticmethod
def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None:
"""Add the menu for the update-patch action."""
parser = dfetch.commands.command.Command.parser(subparsers, UpdatePatch)
parser.add_argument(
"projects",
metavar="<project>",
type=str,
nargs="*",
help="Specific project(s) to update",
)

def __call__(self, args: argparse.Namespace) -> None:
"""Perform the update patch."""
superproject = SuperProject()

exceptions: list[str] = []

if not superproject.in_vcs():
raise RuntimeError(
"The project containing the manifest is not under version control,"
" updating patches is not supported"
)

with in_directory(superproject.root_directory):
for project in superproject.manifest.selected_projects(args.projects):
with catch_runtime_exceptions(exceptions) as exceptions:
subproject = dfetch.project.make(project)

files_to_ignore = superproject.ignored_files(project.destination)

# Check if the project has a patch, maybe suggest creating one?
if not subproject.patch:
logger.print_warning_line(
project.name,
f'skipped - there is no patch file, use "dfetch diff {project.name}" instead',
)
return

# Check if the project was ever fetched
on_disk_version = subproject.on_disk_version()
if not on_disk_version:
logger.print_warning_line(
project.name,
f'skipped - the project was never fetched before, use "dfetch update {project.name}"',
)
return

# Make sure no uncommitted changes (don't care about ignored files)
if superproject.has_local_changes_in_dir(subproject.local_path):
logger.print_warning_line(
project.name,
f"skipped - Uncommitted changes in {subproject.local_path}",
)
return

# force update to fetched version from metadata without applying patch
subproject.update(
force=True,
files_to_ignore=files_to_ignore,
patch_count=len(subproject.patch) - 1,
)

# generate reverse patch
patch_text = subproject.diff(
old_revision=superproject.current_revision(),
new_revision="",
# ignore=files_to_ignore,
reverse=True,
)

# Select patch to overwrite & make backup
patch_to_update = subproject.patch[-1]

if patch_text:
shutil.move(patch_to_update, patch_to_update + ".backup")
patch_path = pathlib.Path(patch_to_update)
logger.print_info_line(
project.name, f"Updating patch {patch_to_update}"
)
patch_path.write_text(patch_text, encoding="UTF-8")
else:
logger.print_info_line(
project.name,
f"No diffs found, kept patch {patch_to_update} unchanged",
)

# force update again to fetched version from metadata but with applying patch
subproject.update(
force=True, files_to_ignore=files_to_ignore, patch_count=-1
)

if exceptions:
raise RuntimeError("\n".join(exceptions))
10 changes: 7 additions & 3 deletions dfetch/project/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,19 @@ def metadata_revision(self) -> str:
return str(self._local_repo.get_last_file_hash(self.metadata_path))

def current_revision(self) -> str:
"""Get the revision of the metadata file."""
"""Get the last revision of the repository."""
return str(self._local_repo.get_current_hash())

def _diff_impl(
self, old_revision: str, new_revision: Optional[str], ignore: Sequence[str]
self,
old_revision: str,
new_revision: Optional[str],
ignore: Sequence[str],
reverse: bool = False,
) -> str:
"""Get the diff of two revisions."""
diff_since_revision = str(
self._local_repo.create_diff(old_revision, new_revision, ignore)
self._local_repo.create_diff(old_revision, new_revision, ignore, reverse)
)

if new_revision:
Expand Down
27 changes: 20 additions & 7 deletions dfetch/project/subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,18 @@ def update_is_required(self, force: bool = False) -> Optional[Version]:
return wanted

def update(
self, force: bool = False, files_to_ignore: Optional[Sequence[str]] = None
self,
force: bool = False,
files_to_ignore: Optional[Sequence[str]] = None,
patch_count: int = -1,
) -> None:
"""Update this subproject if required.

Args:
force (bool, optional): Ignore if version is ok or any local changes were done.
Defaults to False.
files_to_ignore (Sequence[str], optional): list of files that are ok to overwrite.
patch_count (int, optional): Number of patches to apply (-1 means all).
"""
to_fetch = self.update_is_required(force)

Expand Down Expand Up @@ -128,7 +132,7 @@ def update(
actually_fetched = self._fetch_impl(to_fetch)
self._log_project(f"Fetched {actually_fetched}")

applied_patches = self._apply_patches()
applied_patches = self._apply_patches(patch_count)

self.__metadata.fetched(
actually_fetched,
Expand All @@ -139,11 +143,12 @@ def update(
logger.debug(f"Writing repo metadata to: {self.__metadata.path}")
self.__metadata.dump()

def _apply_patches(self) -> list[str]:
def _apply_patches(self, count: int = -1) -> list[str]:
"""Apply the patches."""
cwd = pathlib.Path(".").resolve()
applied_patches = []
for patch in self.__project.patch:
count = len(self.__project.patch) if count == -1 else count
for patch in self.__project.patch[:count]:

patch_path = (cwd / patch).resolve()

Expand Down Expand Up @@ -261,6 +266,11 @@ def ignore(self) -> Sequence[str]:
"""Get the files/folders to ignore of this subproject."""
return self.__project.ignore

@property
def patch(self) -> Sequence[str]:
"""Get the patches of this project."""
return self.__project.patch

@abstractmethod
def check(self) -> bool:
"""Check if it can handle the type."""
Expand Down Expand Up @@ -386,6 +396,7 @@ def _diff_impl(
old_revision: str, # noqa
new_revision: Optional[str], # noqa
ignore: Sequence[str],
reverse: bool = False,
) -> str:
"""Get the diff of two revisions, should be implemented by the child class."""

Expand All @@ -401,13 +412,15 @@ def is_license_file(filename: str) -> bool:
for pattern in SubProject.LICENSE_GLOBS
)

def diff(self, old_rev: str, new_rev: str) -> str:
def diff(self, old_revision: str, new_revision: str, reverse: bool = False) -> str:
"""Generate a relative diff for a subproject."""
if not old_rev:
if not old_revision:
raise RuntimeError(
"When not providing any revisions, dfetch starts from"
f" the last revision to {Metadata.FILENAME} in {self.local_path}."
" Please either commit this, or specify a revision to start from with --revs"
)

return self._diff_impl(old_rev, new_rev, ignore=(Metadata.FILENAME,))
return self._diff_impl(
old_revision, new_revision, ignore=(Metadata.FILENAME,), reverse=reverse
)
27 changes: 27 additions & 0 deletions dfetch/project/superproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,30 @@ def ignored_files(self, path: str) -> Sequence[str]:
return SvnRepo.ignored_files(path)

return []

def in_vcs(self) -> bool:
"""Check if this superproject is under version control."""
return (
GitLocalRepo(self.root_directory).is_git()
or SvnRepo(self.root_directory).is_svn()
)

def has_local_changes_in_dir(self, path: str) -> bool:
"""Check if the superproject has local changes."""
if GitLocalRepo(self.root_directory).is_git():
return GitLocalRepo.any_changes_or_untracked(path)

if SvnRepo(self.root_directory).is_svn():
return SvnRepo.any_changes_or_untracked(path)

return True

def current_revision(self) -> str:
"""Get the last revision of the superproject."""
if GitLocalRepo(self.root_directory).is_git():
return GitLocalRepo(self.root_directory).get_current_hash()

if SvnRepo(self.root_directory).is_svn():
return SvnRepo.get_last_changed_revision(self.root_directory)

return ""
Loading
Loading