diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e199302e..f03fb74a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ==================================== diff --git a/dfetch.code-workspace b/dfetch.code-workspace index 6e6783c8..a0d20c20 100644 --- a/dfetch.code-workspace +++ b/dfetch.code-workspace @@ -63,7 +63,7 @@ "module": "dfetch.__main__", "justMyCode": false, "args": [ - "update" + "update-patch", "sphinxcontrib.asciinema" ] } ] diff --git a/dfetch/__main__.py b/dfetch/__main__.py index 0cc82c8b..1ae91b5c 100644 --- a/dfetch/__main__.py +++ b/dfetch/__main__.py @@ -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 @@ -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 diff --git a/dfetch/commands/command.py b/dfetch/commands/command.py index b2e6d1cd..7e71b335 100644 --- a/dfetch/commands/command.py +++ b/dfetch/commands/command.py @@ -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 @@ -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"(? ../some-project.patch - - # Force a fetch and update the patch to test your changes - dfetch update -f some-project - """ import argparse diff --git a/dfetch/commands/update_patch.py b/dfetch/commands/update_patch.py new file mode 100644 index 00000000..674dc772 --- /dev/null +++ b/dfetch/commands/update_patch.py @@ -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="", + 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)) diff --git a/dfetch/project/git.py b/dfetch/project/git.py index ac878957..a2f4622c 100644 --- a/dfetch/project/git.py +++ b/dfetch/project/git.py @@ -52,11 +52,15 @@ def current_revision(self) -> str: 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: diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index 837b84bb..9fba34af 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -93,7 +93,10 @@ 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. @@ -101,6 +104,7 @@ def update( 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) @@ -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, @@ -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() @@ -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.""" @@ -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.""" @@ -401,7 +412,7 @@ def is_license_file(filename: str) -> bool: for pattern in SubProject.LICENSE_GLOBS ) - def diff(self, old_revision: str, new_revision: 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_revision: raise RuntimeError( @@ -410,4 +421,6 @@ def diff(self, old_revision: str, new_revision: str) -> str: " Please either commit this, or specify a revision to start from with --revs" ) - return self._diff_impl(old_revision, new_revision, ignore=(Metadata.FILENAME,)) + return self._diff_impl( + old_revision, new_revision, ignore=(Metadata.FILENAME,), reverse=reverse + ) diff --git a/dfetch/project/superproject.py b/dfetch/project/superproject.py index bb73ae84..dedbf6d8 100644 --- a/dfetch/project/superproject.py +++ b/dfetch/project/superproject.py @@ -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 "" diff --git a/dfetch/project/svn.py b/dfetch/project/svn.py index 9281a161..52417d28 100644 --- a/dfetch/project/svn.py +++ b/dfetch/project/svn.py @@ -16,7 +16,11 @@ in_directory, safe_rm, ) -from dfetch.vcs.patch import combine_patches, create_svn_patch_for_new_file +from dfetch.vcs.patch import ( + combine_patches, + create_svn_patch_for_new_file, + reverse_patch, +) from dfetch.vcs.svn import SvnRemote, SvnRepo, get_svn_version logger = get_logger(__name__) @@ -190,9 +194,17 @@ def current_revision(self) -> str: return SvnRepo.get_last_changed_revision(self.local_path) 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 between two revisions.""" + if reverse: + if new_revision: + new_revision, old_revision = old_revision, new_revision + filtered = self._repo.create_diff(old_revision, new_revision, ignore) if new_revision: @@ -205,7 +217,13 @@ def _diff_impl( if patch: patches.append(patch.encode("utf-8")) - return combine_patches(patches) + patch_str = combine_patches(patches) + + # SVN has no way of producing a reverse working copy patch, reverse ourselves + if reverse and not new_revision: + patch_str = reverse_patch(patch_str.encode("UTF-8")) + + return patch_str def get_default_branch(self) -> str: """Get the default branch of this repository.""" diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 4e0af102..568865f2 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -383,6 +383,7 @@ def create_diff( old_hash: Optional[str], new_hash: Optional[str], ignore: Optional[Sequence[str]] = None, + reverse: bool = False, ) -> str: """Generate a relative diff patch.""" with in_directory(self._path): @@ -394,6 +395,10 @@ def create_diff( "--no-ext-diff", # Don't allow external diff tools "--no-color", ] + + if reverse: + cmd.extend(["-R", "--src-prefix=b/", "--dst-prefix=a/"]) + if old_hash: cmd.append(old_hash) if new_hash: @@ -430,6 +435,28 @@ def ignored_files(path: str) -> Sequence[str]: .splitlines() ) + @staticmethod + def any_changes_or_untracked(path: str) -> bool: + """List of any changed files.""" + if not Path(path).exists(): + raise RuntimeError("Path does not exist.") + + with in_directory(path): + return bool( + run_on_cmdline( + logger, + [ + "git", + "status", + "--porcelain", + "--", + ".", + ], + ) + .stdout.decode() + .splitlines() + ) + def untracked_files_patch(self, ignore: Optional[Sequence[str]] = None) -> str: """Create a diff for untracked files.""" with in_directory(self._path): diff --git a/dfetch/vcs/patch.py b/dfetch/vcs/patch.py index a70e85d9..0a8f36c0 100644 --- a/dfetch/vcs/patch.py +++ b/dfetch/vcs/patch.py @@ -130,3 +130,39 @@ def combine_patches(patches: Sequence[bytes]) -> str: final_patchset.items += [patch_obj] return dump_patch(final_patchset) + + +def reverse_patch(patch_text: bytes) -> str: + """Reverse the given patch.""" + patch = patch_ng.fromstring(patch_text) + + reverse_patch_lines: list[bytes] = [] + + if not patch: + return "" + + for file in patch.items: + reverse_patch_lines.extend(line.strip() for line in file.header) + reverse_patch_lines.append(b"--- " + file.target) + reverse_patch_lines.append(b"+++ " + file.source) + for hunk in file.hunks: + # swap additions and deletions in the hunk + hunk_lines: list[bytes] = [] + for line in hunk.text: + line = line.rstrip(b"\n") + if line.startswith(b"+"): + hunk_lines.append(b"-" + line[1:]) + elif line.startswith(b"-"): + hunk_lines.append(b"+" + line[1:]) + else: + hunk_lines.append(line) + # Rebuild hunk header + reverse_patch_lines.append( + f"@@ -{hunk.starttgt},{hunk.linestgt} +{hunk.startsrc},{hunk.linessrc} @@".encode( + encoding="UTF-8" + ) + ) + reverse_patch_lines.extend(hunk_lines) + reverse_patch_lines.append(b"") # blank line between files + + return (b"\n".join(reverse_patch_lines)).decode(encoding="UTF-8") diff --git a/dfetch/vcs/svn.py b/dfetch/vcs/svn.py index 7f3cf1c1..dd6db3d0 100644 --- a/dfetch/vcs/svn.py +++ b/dfetch/vcs/svn.py @@ -4,6 +4,7 @@ import pathlib import re from collections.abc import Sequence +from pathlib import Path from typing import NamedTuple, Optional, Union from dfetch.log import get_logger @@ -202,13 +203,12 @@ def get_info_from_target(target: str = "") -> dict[str, str]: } @staticmethod - def get_last_changed_revision(target: str) -> str: + def get_last_changed_revision(target: Union[str, Path]) -> str: """Get the last changed revision of the given target.""" + target_str = str(target).strip() if os.path.isdir(target): last_digits = re.compile(r"(?P\d+)(?!.*\d)") - version = run_on_cmdline( - logger, ["svnversion", target.strip()] - ).stdout.decode() + version = run_on_cmdline(logger, ["svnversion", target_str]).stdout.decode() parsed_version = last_digits.search(version) if parsed_version: @@ -224,7 +224,7 @@ def get_last_changed_revision(target: str) -> str: "--non-interactive", "--show-item", "last-changed-revision", - target.strip(), + target_str, ], ) .stdout.decode() @@ -293,6 +293,26 @@ def ignored_files(path: str) -> Sequence[str]: return [line[1:].strip() for line in result if line.startswith("I")] + @staticmethod + def any_changes_or_untracked(path: str) -> bool: + """List of any changed files.""" + if not pathlib.Path(path).exists(): + raise RuntimeError("Path does not exist.") + + with in_directory(path): + return bool( + run_on_cmdline( + logger, + [ + "svn", + "status", + ".", + ], + ) + .stdout.decode() + .splitlines() + ) + def create_diff( self, old_revision: str, @@ -307,10 +327,8 @@ def create_diff( "--ignore-properties", ".", "-r", - old_revision, + f"{old_revision}:{new_revision}" if new_revision else old_revision, ] - if new_revision: - cmd[-1] += f":{new_revision}" with in_directory(self._path): patch_text = run_on_cmdline(logger, cmd).stdout diff --git a/doc/asciicasts/update-patch.cast b/doc/asciicasts/update-patch.cast new file mode 100644 index 00000000..8ae7fa1c --- /dev/null +++ b/doc/asciicasts/update-patch.cast @@ -0,0 +1,214 @@ +{"version": 2, "width": 185, "height": 26, "timestamp": 1768075641, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[29.804433, "o", "\u001b[H\u001b[2J\u001b[3J"] +[29.807021, "o", "$ "] +[30.809565, "o", "\u001b"] +[30.989816, "o", "[1"] +[31.079961, "o", "ml"] +[31.170111, "o", "s "] +[31.2606, "o", "-"] +[31.350454, "o", "l "] +[31.440751, "o", ".\u001b"] +[31.530919, "o", "[0"] +[31.621049, "o", "m"] +[32.622527, "o", "\r\n"] +[32.625035, "o", "total 16\r\n"] +[32.625238, "o", "drwxr-xr-x+ 3 dev dev 4096 Jan 10 20:07 cpputest\r\n-rw-rw-rw- 1 dev dev 764 Jan 10 20:07 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Jan 10 20:07 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Jan 10 20:07 patches\r\n"] +[32.628548, "o", "$ "] +[33.633144, "o", "\u001b"] +[33.813447, "o", "[1"] +[33.903613, "o", "mc"] +[33.99373, "o", "at"] +[34.083854, "o", " "] +[34.173984, "o", "df"] +[34.264172, "o", "et"] +[34.354325, "o", "ch"] +[34.444632, "o", ".y"] +[34.534652, "o", "a"] +[34.714928, "o", "ml"] +[34.80508, "o", "\u001b["] +[34.895227, "o", "0m"] +[35.897849, "o", "\r\n"] +[35.899439, "o", "manifest:"] +[35.900042, "o", "\r\n"] +[35.900333, "o", " version: 0.0 # DFetch Module syntax version"] +[35.900572, "o", "\r\n"] +[35.900825, "o", "\r\n"] +[35.901037, "o", " remotes: # declare common sources in one place"] +[35.901189, "o", "\r\n"] +[35.901442, "o", " - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)"] +[35.901462, "o", "\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n patch: patches/cpputest.patch\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[35.90745, "o", "$ "] +[36.909908, "o", "\u001b["] +[37.090202, "o", "1m"] +[37.180369, "o", "ca"] +[37.270549, "o", "t "] +[37.360717, "o", "pa"] +[37.45087, "o", "tc"] +[37.541041, "o", "he"] +[37.631213, "o", "s/"] +[37.721353, "o", "cp"] +[37.811504, "o", "pu"] +[37.991854, "o", "te"] +[38.082015, "o", "st"] +[38.172164, "o", ".p"] +[38.262324, "o", "at"] +[38.3525, "o", "ch"] +[38.442677, "o", "\u001b["] +[38.532859, "o", "0m"] +[39.534509, "o", "\r\n"] +[39.538666, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[39.542286, "o", "$ "] +[40.544842, "o", "\u001b["] +[40.725129, "o", "1m"] +[40.815283, "o", "gi"] +[40.905522, "o", "t "] +[40.995641, "o", "sta"] +[41.08593, "o", "tu"] +[41.176052, "o", "s\u001b"] +[41.266258, "o", "[0"] +[41.356421, "o", "m"] +[42.358061, "o", "\r\n"] +[42.362676, "o", "On branch main\r\nChanges not staged for commit:\r\n (use \"git add ...\" to update what will be committed)\r\n (use \"git restore ...\" to discard changes in working directory)\r\n\t\u001b[31mmodified: cpputest/src/README.md\u001b[m\r\n\t\u001b[31mmodified: dfetch.yaml\u001b[m\r\n\r\nUntracked files:\r\n (use \"git add ...\" to include in what will be committed)\r\n\t\u001b[31mpatches/\u001b[m\r\n\r\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\r\n"] +[42.366832, "o", "$ "] +[43.369323, "o", "\u001b["] +[43.549611, "o", "1m"] +[43.63971, "o", "se"] +[43.729861, "o", "d "] +[43.819987, "o", "-i"] +[43.910125, "o", " '"] +[44.000268, "o", "s/"] +[44.090397, "o", "gi"] +[44.180546, "o", "tl"] +[44.270703, "o", "ab"] +[44.451073, "o", "/g"] +[44.541293, "o", "it"] +[44.631596, "o", "ea"] +[44.72175, "o", "/g"] +[44.814652, "o", "' "] +[44.902013, "o", "cp"] +[44.992148, "o", "pu"] +[45.082752, "o", "te"] +[45.172949, "o", "st"] +[45.353316, "o", "/s"] +[45.443456, "o", "rc"] +[45.533852, "o", "/R"] +[45.623972, "o", "EA"] +[45.714248, "o", "DM"] +[45.804418, "o", "E."] +[45.894609, "o", "md"] +[45.98466, "o", "\u001b["] +[46.074892, "o", "0m"] +[47.076395, "o", "\r\n"] +[47.082213, "o", "$ "] +[48.084812, "o", "\u001b"] +[48.265125, "o", "[1"] +[48.355275, "o", "mg"] +[48.445437, "o", "it"] +[48.535643, "o", " "] +[48.625752, "o", "ad"] +[48.716179, "o", "d "] +[48.806337, "o", ".\u001b"] +[48.896512, "o", "[0"] +[48.986658, "o", "m"] +[49.988338, "o", "\r\n"] +[49.998603, "o", "$ "] +[51.001118, "o", "\u001b["] +[51.181428, "o", "1m"] +[51.271599, "o", "gi"] +[51.361772, "o", "t "] +[51.451932, "o", "co"] +[51.5421, "o", "mm"] +[51.632271, "o", "it"] +[51.722427, "o", " -"] +[51.812777, "o", "A "] +[51.902934, "o", "-m"] +[52.083204, "o", " '"] +[52.173368, "o", "Fi"] +[52.26352, "o", "x "] +[52.353709, "o", "vc"] +[52.443849, "o", "s "] +[52.533997, "o", "ho"] +[52.624174, "o", "st"] +[52.714378, "o", "'\u001b"] +[52.804674, "o", "[0"] +[52.985452, "o", "m"] +[53.986508, "o", "\r\n"] +[53.98926, "o", "error: unknown switch `A'\r\nusage: git commit [-a | --interactive | --patch] [-s] [-v] [-u[]] [--amend]\r\n [--dry-run] [(-c | -C | --squash) | --fixup [(amend|reword):]]\r\n [-F | -m ] [--reset-author] [--allow-empty]\r\n [--allow-empty-message] [--no-verify] [-e] [--author=]"] +[53.989343, "o", "\r\n [--date=] [--cleanup=] [--[no-]status]\r\n [-i | -o] [--pathspec-from-file= [--pathspec-file-nul]]\r\n [(--trailer [(=|:)])...] [-S[]]\r\n [--] [...]\r\n\r\n -q, --[no-]quiet suppress summary after successful commit\r\n -v, --[no-]verbose show diff in commit message template\r\n\r\nCommit message options\r\n -F, --[no-]file \r\n read message from file\r\n --[no-]author \r\n override author for commit\r\n --[no-]date override date for commit\r\n -m, --[no-]message \r\n commit message\r\n -c, --[no-]reedit-message \r\n reuse and edit message from specified commit\r\n -C, --[no-]reuse-message \r\n reuse message from specified commit\r\n --[no-]fixup [(amend|reword):]commit\r\n use autosquash formatted message to fixup or amend/reword specified commit\r\n --[no-]squash \r\n "] +[53.989393, "o", "use autosquash formatted message to squash specified commit\r\n --[no-]reset-author the commit is authored by me now (used with -C/-c/--amend)\r\n --trailer add custom trailer(s)\r\n -s, --[no-]signoff add a Signed-off-by trailer\r\n -t, --[no-]template"] +[53.989441, "o", " \r\n use specified template file\r\n -e, --[no-]edit force edit of commit\r\n --[no-]cleanup how to strip spaces and #comments from message\r\n --[no-]status include status in commit message template"] +[53.989488, "o", "\r\n -S, --[no-]gpg-sign[=]\r\n GPG sign commit\r\n\r\nCommit contents options\r\n -a, --[no-]all commit all changed files\r\n -i"] +[53.989503, "o", ", --[no-]include add specified files to index for commit\r\n --[no-]interactive interactively add files\r\n "] +[53.989542, "o", "-p, --[no-]patch interactively add changes\r\n -U, --unified generate diffs with lines context\r\n --inter-hunk-context "] +[53.989553, "o", "\r\n show context between diff hunks up to the specified number of lines\r\n -o, --[no-]only commit only specified files\r\n -n"] +[53.989595, "o", ", --no-verify bypass pre-commit and commit-msg hooks\r\n --verify opposite of --no-verify\r\n --[no-]dry-run show what would be committed\r\n --[no-]short show status concisely\r\n"] +[53.989876, "o", " --[no-]branch show branch information\r\n --[no-]ahead-behind compute full ahead/behind values\r\n --[no-]porcelain machine-readable output\r\n --[no-]long show status in long format (default)\r\n -z, --[no-]null terminate entries with NUL\r\n --[no-]amend amend previous commit\r\n --no-post-rewrite bypass post-rewrite hook\r\n --post-rewrite opposite of --no-post-rewrite\r\n -u, --[no-]untracked-files[=]\r\n show untracked files, optional modes: all, normal, no. (Default: all)\r\n --[no-]pathspec-from-file \r\n read pathspec from file\r\n --[no-]pathspec-file-nul\r\n with --pathspec-from-file, pathspec elements are separated with NUL character\r\n\r\n"] +[53.99371, "o", "$ "] +[54.996299, "o", "\u001b"] +[55.176606, "o", "[1"] +[55.266763, "o", "md"] +[55.356891, "o", "fe"] +[55.447074, "o", "tc"] +[55.537211, "o", "h "] +[55.627388, "o", "up"] +[55.71756, "o", "da"] +[55.807688, "o", "te"] +[55.897835, "o", "-p"] +[56.078051, "o", "a"] +[56.168241, "o", "tc"] +[56.258373, "o", "h "] +[56.348524, "o", "cp"] +[56.438713, "o", "pu"] +[56.528862, "o", "te"] +[56.619011, "o", "st"] +[56.709166, "o", "\u001b["] +[56.799333, "o", "0m"] +[57.801077, "o", "\r\n"] +[58.233464, "o", "\u001b[1;38;5;4m\u001b[34mDfetch (0.11.0)\u001b[0m\r\n\u001b[0m"] +[58.245019, "o", "Traceback (most recent call last):\r\n\u001b[0m"] +[58.246596, "o", "\u001b[0m File \u001b[35m\"/home/dev/.local/bin/dfetch\"\u001b[0m, line \u001b[35m7\u001b[0m, in \u001b[35m\u001b[0m\r\n sys.exit(\u001b[31mmain\u001b[0m\u001b[1;31m()\u001b[0m)\r\n \u001b[31m~~~~\u001b[0m\u001b[1;31m^^\u001b[0m\r\n\u001b[0m\u001b[0m File \u001b[35m\"/workspaces/dfetch/dfetch/__main__.py\"\u001b[0m, line \u001b[35m82\u001b[0m, in \u001b[35mmain\u001b[0m"] +[58.246658, "o", "\r\n \u001b[31mrun\u001b[0m\u001b[1;31m(sys.argv[1:])\u001b[0m\r\n \u001b[31m~~~\u001b[0m\u001b[1;31m^^^^^^^^^^^^^^\u001b[0m\r\n\u001b[0m\u001b[0m File \u001b[35m\"/workspaces/dfetch/dfetch/__main__.py\"\u001b[0m, line \u001b[35m69\u001b[0m, in \u001b[35mrun\u001b[0m\r\n \u001b[31margs.func\u001b[0m\u001b[1;31m(args)\u001b[0m\r\n \u001b[31m~~~~~~~~~\u001b[0m\u001b[1;31m^^^^^^\u001b[0m\r\n\u001b[0m\u001b[0m File \u001b[35m\"/workspaces/dfetch/dfetch/commands/update_patch.py\"\u001b[0m, line \u001b[35m42\u001b[0m, in \u001b[35m__call__\u001b[0m\r\n superproject = SuperProject()\r\n\u001b[0m\u001b[0m File \u001b[35m\"/workspaces/dfetch/dfetch/project/superproject.py\"\u001b[0m, line \u001b[35m42\u001b[0m, in \u001b[35m__init__\u001b[0m\r\n self._manifest = \u001b[31mparse\u001b[0m\u001b[1;31m(manifest_path)\u001b[0m\r\n \u001b[31m~~~~~\u001b[0m\u001b[1;31m^^^^^^^^^^^^^^^\u001b[0m\r\n\u001b[0m\u001b[0m File \u001b[35m\"/workspaces/dfetch/dfetch/manifest/parse.py\"\u001b[0m, line \u001b[35m44\u001b[0m, in \u001b[35mparse\u001b[0m\r\n loaded_manifest = load(manifest_text, schema=MANIFEST_SCHEMA)\r\n\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/parser.py\"\u001b[0m, line \u001b[35m323\u001b[0m, in \u001b[35mload\u001b[0m\r\n return generic_load(yaml_string, schema=schema, label=label)\r\n\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/parser.py\"\u001b[0m, line \u001b[35m292\u001b[0m, in \u001b[35mgeneric_load\u001b[0m\r\n raise parse_error\r\n\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/parser.py\"\u001b[0m, line \u001b[35m285\u001b[0m, in \u001b[35mgeneric_load\u001b[0m\r\n document = ruamelyaml.load(yaml_string, Loader=DynamicStrictYAMLLoader)\r\n"] +[58.246681, "o", "\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/ruamel/main.py\"\u001b[0m, line \u001b[35m986\u001b[0m, in \u001b[35mload\u001b[0m\r\n return \u001b[31mloader._constructor.get_single_data\u001b[0m\u001b[1;31m()\u001b[0m\r\n \u001b[31m~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\u001b[0m\u001b[1;31m^^\u001b[0m\r\n\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/ruamel/constructor.py\"\u001b[0m, line \u001b[35m114\u001b[0m, in \u001b[35mget_single_data\u001b[0m\r\n node = self.composer.get_single_node()\r\n\u001b[0m"] +[58.24669, "o", "\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/ruamel/composer.py\"\u001b[0m, line \u001b[35m78\u001b[0m, in \u001b[35mget_single_node\u001b[0m\r\n document = self.compose_document()\r\n"] +[58.246803, "o", "\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/ruamel/composer.py\"\u001b[0m, line \u001b[35m101\u001b[0m, in \u001b[35mcompose_document\u001b[0m\r\n node = self.compose_node(None, None)\r\n\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/ruamel/composer.py\"\u001b[0m, line \u001b[35m143\u001b[0m, in \u001b[35mcompose_node\u001b[0m\r\n node = self.compose_mapping_node(anchor)\r\n\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/ruamel/composer.py\"\u001b[0m, line \u001b[35m216\u001b[0m, in \u001b[35mcompose_mapping_node\u001b[0m\r\n while not \u001b[31mself.parser.check_event\u001b[0m\u001b[1;31m(MappingEndEvent)\u001b[0m:\r\n \u001b[31m~~~~~~~~~~~~~~~~~~~~~~~\u001b[0m\u001b[1;31m^^^^^^^^^^^^^^^^^\u001b[0m\r\n\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/ruamel/parser.py\"\u001b[0m, line \u001b[35m140\u001b[0m, in \u001b[35mcheck_event\u001b[0m\r\n self.current_event = \u001b[31mself.state\u001b[0m\u001b[1;31m()\u001b[0m\r\n \u001b[31m~~~~~~~~~~\u001b[0m\u001b[1;31m^^\u001b[0m\r\n\u001b[0m\u001b[0m File \u001b[35m\"/home/dev/.local/lib/python3.13/site-packages/strictyaml/ruamel/parser.py\"\u001b[0m, line \u001b[35m623\u001b[0m, in \u001b[35mparse_block_mapping_key\u001b[0m\r\n raise ParserError(\r\n ...<4 lines>...\r\n )\r\n\u001b[0m\u001b[0m\u001b[1;35mstrictyaml.ruamel.parser.ParserError\u001b[0m: \u001b[35mwhile parsing a block mapping\r\n in \"\", line 1, column 1:\r\n manifest:\r\n ^ (line: 1)\r\nexpected , but found ''\r\n in \"\", line 13, column 2:\r\n patch: patches/cpputest.patch\r\n ^ (line: 13)\u001b[0m\r\n\u001b[0m"] +[58.247046, "o", "\u001b[0m"] +[58.247314, "o", "\u001b[0m"] +[58.293625, "o", "$ "] +[59.296122, "o", "\u001b["] +[59.476422, "o", "1m"] +[59.566593, "o", "ca"] +[59.656742, "o", "t "] +[59.746863, "o", "pa"] +[59.837047, "o", "tc"] +[59.927203, "o", "he"] +[60.01735, "o", "s/"] +[60.1075, "o", "cp"] +[60.198278, "o", "pu"] +[60.378724, "o", "tes"] +[60.468916, "o", "t."] +[60.559159, "o", "pa"] +[60.649335, "o", "tc"] +[60.739486, "o", "h\u001b"] +[60.829636, "o", "[0"] +[60.919785, "o", "m"] +[61.921282, "o", "\r\n"] +[61.92326, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[61.926606, "o", "$ "] +[62.929109, "o", "\u001b["] +[63.109374, "o", "1m"] +[63.19954, "o", "gi"] +[63.28969, "o", "t "] +[63.3799, "o", "sta"] +[63.470064, "o", "tu"] +[63.5602, "o", "s\u001b"] +[63.650355, "o", "[0"] +[63.740492, "o", "m"] +[64.742108, "o", "\r\n"] +[64.746854, "o", "On branch main\r\nChanges to be committed:\r\n (use \"git restore --staged ...\" to unstage)\r\n\t\u001b[32mmodified: cpputest/src/README.md\u001b[m\r\n\t\u001b[32mmodified: dfetch.yaml\u001b[m\r\n\t\u001b[32mnew file: patches/cpputest.patch\u001b[m\r\n\r\n"] +[67.753985, "o", "$ "] +[67.755273, "o", "\u001b["] +[67.935652, "o", "1m"] +[68.02575, "o", "\u001b["] +[68.116027, "o", "0m"] +[68.116265, "o", "\r\n"] +[68.117821, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/generate-casts/generate-casts.sh b/doc/generate-casts/generate-casts.sh index 39847da1..2108ef49 100755 --- a/doc/generate-casts/generate-casts.sh +++ b/doc/generate-casts/generate-casts.sh @@ -18,6 +18,7 @@ asciinema rec --overwrite -c "./report-demo.sh" ../asciicasts/report.cast asciinema rec --overwrite -c "./report-sbom-demo.sh" ../asciicasts/sbom.cast asciinema rec --overwrite -c "./freeze-demo.sh" ../asciicasts/freeze.cast asciinema rec --overwrite -c "./diff-demo.sh" ../asciicasts/diff.cast +asciinema rec --overwrite -c "./update-patch-demo.sh" ../asciicasts/update-patch.cast rm -rf update diff --git a/doc/generate-casts/update-patch-demo.sh b/doc/generate-casts/update-patch-demo.sh new file mode 100755 index 00000000..09358770 --- /dev/null +++ b/doc/generate-casts/update-patch-demo.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +source ./demo-magic/demo-magic.sh + +PROMPT_TIMEOUT=1 + +# Copy example manifest +mkdir update-patch +pushd update-patch + +git init +cp -r ../update/* . +git add . +git commit -m "Initial commit" + +pe "ls -l cpputest/src/README.md" +pe "sed -i 's/github/gitlab/g' cpputest/src/README.md" +pe "dfetch diff cpputest" +pe "mkdir -p patches" +pe "mv cpputest.patch patches/cpputest.patch" + +# Insert patch @ line 13 (fragile if init manifest ever changes, but hoping for add command) +pe "sed -i '13i\ patch: patches/cpputest.patch' dfetch.yaml" +pe "dfetch update -f cpputest" +git commit -A -m 'Fix vcs host' + +clear +# Run the command +pe "ls -l ." +pe "cat dfetch.yaml" +pe "cat patches/cpputest.patch" +pe "git status" +pe "sed -i 's/gitlab/gitea/g' cpputest/src/README.md" +pe "git add ." +pe "git commit -A -m 'Fix vcs host'" +pe "dfetch update-patch cpputest" +pe "cat patches/cpputest.patch" +pe "git status" + + +PROMPT_TIMEOUT=3 +wait + +pei "" + +popd +rm -rf update-patch diff --git a/doc/manual.rst b/doc/manual.rst index 610c68d4..50a12b7a 100644 --- a/doc/manual.rst +++ b/doc/manual.rst @@ -113,6 +113,18 @@ Diff .. automodule:: dfetch.commands.diff +Update patch +------------ +.. argparse:: + :module: dfetch.__main__ + :func: create_parser + :prog: dfetch + :path: update-patch + +.. asciinema:: asciicasts/update_patch.cast + +.. automodule:: dfetch.commands.update_patch + Freeze ------ .. argparse:: diff --git a/features/steps/generic_steps.py b/features/steps/generic_steps.py index 24737fee..a6238ee1 100644 --- a/features/steps/generic_steps.py +++ b/features/steps/generic_steps.py @@ -8,6 +8,7 @@ import os import pathlib import re +import shutil from itertools import zip_longest from typing import Iterable, List, Optional, Pattern, Tuple, Union @@ -203,6 +204,12 @@ def step_impl(_, old: str, new: str, path: str): replace_in_file(path, old, new) +@given('"{old_path}" in {directory} is renamed as "{new_path}"') +def step_impl(_, old_path: str, new_path: str, directory: str): + with in_directory(directory): + shutil.move(old_path, new_path) + + @given("the patch file '{name}'") @given("the patch file '{name}' with '{encoding}' encoding") def step_impl(context, name, encoding="UTF-8"): @@ -268,6 +275,7 @@ def step_impl(context, name): @then("the patch file '{name}' is generated") +@then("the patch file '{name}' is updated") def step_impl(context, name): """Check a patch file.""" if context.text: diff --git a/features/steps/git_steps.py b/features/steps/git_steps.py index caf98b43..9a3e6c70 100644 --- a/features/steps/git_steps.py +++ b/features/steps/git_steps.py @@ -134,6 +134,7 @@ def step_impl(context, directory, path): commit_all("A change") +@given("all files in {directory} are committed") @when("all files in {directory} are committed") def step_impl(_, directory): with in_directory(directory): diff --git a/features/steps/svn_steps.py b/features/steps/svn_steps.py index b0d81bd1..854365d4 100644 --- a/features/steps/svn_steps.py +++ b/features/steps/svn_steps.py @@ -125,6 +125,12 @@ def step_impl(context): add_and_commit("Initial commit") +@given("all files in {directory} are added and committed") +def step_impl(_, directory): + with in_directory(directory): + add_and_commit(f"Committed all files in {directory}") + + @given('"{path}" in {directory} is changed, added and committed with') def step_impl(context, directory, path): with in_directory(directory): diff --git a/features/update-patch-in-git.feature b/features/update-patch-in-git.feature new file mode 100644 index 00000000..aad92fc4 --- /dev/null +++ b/features/update-patch-in-git.feature @@ -0,0 +1,75 @@ +Feature: Update an existing patch in git + + If working with external projects, local changes are can be tracked using + patch files. If those local changes evolve over time, *Dfetch* should allow + the user to update an existing patch so that it reflects the current working + copy of the project. + + The update process must be safe, reproducible, and leave the project in a + patched state matching the manifest configuration. + + Background: + Given a git repository "SomeProject.git" + And the patch file 'MyProject/patches/SomeProject.patch' + """ + diff --git a/README.md b/README.md + index 32d9fad..62248b7 100644 + --- a/README.md + +++ b/README.md + @@ -1,1 +1,1 @@ + -Generated file for SomeProject.git + +Patched file for SomeProject.git + """ + And a fetched and committed MyProject with the manifest + """ + manifest: + version: 0.0 + projects: + - name: SomeProject + url: some-remote-server/SomeProject.git + patch: patches/SomeProject.patch + """ + + Scenario: Patch is updated with new local changes + Given "SomeProject/README.md" in MyProject is changed and committed with + """ + Update to patched file for SomeProject.git + """ + When I run "dfetch update-patch SomeProject" in MyProject + Then the patch file 'MyProject/patches/SomeProject.patch' is updated + """ + diff --git a/README.md b/README.md + index 1e65bd6..925b8c4 100644 + --- a/README.md + +++ b/README.md + @@ -1 +1,2 @@ + -Generated file for SomeProject.git + +Patched file for SomeProject.git + +Update to patched file for SomeProject.git + + """ + + Scenario: Patch is updated with new but not ignored files + Given files as '*.tmp' are ignored in git in MyProject + And "SomeProject/IGNORE_ME.tmp" in MyProject is created + And "SomeProject/NEWFILE.md" in MyProject is created + And all files in MyProject are committed + When I run "dfetch update-patch SomeProject" in MyProject + Then the patch file 'MyProject/patches/SomeProject.patch' is updated + """ + diff --git a/NEWFILE.md b/NEWFILE.md + new file mode 100644 + index 0000000..0ee3895 + --- /dev/null + +++ b/NEWFILE.md + @@ -0,0 +1 @@ + +Some content + diff --git a/README.md b/README.md + index 1e65bd6..38c1a65 100644 + --- a/README.md + +++ b/README.md + @@ -1 +1 @@ + -Generated file for SomeProject.git + +Patched file for SomeProject.git + + """ diff --git a/features/update-patch-in-svn.feature b/features/update-patch-in-svn.feature new file mode 100644 index 00000000..60683087 --- /dev/null +++ b/features/update-patch-in-svn.feature @@ -0,0 +1,68 @@ +Feature: Update an existing patch in svn + + If working with external projects, local changes are can be tracked using + patch files. If those local changes evolve over time, *Dfetch* should allow + the user to update an existing patch so that it reflects the current working + copy of the project. + + The update process must be safe, reproducible, and leave the project in a + patched state matching the manifest configuration. + + Background: + Given a svn-server "SomeProject" + And the patch file 'MySvnProject/patches/SomeProject.patch' + """ + Index: README.md + =================================================================== + --- README.md + +++ README.md + @@ -1,1 +1,1 @@ + -Generated file for SomeProject + +Patched file for SomeProject + """ + And a fetched and committed MySvnProject with the manifest + """ + manifest: + version: 0.0 + projects: + - name: SomeProject + url: some-remote-server/SomeProject + patch: patches/SomeProject.patch + vcs: svn + """ + + Scenario: Patch is updated with new local changes + Given "SomeProject/README.md" in MySvnProject is changed, added and committed with + """ + Update to patched file for SomeProject + """ + When I run "dfetch update-patch SomeProject" in MySvnProject + Then the patch file 'MySvnProject/patches/SomeProject.patch' is updated + """ + Index: README.md + =================================================================== + --- README.md + +++ README.md + @@ -1,1 +1,2 @@ + +Patched file for SomeProject + +Update to patched file for SomeProject + -Generated file for SomeProject + """ + + Scenario: Patch is updated with new but not ignored files + Given files as '*.tmp' are ignored in 'MySvnProject/SomeProject' in svn + And "SomeProject/IGNORE_ME.tmp" in MySvnProject is created + And "SomeProject/NEWFILE.md" in MySvnProject is created + And all files in MySvnProject are added and committed + When I run "dfetch update-patch SomeProject" in MySvnProject + Then the patch file 'MySvnProject/patches/SomeProject.patch' is updated + """ + Index: README.md + =================================================================== + --- README.md + +++ README.md + @@ -1,1 +1,1 @@ + +Patched file for SomeProject + -Generated file for SomeProject + + """ diff --git a/tests/test_fuzzing.py b/tests/test_fuzzing.py index 95472a78..e9d6864c 100644 --- a/tests/test_fuzzing.py +++ b/tests/test_fuzzing.py @@ -181,7 +181,6 @@ def test_update(data): if __name__ == "__main__": - settings.load_profile("manual") example = manifest_strategy.example() diff --git a/tests/test_patch.py b/tests/test_patch.py index 33253cf2..6e23e4a1 100644 --- a/tests/test_patch.py +++ b/tests/test_patch.py @@ -8,6 +8,7 @@ from dfetch.vcs.patch import ( create_git_patch_for_new_file, create_svn_patch_for_new_file, + reverse_patch, ) @@ -58,3 +59,30 @@ def test_create_svn_patch_for_new_file(tmp_path): ) assert actual_patch == expected_patch + + +def test_reverse_patch(): + """Check reversing a patch.""" + patch = b""" +Index: README.md +=================================================================== +--- README.md ++++ README.md +@@ -1,1 +1,2 @@ + Patched file for SomeProject ++Update to patched file for SomeProject +""" + + reversed_patch = reverse_patch(patch) + + expected = """ +Index: README.md +=================================================================== +--- README.md ++++ README.md +@@ -1,2 +1,1 @@ + Patched file for SomeProject +-Update to patched file for SomeProject +""" + + assert reversed_patch == expected diff --git a/tests/test_subproject.py b/tests/test_subproject.py index b8137710..99e56442 100644 --- a/tests/test_subproject.py +++ b/tests/test_subproject.py @@ -49,7 +49,13 @@ def current_revision(self): def metadata_revision(self): return "1" - def _diff_impl(self, old_revision, new_revision, ignore): + def _diff_impl( + self, + old_revision, + new_revision, + ignore, + reverse=False, + ): return "" def get_default_branch(self):