Skip to content

Commit 513130b

Browse files
spoorccben-edna
authored andcommitted
Fix #614: Introduce new update-patch command
1 parent ec1b61d commit 513130b

24 files changed

+742
-90
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Release 0.12.0 (unreleased)
1111
* Skip patches outside manifest dir (#942)
1212
* Make patch path in metadata platform independent (#937)
1313
* Fix extra newlines in patch for new files (#945)
14+
* Introduce new ``update-patch`` command (#614)
1415

1516
Release 0.11.0 (released 2026-01-03)
1617
====================================

dfetch.code-workspace

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"module": "dfetch.__main__",
6464
"justMyCode": false,
6565
"args": [
66-
"update"
66+
"update-patch", "sphinxcontrib.asciinema"
6767
]
6868
}
6969
]

dfetch/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import dfetch.commands.init
1616
import dfetch.commands.report
1717
import dfetch.commands.update
18+
import dfetch.commands.update_patch
1819
import dfetch.commands.validate
1920
import dfetch.log
2021
import dfetch.util.cmdline
@@ -45,6 +46,7 @@ def create_parser() -> argparse.ArgumentParser:
4546
dfetch.commands.init.Init.create_menu(subparsers)
4647
dfetch.commands.report.Report.create_menu(subparsers)
4748
dfetch.commands.update.Update.create_menu(subparsers)
49+
dfetch.commands.update_patch.UpdatePatch.create_menu(subparsers)
4850
dfetch.commands.validate.Validate.create_menu(subparsers)
4951

5052
return parser

dfetch/commands/command.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""A generic command."""
22

33
import argparse
4+
import re
45
import sys
56
from abc import ABC, abstractmethod
67
from argparse import ArgumentParser # pylint: disable=unused-import
@@ -18,6 +19,12 @@
1819
)
1920

2021

22+
def pascal_to_kebab(name: str) -> str:
23+
"""Insert a dash before each uppercase letter (except the first) and lowercase everything."""
24+
s1 = re.sub(r"(?<!^)(?=[A-Z])", "-", name)
25+
return s1.lower()
26+
27+
2128
class Command(ABC):
2229
"""An abstract command that dfetch can perform.
2330
@@ -78,7 +85,7 @@ def parser(
7885
help_str, epilog = command.__doc__.split("\n", 1)
7986

8087
parser = subparsers.add_parser(
81-
command.__name__.lower(),
88+
pascal_to_kebab(command.__name__),
8289
description=help_str,
8390
help=help_str,
8491
epilog=epilog,

dfetch/commands/diff.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -62,38 +62,6 @@
6262
6363
``git apply --verbose --directory='Core/MyModule/MySubmodule' MySubmodule.patch``
6464
65-
Updating the patch
66-
======================
67-
If you have further changes to a project with a patch, you can create an additional patch if the changes are
68-
unrelated, or you can update the patch as described below.
69-
70-
First step is to reverse the original patch, using
71-
``apply`` and with the ``--directory`` argument as shown above. To reverse the patch you also require the ``-R``.
72-
This will return the project to the state before the patch was applied. You can then stage the project and re-apply
73-
the patch to the project to have an editable patch.
74-
75-
.. code-block:: sh
76-
77-
# First apply the reverse patch
78-
git apply --verbose --directory='some-project' -R some-project.patch
79-
80-
# Stage the project in its unpatched state
81-
git add some-project
82-
83-
# Re-apply the patch
84-
git apply --verbose --directory='some-project' some-project.patch
85-
86-
Now you can make further changes to the project, and re-generate the patch using the raw git command as shown below.
87-
88-
.. code-block:: sh
89-
90-
# Generate the patch from the directory again
91-
cd some-project
92-
git diff --relative --binary --no-ext-diff --no-color . > ../some-project.patch
93-
94-
# Force a fetch and update the patch to test your changes
95-
dfetch update -f some-project
96-
9765
"""
9866

9967
import argparse

dfetch/commands/update_patch.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Updating patches.
2+
3+
*Dfetch* allows you to keep local changes to external projects in the form of
4+
patch files. When those local changes evolve over time, an existing patch can
5+
be updated to reflect the new state of the project.
6+
7+
The ``update-patch`` command automates the otherwise manual process of
8+
refreshing a patch. It safely regenerates the last patch of a project based on
9+
the current working tree, while keeping the upstream revision unchanged.
10+
11+
This command operates on projects defined in the :ref:`Manifest` and requires
12+
that the manifest itself is located inside a version-controlled repository
13+
(the *superproject*). The version control system of the superproject is used to
14+
calculate and regenerate the patch.
15+
16+
The existing patch is backed up before being overwritten.
17+
18+
The below statement will update the patch for ``some-project`` from your manifest.
19+
20+
.. code-block:: sh
21+
22+
dfetch update-patch some-project
23+
24+
.. tabs::
25+
26+
.. tab:: Git
27+
28+
.. scenario-include:: ../features/update-patch-in-git.feature
29+
30+
.. tab:: SVN
31+
32+
.. scenario-include:: ../features/update-patch-in-svn.feature
33+
"""
34+
35+
import argparse
36+
import pathlib
37+
import shutil
38+
39+
import dfetch.commands.command
40+
import dfetch.manifest.project
41+
import dfetch.project
42+
from dfetch.log import get_logger
43+
from dfetch.project.superproject import SuperProject
44+
from dfetch.util.util import catch_runtime_exceptions, in_directory
45+
46+
logger = get_logger(__name__)
47+
48+
49+
class UpdatePatch(dfetch.commands.command.Command):
50+
"""Update a patch to reflect the last changes.
51+
52+
The ``update-patch`` command regenerates the last patch of one or
53+
more projects based on the current working tree. This is useful
54+
when you have modified a project after applying a patch and want
55+
to record those changes in an updated patch file. If there is no
56+
patch yet, use ``dfetch diff`` instead.
57+
"""
58+
59+
@staticmethod
60+
def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None:
61+
"""Add the menu for the update-patch action."""
62+
parser = dfetch.commands.command.Command.parser(subparsers, UpdatePatch)
63+
parser.add_argument(
64+
"projects",
65+
metavar="<project>",
66+
type=str,
67+
nargs="*",
68+
help="Specific project(s) to update",
69+
)
70+
71+
def __call__(self, args: argparse.Namespace) -> None:
72+
"""Perform the update patch."""
73+
superproject = SuperProject()
74+
75+
exceptions: list[str] = []
76+
77+
if not superproject.in_vcs():
78+
raise RuntimeError(
79+
"The project containing the manifest is not under version control,"
80+
" updating patches is not supported"
81+
)
82+
83+
with in_directory(superproject.root_directory):
84+
for project in superproject.manifest.selected_projects(args.projects):
85+
with catch_runtime_exceptions(exceptions) as exceptions:
86+
subproject = dfetch.project.make(project)
87+
88+
files_to_ignore = superproject.ignored_files(project.destination)
89+
90+
# Check if the project has a patch, maybe suggest creating one?
91+
if not subproject.patch:
92+
logger.print_warning_line(
93+
project.name,
94+
f'skipped - there is no patch file, use "dfetch diff {project.name}" instead',
95+
)
96+
return
97+
98+
# Check if the project was ever fetched
99+
on_disk_version = subproject.on_disk_version()
100+
if not on_disk_version:
101+
logger.print_warning_line(
102+
project.name,
103+
f'skipped - the project was never fetched before, use "dfetch update {project.name}"',
104+
)
105+
return
106+
107+
# Make sure no uncommitted changes (don't care about ignored files)
108+
if superproject.has_local_changes_in_dir(subproject.local_path):
109+
logger.print_warning_line(
110+
project.name,
111+
f"skipped - Uncommitted changes in {subproject.local_path}",
112+
)
113+
return
114+
115+
# force update to fetched version from metadata without applying patch
116+
subproject.update(
117+
force=True,
118+
files_to_ignore=files_to_ignore,
119+
patch_count=len(subproject.patch) - 1,
120+
)
121+
122+
# generate reverse patch
123+
patch_text = subproject.diff(
124+
old_revision=superproject.current_revision(),
125+
new_revision="",
126+
# ignore=files_to_ignore,
127+
reverse=True,
128+
)
129+
130+
# Select patch to overwrite & make backup
131+
patch_to_update = subproject.patch[-1]
132+
133+
if patch_text:
134+
shutil.move(patch_to_update, patch_to_update + ".backup")
135+
patch_path = pathlib.Path(patch_to_update)
136+
logger.print_info_line(
137+
project.name, f"Updating patch {patch_to_update}"
138+
)
139+
patch_path.write_text(patch_text, encoding="UTF-8")
140+
else:
141+
logger.print_info_line(
142+
project.name,
143+
f"No diffs found, kept patch {patch_to_update} unchanged",
144+
)
145+
146+
# force update again to fetched version from metadata but with applying patch
147+
subproject.update(
148+
force=True, files_to_ignore=files_to_ignore, patch_count=-1
149+
)
150+
151+
if exceptions:
152+
raise RuntimeError("\n".join(exceptions))

dfetch/project/git.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,19 @@ def metadata_revision(self) -> str:
4848
return str(self._local_repo.get_last_file_hash(self.metadata_path))
4949

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

5454
def _diff_impl(
55-
self, old_revision: str, new_revision: Optional[str], ignore: Sequence[str]
55+
self,
56+
old_revision: str,
57+
new_revision: Optional[str],
58+
ignore: Sequence[str],
59+
reverse: bool = False,
5660
) -> str:
5761
"""Get the diff of two revisions."""
5862
diff_since_revision = str(
59-
self._local_repo.create_diff(old_revision, new_revision, ignore)
63+
self._local_repo.create_diff(old_revision, new_revision, ignore, reverse)
6064
)
6165

6266
if new_revision:

dfetch/project/subproject.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,18 @@ def update_is_required(self, force: bool = False) -> Optional[Version]:
9393
return wanted
9494

9595
def update(
96-
self, force: bool = False, files_to_ignore: Optional[Sequence[str]] = None
96+
self,
97+
force: bool = False,
98+
files_to_ignore: Optional[Sequence[str]] = None,
99+
patch_count: int = -1,
97100
) -> None:
98101
"""Update this subproject if required.
99102
100103
Args:
101104
force (bool, optional): Ignore if version is ok or any local changes were done.
102105
Defaults to False.
103106
files_to_ignore (Sequence[str], optional): list of files that are ok to overwrite.
107+
patch_count (int, optional): Number of patches to apply (-1 means all).
104108
"""
105109
to_fetch = self.update_is_required(force)
106110

@@ -128,7 +132,7 @@ def update(
128132
actually_fetched = self._fetch_impl(to_fetch)
129133
self._log_project(f"Fetched {actually_fetched}")
130134

131-
applied_patches = self._apply_patches()
135+
applied_patches = self._apply_patches(patch_count)
132136

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

142-
def _apply_patches(self) -> list[str]:
146+
def _apply_patches(self, count: int = -1) -> list[str]:
143147
"""Apply the patches."""
144148
cwd = pathlib.Path(".").resolve()
145149
applied_patches = []
146-
for patch in self.__project.patch:
150+
count = len(self.__project.patch) if count == -1 else count
151+
for patch in self.__project.patch[:count]:
147152

148153
patch_path = (cwd / patch).resolve()
149154

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

269+
@property
270+
def patch(self) -> Sequence[str]:
271+
"""Get the patches of this project."""
272+
return self.__project.patch
273+
264274
@abstractmethod
265275
def check(self) -> bool:
266276
"""Check if it can handle the type."""
@@ -386,6 +396,7 @@ def _diff_impl(
386396
old_revision: str, # noqa
387397
new_revision: Optional[str], # noqa
388398
ignore: Sequence[str],
399+
reverse: bool = False,
389400
) -> str:
390401
"""Get the diff of two revisions, should be implemented by the child class."""
391402

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

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

413-
return self._diff_impl(old_rev, new_rev, ignore=(Metadata.FILENAME,))
424+
return self._diff_impl(
425+
old_revision, new_revision, ignore=(Metadata.FILENAME,), reverse=reverse
426+
)

dfetch/project/superproject.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,30 @@ def ignored_files(self, path: str) -> Sequence[str]:
7979
return SvnRepo.ignored_files(path)
8080

8181
return []
82+
83+
def in_vcs(self) -> bool:
84+
"""Check if this superproject is under version control."""
85+
return (
86+
GitLocalRepo(self.root_directory).is_git()
87+
or SvnRepo(self.root_directory).is_svn()
88+
)
89+
90+
def has_local_changes_in_dir(self, path: str) -> bool:
91+
"""Check if the superproject has local changes."""
92+
if GitLocalRepo(self.root_directory).is_git():
93+
return GitLocalRepo.any_changes_or_untracked(path)
94+
95+
if SvnRepo(self.root_directory).is_svn():
96+
return SvnRepo.any_changes_or_untracked(path)
97+
98+
return True
99+
100+
def current_revision(self) -> str:
101+
"""Get the last revision of the superproject."""
102+
if GitLocalRepo(self.root_directory).is_git():
103+
return GitLocalRepo(self.root_directory).get_current_hash()
104+
105+
if SvnRepo(self.root_directory).is_svn():
106+
return SvnRepo.get_last_changed_revision(self.root_directory)
107+
108+
return ""

0 commit comments

Comments
 (0)