From 2dcc709dd530f5a77acde7b7af04dfbd6fa91bde Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 20 Feb 2026 20:35:35 +0000 Subject: [PATCH 1/2] Bump pip to 26.0.1 --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0ee5e433..ccadd66a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -40,7 +40,7 @@ ENV PYTHONUSERBASE="/home/dev/.local" COPY --chown=dev:dev . . -RUN pip install --no-cache-dir --root-user-action=ignore --upgrade pip==25.3 \ +RUN pip install --no-cache-dir --root-user-action=ignore --upgrade pip==26.0.1 \ && pip install --no-cache-dir --root-user-action=ignore -e .[development,docs,test,casts,build] \ && pre-commit install --install-hooks From 44acb009d2e3beacaedb1e1ee0cfa5046c44b1ed Mon Sep 17 00:00:00 2001 From: Ben Spoor <37540691+ben-edna@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:24:04 +0000 Subject: [PATCH 2/2] Introduce Patch class for cleaner API --- dfetch/commands/format_patch.py | 42 +-- dfetch/project/gitsubproject.py | 5 - dfetch/project/gitsuperproject.py | 12 +- dfetch/project/subproject.py | 10 +- dfetch/project/svnsubproject.py | 5 - dfetch/project/svnsuperproject.py | 26 +- dfetch/vcs/git.py | 11 +- dfetch/vcs/patch.py | 468 ++++++++++++++---------- dfetch/vcs/svn.py | 8 +- features/diff-in-git.feature | 2 +- features/journey-basic-patching.feature | 2 +- tests/test_patch.py | 70 ++-- tests/test_subproject.py | 3 - 13 files changed, 355 insertions(+), 309 deletions(-) diff --git a/dfetch/commands/format_patch.py b/dfetch/commands/format_patch.py index 767e7e45..a0358668 100644 --- a/dfetch/commands/format_patch.py +++ b/dfetch/commands/format_patch.py @@ -29,8 +29,6 @@ import pathlib import re -import patch_ng - import dfetch.commands.command import dfetch.manifest.project import dfetch.project @@ -40,14 +38,7 @@ from dfetch.project.subproject import SubProject from dfetch.project.svnsubproject import SvnSubProject from dfetch.util.util import catch_runtime_exceptions, in_directory -from dfetch.vcs.patch import ( - PatchAuthor, - PatchInfo, - add_prefix_to_patch, - convert_patch_to, - dump_patch, - parse_patch, -) +from dfetch.vcs.patch import Patch, PatchAuthor, PatchInfo, PatchType logger = get_logger(__name__) @@ -112,7 +103,7 @@ def __call__(self, args: argparse.Namespace) -> None: continue version = subproject.on_disk_version() - for idx, patch in enumerate(subproject.patch, start=1): + for idx, patch_file in enumerate(subproject.patch, start=1): patch_info = PatchInfo( author=PatchAuthor( @@ -125,20 +116,19 @@ def __call__(self, args: argparse.Namespace) -> None: revision="" if not version else version.revision, ) - corrected_patch = convert_patch_to( - parse_patch(patch), _determine_target_patch_type(subproject) + patch = Patch.from_file(patch_file).convert_type( + _determine_target_patch_type(subproject) ) - prefixed_patch = add_prefix_to_patch( - corrected_patch, - path_prefix=re.split(r"\*", subproject.source, 1)[0].rstrip( - "/" - ), + patch.add_prefix( + re.split(r"\*", subproject.source, 1)[0].rstrip("/") ) - output_patch_file = output_dir_path / pathlib.Path(patch).name + output_patch_file = ( + output_dir_path / pathlib.Path(patch_file).name + ) output_patch_file.write_text( - subproject.create_formatted_patch_header(patch_info) - + dump_patch(prefixed_patch) + patch.dump_header(patch_info) + patch.dump(), + encoding="utf-8", ) logger.print_info_line( @@ -150,13 +140,13 @@ def __call__(self, args: argparse.Namespace) -> None: raise RuntimeError("\n".join(exceptions)) -def _determine_target_patch_type(subproject: SubProject) -> str: +def _determine_target_patch_type(subproject: SubProject) -> PatchType: """Determine the subproject type for the patch.""" if isinstance(subproject, GitSubProject): - required_type = patch_ng.GIT + required_type = PatchType.GIT elif isinstance(subproject, SvnSubProject): - required_type = patch_ng.SVN + required_type = PatchType.SVN else: - required_type = patch_ng.PLAIN + required_type = PatchType.PLAIN - return str(required_type) + return required_type diff --git a/dfetch/project/gitsubproject.py b/dfetch/project/gitsubproject.py index 5315f8b0..c52f3208 100644 --- a/dfetch/project/gitsubproject.py +++ b/dfetch/project/gitsubproject.py @@ -10,7 +10,6 @@ from dfetch.project.subproject import SubProject from dfetch.util.util import safe_rmtree from dfetch.vcs.git import GitLocalRepo, GitRemote, get_git_version -from dfetch.vcs.patch import PatchInfo logger = get_logger(__name__) @@ -112,7 +111,3 @@ def _determine_fetched_version(self, version: Version, fetched_sha: str) -> Vers def get_default_branch(self) -> str: # type: ignore """Get the default branch of this repository.""" return self._remote_repo.get_default_branch() - - def create_formatted_patch_header(self, patch_info: PatchInfo) -> str: - """Create a formatted patch header for the given patch info.""" - return patch_info.to_git_header() diff --git a/dfetch/project/gitsuperproject.py b/dfetch/project/gitsuperproject.py index 4614ad2d..ec9f7928 100644 --- a/dfetch/project/gitsuperproject.py +++ b/dfetch/project/gitsuperproject.py @@ -18,7 +18,6 @@ from dfetch.project.superproject import RevisionRange, SuperProject from dfetch.util.util import resolve_absolute_path from dfetch.vcs.git import GitLocalRepo -from dfetch.vcs.patch import reverse_patch logger = get_logger(__name__) @@ -138,14 +137,9 @@ def diff( combined_diff += [diff_since_revision] untracked_files_patch = local_repo.untracked_files_patch(ignore) - if untracked_files_patch: + if not untracked_files_patch.is_empty(): if reverse: - reversed_patch = reverse_patch(untracked_files_patch.encode("utf-8")) - if not reversed_patch: - raise RuntimeError( - "Failed to reverse untracked files patch; patch parsing returned empty." - ) - untracked_files_patch = reversed_patch - combined_diff += [untracked_files_patch] + untracked_files_patch.reverse() + combined_diff += [untracked_files_patch.dump()] return "\n".join(combined_diff) diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index d787c9bd..20f685e8 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -13,7 +13,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 PatchInfo, apply_patch +from dfetch.vcs.patch import Patch logger = get_logger(__name__) @@ -161,7 +161,7 @@ def _apply_patches(self, count: int = -1) -> list[str]: normalized_patch_path = str(relative_patch_path.as_posix()) self._log_project(f'Applying patch "{normalized_patch_path}"') - result = apply_patch(normalized_patch_path, root=self.local_path) + result = Patch.from_file(normalized_patch_path).apply(root=self.local_path) if result.encoding_warning: self._log_project( @@ -395,9 +395,3 @@ def is_license_file(filename: str) -> bool: fnmatch.fnmatch(filename.lower(), pattern) for pattern in SubProject.LICENSE_GLOBS ) - - @abstractmethod - def create_formatted_patch_header(self, patch_info: PatchInfo) -> str: - """Create a formatted patch header for the given patch info.""" - del patch_info - return "" diff --git a/dfetch/project/svnsubproject.py b/dfetch/project/svnsubproject.py index 0ca4a2ce..6284daaf 100644 --- a/dfetch/project/svnsubproject.py +++ b/dfetch/project/svnsubproject.py @@ -13,7 +13,6 @@ find_non_matching_files, safe_rm, ) -from dfetch.vcs.patch import PatchInfo from dfetch.vcs.svn import SvnRemote, SvnRepo, get_svn_version logger = get_logger(__name__) @@ -180,7 +179,3 @@ def _get_revision(self, branch: str) -> str: def get_default_branch(self) -> str: """Get the default branch of this repository.""" return SvnRepo.DEFAULT_BRANCH - - def create_formatted_patch_header(self, patch_info: PatchInfo) -> str: - """Create a formatted patch header for the given patch info.""" - return patch_info.to_svn_header() diff --git a/dfetch/project/svnsuperproject.py b/dfetch/project/svnsuperproject.py index 706902d6..e19aa11f 100644 --- a/dfetch/project/svnsuperproject.py +++ b/dfetch/project/svnsuperproject.py @@ -20,11 +20,7 @@ in_directory, resolve_absolute_path, ) -from dfetch.vcs.patch import ( - combine_patches, - create_svn_patch_for_new_file, - reverse_patch, -) +from dfetch.vcs.patch import Patch, PatchType from dfetch.vcs.svn import SvnRepo logger = get_logger(__name__) @@ -116,22 +112,18 @@ def diff( if new: new, old = old, new - filtered = repo.create_diff(old, new, ignore) + patch = repo.create_diff(old, new, ignore) if new: - return filtered + return patch.dump() - patches: list[bytes] = [filtered.encode("utf-8")] if filtered else [] with in_directory(path): - for file_path in repo.untracked_files(".", ignore): - patch = create_svn_patch_for_new_file(file_path) - if patch: - patches.append(patch.encode("utf-8")) - - patch_str = combine_patches(patches) + patch.extend( + Patch.for_new_files(repo.untracked_files(".", ignore), PatchType.SVN) + ) # SVN has no way of producing a reverse working copy patch, reverse ourselves - if reverse and not new: - patch_str = reverse_patch(patch_str.encode("UTF-8")) + if reverse: + patch.reverse() - return patch_str + return patch.dump() diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index dbb28f66..01315732 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -12,7 +12,7 @@ from dfetch.log import get_logger from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline from dfetch.util.util import in_directory, safe_rmtree -from dfetch.vcs.patch import create_git_patch_for_new_file +from dfetch.vcs.patch import Patch, PatchType logger = get_logger(__name__) @@ -457,7 +457,7 @@ def any_changes_or_untracked(path: str) -> bool: .splitlines() ) - def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> str: + def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> Patch: """Create a diff for untracked files.""" with in_directory(self._path): untracked_files = ( @@ -476,10 +476,9 @@ def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> str: ] if untracked_files: - return "\n".join( - [create_git_patch_for_new_file(file) for file in untracked_files] - ) - return "" + return Patch.for_new_files(untracked_files, PatchType.GIT) + + return Patch.empty() @staticmethod def submodules() -> list[Submodule]: diff --git a/dfetch/vcs/patch.py b/dfetch/vcs/patch.py index 23591759..249792bb 100644 --- a/dfetch/vcs/patch.py +++ b/dfetch/vcs/patch.py @@ -1,5 +1,8 @@ """Various patch utilities for VCS systems.""" +from __future__ import annotations + +import copy import datetime import difflib import hashlib @@ -8,6 +11,7 @@ from collections.abc import Sequence from dataclasses import dataclass, field from email.utils import format_datetime +from enum import Enum from pathlib import Path import patch_ng @@ -17,6 +21,17 @@ configure_external_logger("patch_ng") +class PatchType(Enum): + """Type of patch.""" + + DIFF = patch_ng.DIFF + GIT = patch_ng.GIT + HG = patch_ng.HG + SVN = patch_ng.SVN + PLAIN = patch_ng.PLAIN + MIXED = patch_ng.MIXED + + @dataclass class PatchResult: """Result of applying a patch.""" @@ -24,141 +39,295 @@ class PatchResult: encoding_warning: bool = False -def _git_mode(path: Path) -> str: - if path.is_symlink(): - return "120000" - perms = stat.S_IMODE(path.stat().st_mode) - return "100755" if perms & stat.S_IXUSR else "100644" - - -def _git_blob_sha1(path: Path) -> str: - data = path.read_bytes() - header = f"blob {len(data)}\0".encode("ascii") - store = header + data - return hashlib.sha1(store, usedforsecurity=False).hexdigest() - - -def filter_patch(patch_text: bytes, ignore: Sequence[str]) -> str: - """Filter out files from a patch text.""" - if not patch_text: - return "" - - filtered_patchset = patch_ng.PatchSet() - unfiltered_patchset = patch_ng.fromstring(patch_text) or [] +@dataclass(eq=False) +class Patch: + """Patch object for parsing, manipulating, and applying patches. + + This class provides a high-level interface for working with patches, abstracting + away the underlying patch_ng library. It supports loading patches from files or + strings, applying them to a filesystem, and converting between different patch + formats (e.g., git vs svn). It also allows for filtering out specific files and + adding path prefixes to all files in the patch. The class is designed to be flexible + and extensible, making it easier to work with patches in various contexts. + """ + + _patchset: patch_ng.PatchSet + path: str = "" + _result: PatchResult = field(default_factory=PatchResult) + + @staticmethod + def from_file(path: str | Path) -> Patch: + """Create patch object from file.""" + ps = patch_ng.fromfile(str(path)) + result = PatchResult() + + if not ps: + with open(path, "rb") as patch_file: + patch_text = patch_ng.decode_text(patch_file.read()).encode("utf-8") + ps = patch_ng.fromstring(patch_text) + + if ps: + result.encoding_warning = True + + if not ps or not ps.items: + raise RuntimeError(f'Invalid or empty patch: "{path}"') + return Patch(ps, path=str(path), _result=result) + + @staticmethod + def from_bytes(data: bytes) -> Patch: + """Create patch object from data bytes.""" + ps = patch_ng.fromstring(data) + if not ps or not ps.items: + raise RuntimeError("Invalid patch input") + return Patch(ps) + + @staticmethod + def from_string(data: str) -> Patch: + """Create patch object from str.""" + return Patch.from_bytes(data.encode("UTF-8")) + + @staticmethod + def _unified_diff_new_file(path: Path) -> list[str]: + """Create a unified diff for a new file.""" + with path.open("r", encoding="utf-8", errors="replace") as new_file: + lines = new_file.readlines() + + return list( + difflib.unified_diff( + [], lines, fromfile="/dev/null", tofile=str(path), lineterm="\n" + ) + ) - for patch in unfiltered_patchset: - if patch.target.decode("utf-8") not in ignore: - filtered_patchset.items += [patch] + @staticmethod + def _for_new_file(file_path: str | Path, patch_type: PatchType) -> Patch: + """Create a patch for a new untracked file, preserving file mode.""" + path = Path(file_path) + diff = Patch._unified_diff_new_file(path) + + if not diff: + return Patch.empty().convert_type(patch_type) + + if patch_type == PatchType.GIT: + return Patch.from_string( + "".join( + [ + f"diff --git a/{file_path} b/{file_path}\n", + f"new file mode {_git_mode(path)}\n", + f"index 0000000..{_git_blob_sha1(path)[:7]}\n", + ] + + diff + ) + ) - return dump_patch(filtered_patchset) + if patch_type == PatchType.SVN: + return Patch.from_string( + "".join([f"Index: {file_path}\n", "=" * 67 + "\n"] + diff) + ) + return Patch.from_string("".join(diff)) + + @staticmethod + def for_new_files( + file_paths: list[str] | list[Path], patch_type: PatchType + ) -> Patch: + """Create a patch for multiple new files.""" + patch: Patch | None = None + for file in file_paths: + new_patch = Patch._for_new_file(file, patch_type) + if not new_patch.is_empty(): + if patch is None: + patch = new_patch + else: + patch.extend(new_patch) + return patch if patch is not None else Patch.empty() -def dump_patch(patch_set: patch_ng.PatchSet) -> str: - """Dump a patch to string.""" - patch_lines: list[str] = [] + @staticmethod + def empty() -> Patch: + """Create empty patch object.""" + return Patch(patch_ng.PatchSet()) - for p in patch_set.items: - for headline in p.header: - patch_lines.append(headline.rstrip(b"\r\n").decode("utf-8")) + def is_empty(self) -> bool: + """Check if the patch is empty.""" + return not self._patchset.items - source, target = p.source.decode("utf-8"), p.target.decode("utf-8") - if p.type == patch_ng.GIT: - if source != "/dev/null": - source = "a/" + source - if target != "/dev/null": - target = "b/" + target + @property + def files(self) -> list[str]: + """Get a list of all target files.""" + return [patch.target.decode("utf-8") for patch in self._patchset.items] - patch_lines.append(f"--- {source}") - patch_lines.append(f"+++ {target}") - for h in p.hunks: - patch_lines.append( - f"@@ -{h.startsrc},{h.linessrc} +{h.starttgt},{h.linestgt} @@" + def apply(self, root: str = ".", fuzz: bool = True) -> PatchResult: + """Apply this patch to a filesystem root.""" + if not self._patchset.apply(strip=0, root=root, fuzz=fuzz): + raise RuntimeError( + f'Applying patch "{self.path or ""}" failed' ) - for line in h.text: - patch_lines.append(line.rstrip(b"\r\n").decode("utf-8")) - return "\n".join(patch_lines) + "\n" if patch_lines else "" - -def apply_patch( - patch_path: str, - root: str = ".", -) -> PatchResult: - """Apply the specified patch relative to the root.""" - patch_set = patch_ng.fromfile(patch_path) + return self._result - result = PatchResult() + def dump(self) -> str: + """Serialize patch back to unified diff text.""" + if self.is_empty(): + return "" - 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) + patch_lines: list[str] = [] - if patch_set: - result.encoding_warning = True + for p in self._patchset.items: + for headline in p.header: + patch_lines.append(headline.rstrip(b"\r\n").decode("utf-8")) - 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') - - return result - - -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)) - return ( - "" if not diff else "".join([f"Index: {file_path}\n", "=" * 67 + "\n"] + diff) - ) + source, target = p.source.decode("utf-8"), p.target.decode("utf-8") + if p.type == patch_ng.GIT: + if source != "/dev/null": + source = "a/" + source + if target != "/dev/null": + target = "b/" + target + patch_lines.append(f"--- {source}") + patch_lines.append(f"+++ {target}") + for h in p.hunks: + patch_lines.append( + f"@@ -{h.startsrc},{h.linessrc} +{h.starttgt},{h.linestgt} @@" + ) + for line in h.text: + patch_lines.append(line.rstrip(b"\r\n").decode("utf-8")) + return "\n".join(patch_lines) + "\n" if patch_lines else "" + + def dump_header(self, patch_info: PatchInfo) -> str: + """Dump patch header based on patch type.""" + if self._patchset.type == PatchType.GIT.value: + return patch_info.to_git_header() + return "" -def create_git_patch_for_new_file(file_path: str) -> str: - """Create a Git patch for a new untracked file, preserving file mode.""" - path = Path(file_path) - diff = _unified_diff_new_file(path) - - return ( - "" - if not diff - else "".join( - [ - f"diff --git a/{file_path} b/{file_path}\n", - f"new file mode {_git_mode(path)}\n", - f"index 0000000..{_git_blob_sha1(path)[:7]}\n", - ] - + diff + def reverse(self) -> Patch: + """Reverse this patch.""" + if self.is_empty(): + return self + reversed_text = _reverse_patch(self.dump()) + if not reversed_text: + raise RuntimeError("Failed to reverse patch") + self._patchset = self.from_bytes( # pylint: disable=protected-access + reversed_text.encode("utf-8") + )._patchset + return self + + def filter(self, ignore: Sequence[str]) -> Patch: + """Remove the ignored files.""" + filtered = patch_ng.PatchSet() + filtered.type = self._patchset.type + for p in self._patchset: + if p.target.decode("utf-8") not in ignore: + filtered.items.append(p) + self._patchset = filtered + return self + + def add_prefix(self, path_prefix: str) -> Patch: + """Add path_prefix to all file paths.""" + prefix = path_prefix.strip("/").encode() + if prefix: + prefix += b"/" + + diff_git = re.compile( + r"^diff --git (?:(?Pa/))?(?P.+) (?:(?Pb/))?(?P.+?)[\r\n]*$" ) - ) - + svn_index = re.compile(rb"^Index: (?P.+)$") + + for file in self._patchset.items: + file.source = _rewrite_path(prefix, file.source) + file.target = _rewrite_path(prefix, file.target) + + for idx, line in enumerate(file.header): + + git_match = diff_git.match(line.decode("utf-8", errors="replace")) + if git_match: + file.header[idx] = ( + b"diff --git " + + ( + git_match.group("a").encode() + if git_match.group("a") + else b"" + ) + + _rewrite_path(prefix, git_match.group("old").encode()) + + b" " + + ( + git_match.group("b").encode() + if git_match.group("b") + else b"" + ) + + _rewrite_path(prefix, git_match.group("new").encode()) + ) + break + + svn_match = svn_index.match(line) + if svn_match: + file.header[idx] = b"Index: " + _rewrite_path( + prefix, svn_match.group("target") + ) + break + + return self + + def convert_type(self, required: PatchType) -> Patch: + """Convert patch type: patch_ng.GIT <-> patch_ng.SVN. No-op for other types.""" + if required.value == self._patchset.type: + return self + + if required.value == patch_ng.GIT: + for file in self._patchset.items: + file.header = [ + b"diff --git " + + _rewrite_path(b"a/", file.source) + + b" " + + _rewrite_path(b"b/", file.target) + + b"\n" + ] + file.type = required.value + elif required.value == patch_ng.SVN: + for file in self._patchset.items: + file.header = [b"Index: " + file.target + b"\n", b"=" * 67 + b"\n"] + file.type = required.value + else: + # Unsupported conversion, leave headers and per-file types unchanged. + return self + self._patchset.type = required.value + return self + + def extend(self, other: Patch | Sequence[Patch]) -> Patch: + """Extend this patch with another patch or sequence of patches.""" + if isinstance(other, Patch): + other = [other] + + for patch in other: + if ( + patch._patchset.type # pylint: disable=protected-access + != self._patchset.type + ): + patch = copy.deepcopy(patch) + patch.convert_type(PatchType(self._patchset.type)) + + self._patchset.items += copy.copy( + patch._patchset.items # pylint: disable=protected-access + ) -def _unified_diff_new_file(path: Path) -> list[str]: - """Create a unified diff for a new file.""" - with path.open("r", encoding="utf-8", errors="replace") as new_file: - lines = new_file.readlines() + return self - return list( - difflib.unified_diff( - [], lines, fromfile="/dev/null", tofile=str(path), lineterm="\n" - ) - ) +def _git_mode(path: Path) -> str: + if path.is_symlink(): + return "120000" + perms = stat.S_IMODE(path.stat().st_mode) + return "100755" if perms & stat.S_IXUSR else "100644" -def combine_patches(patches: Sequence[bytes]) -> str: - """Combine multiple patches into a single patch.""" - if not patches: - return "" - - final_patchset = patch_ng.PatchSet() - for patch in patches: - for patch_obj in patch_ng.fromstring(patch) or []: - final_patchset.items += [patch_obj] - return dump_patch(final_patchset) +def _git_blob_sha1(path: Path) -> str: + data = path.read_bytes() + header = f"blob {len(data)}\0".encode("ascii") + store = header + data + return hashlib.sha1(store, usedforsecurity=False).hexdigest() -def reverse_patch(patch_text: bytes) -> str: +def _reverse_patch(patch_text: str) -> str: """Reverse the given patch.""" - patch = patch_ng.fromstring(patch_text) + patch = patch_ng.fromstring(patch_text.encode("utf-8")) reverse_patch_lines: list[bytes] = [] @@ -244,86 +413,9 @@ def to_git_header(self) -> str: "\n" ) - def to_svn_header(self) -> str: - """Convert patch info to a string.""" - return "" - - -def parse_patch(file_path: str | Path) -> patch_ng.PatchSet: - """Parse the patch from file_path.""" - patch = patch_ng.fromfile(str(file_path)) - if not patch or not patch.items: - raise RuntimeError(f'Failed to parse patch file: "{file_path}"') - return patch - def _rewrite_path(prefix: bytes, path: bytes) -> bytes: """Add prefix if a real path.""" if path == b"/dev/null": return b"/dev/null" return prefix + path - - -def add_prefix_to_patch( - patch: patch_ng.PatchSet, path_prefix: str -) -> patch_ng.PatchSet: - """Add a prefix to all file paths in the given patch file.""" - prefix = path_prefix.strip("/").encode() - if prefix: - prefix += b"/" - - diff_git = re.compile( - r"^diff --git (?:(?Pa/))?(?P.+) (?:(?Pb/))?(?P.+?)[\r\n]*$" - ) - svn_index = re.compile(rb"^Index: (.+)$") - - for file in patch.items: - file.source = _rewrite_path(prefix, file.source) - file.target = _rewrite_path(prefix, file.target) - - for idx, line in enumerate(file.header): - - git_match = diff_git.match(line.decode("utf-8", errors="replace")) - if git_match: - file.header[idx] = ( - b"diff --git " - + (git_match.group("a").encode() if git_match.group("a") else b"") - + _rewrite_path(prefix, git_match.group("old").encode()) - + b" " - + (git_match.group("b").encode() if git_match.group("b") else b"") - + _rewrite_path(prefix, git_match.group("new").encode()) - ) - break - - svn_match = svn_index.match(line) - if svn_match: - file.header[idx] = b"Index: " + _rewrite_path( - prefix, svn_match.group(1) - ) - break - - return patch - - -def convert_patch_to(patch: patch_ng.PatchSet, required_type: str) -> patch_ng.PatchSet: - """Convert the patch to the required type.""" - if required_type == patch.type: - return patch - - if required_type == patch_ng.GIT: - for file in patch.items: - file.header = [ - b"diff --git " - + _rewrite_path(b"a/", file.source) - + b" " - + _rewrite_path(b"b/", file.target) - + b"\n" - ] - file.type = required_type - elif required_type == patch_ng.SVN: - for file in patch.items: - file.header = [b"Index: " + file.target + b"\n", b"=" * 67 + b"\n"] - file.type = required_type - patch.type = required_type - - return patch diff --git a/dfetch/vcs/svn.py b/dfetch/vcs/svn.py index 793eff3a..e8ca6f84 100644 --- a/dfetch/vcs/svn.py +++ b/dfetch/vcs/svn.py @@ -10,7 +10,7 @@ from dfetch.log import get_logger from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline from dfetch.util.util import in_directory -from dfetch.vcs.patch import filter_patch +from dfetch.vcs.patch import Patch, PatchType logger = get_logger(__name__) @@ -318,7 +318,7 @@ def create_diff( old_revision: str, new_revision: str | None, ignore: Sequence[str], - ) -> str: + ) -> Patch: """Generate a relative diff patch.""" cmd = ["svn", "diff", "--non-interactive", "--ignore-properties", "."] @@ -333,7 +333,9 @@ def create_diff( with in_directory(self._path): patch_text = run_on_cmdline(logger, cmd).stdout - return filter_patch(patch_text, ignore) + if not patch_text.strip(): + return Patch.empty().convert_type(PatchType.SVN) + return Patch.from_bytes(patch_text).filter(ignore) def get_username(self) -> str: """Get the username of the local svn repo.""" diff --git a/features/diff-in-git.feature b/features/diff-in-git.feature index 3c4a85ac..30cfc49d 100644 --- a/features/diff-in-git.feature +++ b/features/diff-in-git.feature @@ -56,7 +56,7 @@ Feature: Diff in git index 0000000..0ee3895 --- /dev/null +++ NEW_UNCOMMITTED_FILE.md - @@ -0,0 +1 @@ + @@ -0,0 +1,1 @@ +Some content """ diff --git a/features/journey-basic-patching.feature b/features/journey-basic-patching.feature index ed89cabf..cbe02c19 100644 --- a/features/journey-basic-patching.feature +++ b/features/journey-basic-patching.feature @@ -37,7 +37,7 @@ Feature: Basic patch journey index 0000000..0ee3895 --- /dev/null +++ my-new-file.md - @@ -0,0 +1 @@ + @@ -0,0 +1,1 @@ +Some content """ When the manifest 'dfetch.yaml' in MyPatchExample is changed to diff --git a/tests/test_patch.py b/tests/test_patch.py index 146f1e59..4c9d88d0 100644 --- a/tests/test_patch.py +++ b/tests/test_patch.py @@ -12,14 +12,11 @@ from hypothesis import given, settings from hypothesis import strategies as st +from dfetch.util.util import in_directory from dfetch.vcs.patch import ( - add_prefix_to_patch, - apply_patch, - create_git_patch_for_new_file, - create_svn_patch_for_new_file, - dump_patch, - parse_patch, - reverse_patch, + Patch, + PatchType, + _reverse_patch, ) @@ -30,10 +27,11 @@ def _normalize(patch: str) -> str: def test_create_git_patch_for_new_file(tmp_path): """Check basic patch generation for new files.""" - test_file = tmp_path / "test.txt" - test_file.write_text("Hello World\n\nLine above is empty\n") + test_file = Path("test.txt") - actual_patch = create_git_patch_for_new_file(str(test_file)) + with in_directory(tmp_path): + test_file.write_text("Hello World\n\nLine above is empty\n") + actual_patch = Patch._for_new_file(str(test_file), PatchType.GIT) expected_patch = "\n".join( [ @@ -50,15 +48,16 @@ def test_create_git_patch_for_new_file(tmp_path): ] ) - assert actual_patch == expected_patch + assert actual_patch.dump() == expected_patch def test_create_svn_patch_for_new_file(tmp_path): """Check basic patch generation for new files.""" - test_file = tmp_path / "test.txt" - test_file.write_text("Hello World\n\nLine above is empty\n") + test_file = Path("test.txt") - actual_patch = create_svn_patch_for_new_file(str(test_file)) + with in_directory(tmp_path): + test_file.write_text("Hello World\n\nLine above is empty\n") + actual_patch = Patch._for_new_file(str(test_file), PatchType.SVN) expected_patch = "\n".join( [ @@ -74,7 +73,7 @@ def test_create_svn_patch_for_new_file(tmp_path): ] ) - assert actual_patch == expected_patch + assert actual_patch.dump() == expected_patch def test_reverse_patch_simple_addition(): @@ -87,7 +86,7 @@ def test_reverse_patch_simple_addition(): @@ -1,1 +1,2 @@ Patched file for SomeProject +Update to patched file for SomeProject - """).encode() + """) expected = _normalize(""" Index: README.md @@ -99,7 +98,7 @@ def test_reverse_patch_simple_addition(): -Update to patched file for SomeProject """) - assert reverse_patch(patch) == expected + assert _reverse_patch(patch) == expected def test_reverse_patch_replacement_order(): @@ -113,7 +112,7 @@ def test_reverse_patch_replacement_order(): -Patched file for SomeProject -Update to patched file for SomeProject +Generated file for SomeProject - """).encode() + """) expected = _normalize(""" Index: README.md @@ -126,7 +125,7 @@ def test_reverse_patch_replacement_order(): +Update to patched file for SomeProject """) - assert reverse_patch(patch) == expected + assert _reverse_patch(patch) == expected def test_reverse_patch_mixed_context(): @@ -140,7 +139,7 @@ def test_reverse_patch_mixed_context(): +line TWO line three line four - """).encode() + """) expected = _normalize(""" --- b/file.txt @@ -153,7 +152,7 @@ def test_reverse_patch_mixed_context(): line four """) - assert reverse_patch(patch) == expected + assert _reverse_patch(patch) == expected def test_reverse_patch_multiple_hunks(): @@ -169,7 +168,7 @@ def test_reverse_patch_multiple_hunks(): context +added line more context - """).encode() + """) expected = _normalize(""" --- b/file.txt @@ -184,7 +183,7 @@ def test_reverse_patch_multiple_hunks(): more context """) - assert reverse_patch(patch) == expected + assert _reverse_patch(patch) == expected def test_reverse_patch_file_creation(): @@ -195,7 +194,7 @@ def test_reverse_patch_file_creation(): @@ -0,0 +1,2 @@ +hello +world - """).encode() + """) expected = _normalize(""" --- b/newfile.txt @@ -205,7 +204,7 @@ def test_reverse_patch_file_creation(): -world """) - assert reverse_patch(patch) == expected + assert _reverse_patch(patch) == expected def test_reverse_patch_file_deletion(): @@ -216,7 +215,7 @@ def test_reverse_patch_file_deletion(): @@ -1,2 +0,0 @@ -goodbye -cruel world - """).encode() + """) expected = _normalize(""" --- /dev/null @@ -226,7 +225,7 @@ def test_reverse_patch_file_deletion(): +cruel world """) - assert reverse_patch(patch) == expected + assert _reverse_patch(patch) == expected def test_reverse_patch_zero_length_hunk(): @@ -236,7 +235,7 @@ def test_reverse_patch_zero_length_hunk(): +++ b/file.txt @@ -3,0 +3,1 @@ +inserted - """).encode() + """) expected = _normalize(""" --- b/file.txt @@ -245,7 +244,7 @@ def test_reverse_patch_zero_length_hunk(): -inserted """) - assert reverse_patch(patch) == expected + assert _reverse_patch(patch) == expected # Random small file: 5–15 lines, each line 5–20 chars (filtered to exclude control chars) @@ -292,7 +291,7 @@ def test_reverse_patch_small_random(original_lines, rng): ) ) - patch_reverse = reverse_patch(patch_forward.encode()) + patch_reverse = _reverse_patch(patch_forward) if not patch_forward: # No changes detected; skip @@ -306,9 +305,9 @@ def test_reverse_patch_small_random(original_lines, rng): patch_file.write_text(patch_reverse) try: - apply_patch(str(patch_file), root=str(tmp_path)) + Patch.from_file(patch_file).apply(root=str(tmp_path)) except Exception as e: - assert False, f"Reverse patch failed: {e}" + pytest.fail(reason=f"Reverse patch failed: {e}") restored = target_file.read_text() assert restored == original, "Reverse patch did not restore original!" @@ -334,8 +333,6 @@ def test_patch_prefix_new_file(tmp_path): original_patch_file = tmp_path / "original.patch" original_patch_file.write_text(original_patch) - parsed_patch = parse_patch(original_patch_file) - expected_patch = "\n".join( [ "diff --git a/src/test.txt b/src/test.txt", @@ -351,9 +348,8 @@ def test_patch_prefix_new_file(tmp_path): ] ) - prefixed_patch = add_prefix_to_patch( - parsed_patch, + prefixed_patch = Patch.from_file(original_patch_file).add_prefix( path_prefix="src", ) - assert dump_patch(prefixed_patch) == expected_patch + assert prefixed_patch.dump() == expected_patch diff --git a/tests/test_subproject.py b/tests/test_subproject.py index 636cec6e..b3503c29 100644 --- a/tests/test_subproject.py +++ b/tests/test_subproject.py @@ -46,9 +46,6 @@ def _list_of_tags(self): def get_default_branch(self): return "" - def create_formatted_patch_header(self, patch_info): - return "" - @pytest.mark.parametrize( "name, given_on_disk, given_wanted, expect_wanted, expect_have",