Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Release 0.12.0 (unreleased)
* Add Fuzzing (#819)
* Don't allow NULL or control characters in manifest (#114)
* Allow multiple patches in manifest (#897)
* Fallback and warn if patch is not UTF-8 encoded (#941)
* Skip patches outside manifest dir (#942)
* Make patch path in metadata platform independent (#937)

Release 0.11.0 (released 2026-01-03)
====================================
Expand Down
10 changes: 6 additions & 4 deletions dfetch/manifest/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@
#####
*DFetch* promotes upstreaming changes, but also allows local changes. These changes can be managed with local patch
files. *DFetch* will apply the patch files in order every time a new upstream version is fetched. The patch file can
be specified with the ``patch:`` attribute. This can be a single patch file or multiple.
be specified with the ``patch:`` attribute. This can be a single patch file or multiple. Patch files should be UTF-8
encoded files and using forward slashes is encouraged for cross-platform support. The path should be relative to the
directory of the manifest.

.. code-block:: yaml

Expand All @@ -240,11 +242,11 @@
- name: cpputest
vcs: git
repo-path: cpputest/cpputest
patch: local_changes.patch
patch: patches/local_changes.patch

The patch should be generated using the *Dfetch* :ref:`Diff` command.
Alternately the patch can be generated manually as such.
Note that the patch should be *relative* to the projects root.
Alternately the patch can be generated manually as such and should be
a *relative* patch, relative to the fetched projects root.

.. tabs::

Expand Down
42 changes: 25 additions & 17 deletions dfetch/project/subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from typing import Optional

from halo import Halo
from patch_ng import fromfile

from dfetch.log import get_logger
from dfetch.manifest.project import ProjectEntry
Expand All @@ -17,6 +16,7 @@
from dfetch.project.metadata import Metadata
from dfetch.util.util import hash_directory, safe_rm
from dfetch.util.versions import latest_tag_from_list
from dfetch.vcs.patch import apply_patch

logger = get_logger(__name__)

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

applied_patches = []
for patch in self.__project.patch:
if os.path.exists(patch):
self.apply_patch(patch)
applied_patches.append(patch)
else:
logger.warning(f"Skipping non-existent patch {patch}")
applied_patches = self._apply_patches()

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

def apply_patch(self, patch: str) -> None:
"""Apply the specified patch to the destination."""
patch_set = fromfile(patch)
def _apply_patches(self) -> list[str]:
"""Apply the patches."""
cwd = pathlib.Path(".").resolve()
applied_patches = []
for patch in self.__project.patch:

if not patch_set:
raise RuntimeError(f'Invalid patch file: "{patch}"')
if patch_set.apply(0, root=self.local_path, fuzz=True):
self._log_project(f'Applied patch "{patch}"')
else:
raise RuntimeError(f'Applying patch "{patch}" failed')
patch_path = (cwd / patch).resolve()

try:
patch_path.relative_to(cwd)
except ValueError:
self._log_project(f'Skipping patch "{patch}" which is outside {cwd}.')
continue

if not patch_path.exists():
self._log_project(f"Skipping non-existent patch {patch}")
continue

normalized_patch_path = str(patch_path.relative_to(cwd).as_posix())

apply_patch(normalized_patch_path, root=self.local_path)
self._log_project(f'Applied patch "{normalized_patch_path}"')
applied_patches.append(normalized_patch_path)
return applied_patches

def check_for_update(
self, reporters: Sequence[AbstractCheckReporter], files_to_ignore: Sequence[str]
Expand Down
25 changes: 25 additions & 0 deletions dfetch/vcs/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

import patch_ng

from dfetch.log import get_logger

logger = get_logger(__name__)


def _git_mode(path: Path) -> str:
if path.is_symlink():
Expand Down Expand Up @@ -55,6 +59,27 @@ def dump_patch(patch_set: patch_ng.PatchSet) -> str:
return "\n".join(patch_lines) + "\n" if patch_lines else ""


def apply_patch(patch_path: str, root: str = ".") -> None:
"""Apply the specified patch relative to the root."""
patch_set = patch_ng.fromfile(patch_path)

if not patch_set:
with open(patch_path, "rb") as patch_file:
patch_text = patch_ng.decode_text(patch_file.read()).encode("utf-8")
patch_set = patch_ng.fromstring(patch_text)

if patch_set:
logger.warning(
f'After retrying found that patch-file "{patch_path}" '
"is not UTF-8 encoded, consider saving it with UTF-8 encoding."
)

if not patch_set:
raise RuntimeError(f'Invalid patch file: "{patch_path}"')
if not patch_set.apply(strip=0, root=root, fuzz=True):
raise RuntimeError(f'Applying patch "{patch_path}" failed')


def create_svn_patch_for_new_file(file_path: str) -> str:
"""Create a svn patch for a new file."""
diff = unified_diff_new_file(Path(file_path))
Expand Down
76 changes: 76 additions & 0 deletions features/patch-after-fetch-git.feature
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,79 @@ Feature: Patch after fetching from git repo
# Test-repo
A test repo for testing dfetch.
"""
And the output shows
"""
Dfetch (0.11.0)
ext/test-repo-tag : Fetched v2.0
successfully patched 1/1: b'README.md'
ext/test-repo-tag : Applied patch "001-diff.patch"
successfully patched 1/1: b'README.md'
ext/test-repo-tag : Applied patch "002-diff.patch"
"""

Scenario: Fallback to other file encodings if patch file is not UTF-8 encoded
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'

remotes:
- name: github-com-dfetch-org
url-base: https://github.com/dfetch-org/test-repo

projects:
- name: ext/test-repo-tag
tag: v2.0
dst: ext/test-repo-tag
patch: diff.patch
"""
And the patch file 'diff.patch' with 'UTF-16' encoding
"""
diff --git a/README.md b/README.md
index 32d9fad..62248b7 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,2 @@
# Test-repo
-A test repo for testing dfetch.
+A test repo for testing patch.
"""
When I run "dfetch update"
Then the patched 'ext/test-repo-tag/README.md' is
"""
# Test-repo
A test repo for testing patch.
"""
And the output shows
"""
Dfetch (0.11.0)
ext/test-repo-tag : Fetched v2.0
error: no patch data found!
After retrying found that patch-file "diff.patch" is not UTF-8 encoded, consider saving it with UTF-8 encoding.
successfully patched 1/1: b'README.md'
ext/test-repo-tag : Applied patch "diff.patch"
"""

Scenario: Patch files are outside manifest dir
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'

remotes:
- name: github-com-dfetch-org
url-base: https://github.com/dfetch-org/test-repo

projects:
- name: ext/test-repo-tag
tag: v2.0
dst: ext/test-repo-tag
patch: ../diff.patch
"""
When I run "dfetch update"
Then the output shows
"""
Dfetch (0.11.0)
ext/test-repo-tag : Fetched v2.0
ext/test-repo-tag : Skipping patch "../diff.patch" which is outside /some/path.
"""
11 changes: 7 additions & 4 deletions features/steps/generic_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
urn_uuid = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
bom_ref = re.compile(r"BomRef\.[0-9]+\.[0-9]+")
svn_error = re.compile(r"svn: E\d{6}: .+")
abs_path = re.compile(r"/tmp/[\w_]+")


def remote_server_path(context):
Expand Down Expand Up @@ -104,13 +105,13 @@ def check_content(
)


def generate_file(path, content):
def generate_file(path, content, encoding="UTF-8"):
opt_dir = path.rsplit("/", maxsplit=1)

if len(opt_dir) > 1:
pathlib.Path(opt_dir[0]).mkdir(parents=True, exist_ok=True)

with open(path, "w", encoding="UTF-8") as new_file:
with open(path, "w", encoding=encoding) as new_file:
for line in content.splitlines():
print(line, file=new_file)

Expand Down Expand Up @@ -181,6 +182,7 @@ def check_output(context, line_count=None):
"some-remote-server",
),
(svn_error, "svn: EXXXXXX: <some error text>"),
(abs_path, "/some/path"),
],
text=context.cmd_output,
)
Expand All @@ -202,8 +204,9 @@ def step_impl(_, old: str, new: str, path: str):


@given("the patch file '{name}'")
def step_impl(context, name):
generate_file(os.path.join(os.getcwd(), name), context.text)
@given("the patch file '{name}' with '{encoding}' encoding")
def step_impl(context, name, encoding="UTF-8"):
generate_file(os.path.join(os.getcwd(), name), context.text, encoding)


@given('"{path}" in {directory} is created')
Expand Down
Loading