From edf64928f26c432cf64073d80088426027393547 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 18:17:28 +0000 Subject: [PATCH 01/22] Add review-patch command for interactive patch inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `dfetch review-patch` which stages the clean upstream source in the git index and applies the selected patches to the working tree, so any diff-aware editor sees `git diff` (working tree vs index) showing exactly what the patches contribute. The command always restores original state on exit — no permanent changes to working tree or index. - New command: dfetch/commands/review_patch.py with --count/-n, --interactive/-i - GitSuperProject.add_path() and restore_staged() methods for index control - GitLocalRepo.add_path() and restore_staged() as git-level primitives - SVN superprojects supported (with a warning; no staging step) - Interactive TUI uses read_key()/Screen for ← → step-through - 8 unit tests, 4 git BDD scenarios - Documentation and changelog updated Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- CHANGELOG.rst | 1 + dfetch/__main__.py | 2 + dfetch/commands/review_patch.py | 267 +++++++++++++++++++++++++++ dfetch/project/gitsuperproject.py | 8 + dfetch/vcs/git.py | 10 + doc/howto/patching.rst | 50 +++++ features/review-patch-in-git.feature | 102 ++++++++++ features/review-patch-in-svn.feature | 73 ++++++++ tests/test_review_patch.py | 191 +++++++++++++++++++ 9 files changed, 704 insertions(+) create mode 100644 dfetch/commands/review_patch.py create mode 100644 features/review-patch-in-git.feature create mode 100644 features/review-patch-in-svn.feature create mode 100644 tests/test_review_patch.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e37ceb3b9..5afe7220c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,7 @@ Release 0.14.2 (released 2026-06-21) Release 0.14.1 (released 2026-06-19) ==================================== +* Add ``review-patch`` command to inspect patch contributions interactively without making permanent changes (#review-patch) * Implement C-043: add ``pip-audit`` OSV gate to the release workflow * Add CRA Compliance: OSCAL 1.2.2 Component Definition * Fix ``dfetch import`` mangling the namespace of a generic VCS URL whose path contains ``.git`` (#1268) diff --git a/dfetch/__main__.py b/dfetch/__main__.py index 3ee7bd157..931b5e780 100644 --- a/dfetch/__main__.py +++ b/dfetch/__main__.py @@ -20,6 +20,7 @@ import dfetch.commands.remove import dfetch.commands.report import dfetch.commands.update +import dfetch.commands.review_patch import dfetch.commands.update_patch import dfetch.commands.validate import dfetch.log @@ -56,6 +57,7 @@ def create_parser() -> argparse.ArgumentParser: dfetch.commands.remove.Remove.create_menu(subparsers) dfetch.commands.report.Report.create_menu(subparsers) dfetch.commands.update.Update.create_menu(subparsers) + dfetch.commands.review_patch.ReviewPatch.create_menu(subparsers) dfetch.commands.update_patch.UpdatePatch.create_menu(subparsers) dfetch.commands.validate.Validate.create_menu(subparsers) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py new file mode 100644 index 000000000..ba97dddd1 --- /dev/null +++ b/dfetch/commands/review_patch.py @@ -0,0 +1,267 @@ +"""Reviewing patches interactively. + +*Dfetch* allows you to keep local changes to external projects in the form of +patch files. The ``review-patch`` command lets you inspect what each patch +contributes by staging the clean upstream source in the git index and +optionally stepping the working tree through each patch in turn. + +Because ``git diff`` always compares the working tree against the index, any +diff-aware editor will immediately show what the applied patches change +relative to the upstream source — no manual setup needed. + +Run without arguments to review all patches at once: + +.. code-block:: console + + $ dfetch review-patch some-project + +Use ``--count`` to stop at a specific patch number, or ``--interactive`` to +step through the stack with the ← and → arrow keys. In either case the +command always restores the original working tree and index before it exits — +no permanent changes are made. + +.. tabs:: + + .. tab:: Git + + .. scenario-include:: ../features/review-patch-in-git.feature + + .. tab:: SVN + + .. scenario-include:: ../features/review-patch-in-svn.feature +""" + +import argparse +from collections.abc import Callable, Sequence + +import dfetch.commands.command +import dfetch.manifest.project +import dfetch.project +from dfetch.log import get_logger +from dfetch.project import create_super_project +from dfetch.project.gitsuperproject import GitSuperProject +from dfetch.project.subproject import SubProject +from dfetch.project.superproject import NoVcsSuperProject, SuperProject +from dfetch.terminal import BOLD, DIM, RESET, Screen, is_tty, read_key +from dfetch.util.util import in_directory + +logger = get_logger(__name__) + + +class ReviewPatch(dfetch.commands.command.Command): + """Review what patches contribute to a project. + + The ``review-patch`` command stages the clean upstream source in the git + index and applies the selected patches to the working tree, so ``git diff`` + shows exactly what the patches change relative to upstream. Use + ``--interactive`` to step through the stack patch-by-patch with ← and →. + The command always restores the original state before it exits. + """ + + @staticmethod + def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None: + """Add the menu for the review-patch action.""" + parser = dfetch.commands.command.Command.parser(subparsers, ReviewPatch) + parser.add_argument( + "projects", + metavar="", + type=str, + nargs="*", + help="Specific project(s) to review", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--count", + "-n", + metavar="N", + type=int, + default=None, + help="Number of patches to apply (default: all)", + ) + group.add_argument( + "--interactive", + "-i", + action="store_true", + default=False, + help="Step through patches interactively with ← and →", + ) + + def __call__(self, args: argparse.Namespace) -> None: + """Perform the review patch.""" + superproject = create_super_project() + + had_errors: bool = False + + if isinstance(superproject, NoVcsSuperProject): + raise RuntimeError( + "The project containing the manifest is not under version control," + " reviewing patches is not supported" + ) + if not isinstance(superproject, GitSuperProject): + logger.warning( + "review-patch has limited support in SVN superprojects" + " (no staging area — use `svn diff` to inspect changes)" + ) + + if args.interactive and not is_tty(): + raise RuntimeError("--interactive requires an interactive terminal") + + with in_directory(superproject.root_directory): + for project in superproject.manifest.selected_projects(args.projects): + try: + self._review_project( + superproject, project, args.count, args.interactive + ) + except RuntimeError as exc: + logger.print_error_line(project.name, str(exc)) + had_errors = True + + if had_errors: + raise RuntimeError() + + def _review_project( + self, + superproject: SuperProject, + project: dfetch.manifest.project.ProjectEntry, + count: int | None, + interactive: bool, + ) -> None: + """Set up review state for a single project, then restore.""" + subproject = dfetch.project.create_sub_project(project) + destination = project.destination + + def _ignored(dst: str = destination) -> list[str]: + return list(superproject.ignored_files(dst)) + + if not subproject.patch: + logger.print_warning_line( + project.name, + 'skipped - there is no patch file, use "dfetch diff"' + f" {project.name} to create one", + ) + return + + 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, use "dfetch update {project.name}"', + ) + return + + 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 + + is_git = isinstance(superproject, GitSuperProject) + total_patches = len(list(subproject.patch)) + + subproject.update( + force=True, + ignored_files_callback=_ignored, + patch_count=0, + eol_preferences_callback=superproject.eol_preferences, + ) + + if is_git: + assert isinstance(superproject, GitSuperProject) + superproject.add_path(subproject.local_path) + + try: + if interactive: + _step_tui( + list(subproject.patch), + subproject, + superproject, + _ignored, + project.name, + ) + else: + chosen_count = count if count is not None else -1 + subproject.update( + force=True, + ignored_files_callback=_ignored, + patch_count=chosen_count, + eol_preferences_callback=superproject.eol_preferences, + ) + patch_label = ( + str(total_patches) if chosen_count == -1 else str(chosen_count) + ) + logger.print_info_line( + project.name, + f"stage = upstream, working tree = {patch_label} patch(es) applied" + " — open your editor and run `git diff` to inspect", + ) + if is_tty(): + input("Press Enter to restore...") + finally: + subproject.update( + force=True, + ignored_files_callback=_ignored, + patch_count=-1, + eol_preferences_callback=superproject.eol_preferences, + ) + if is_git: + assert isinstance(superproject, GitSuperProject) + superproject.restore_staged(subproject.local_path) + logger.print_info_line(project.name, "restored") + + +def _step_tui( + patches: list[str], + subproject: SubProject, + superproject: SuperProject, + ignored_callback: Callable[[], Sequence[str]], + project_name: str, +) -> None: + """Interactive patch-stack stepper using arrow keys. + + The git index is staged once (clean upstream) before this function is + called. Only the working tree is updated on each step so that + ``git diff`` always shows the contribution of the currently applied patches. + """ + total = len(patches) + current = 0 + screen = Screen() + + while True: + count_label = str(current) if current < total else "all" + lines: list[str] = [ + f" {DIM}← → step Enter restore and exit Ctrl-C abort{RESET}", + " " + "─" * 54, + f" {project_name} [{count_label}/{total} patches applied]", + ] + for idx, patch_name in enumerate(patches): + marker = f"{BOLD}[x]{RESET}" if idx < current else f"{DIM}[ ]{RESET}" + lines.append(f" {marker} {patch_name}") + screen.draw(lines) + + try: + key = read_key() + except KeyboardInterrupt: + screen.clear() + return + + if key == "LEFT" and current > 0: + current -= 1 + subproject.update( + force=True, + ignored_files_callback=ignored_callback, + patch_count=current, + eol_preferences_callback=superproject.eol_preferences, + ) + elif key == "RIGHT" and current < total: + current += 1 + patch_count = -1 if current == total else current + subproject.update( + force=True, + ignored_files_callback=ignored_callback, + patch_count=patch_count, + eol_preferences_callback=superproject.eol_preferences, + ) + elif key in ("ENTER", "ESC"): + screen.clear() + return diff --git a/dfetch/project/gitsuperproject.py b/dfetch/project/gitsuperproject.py index 3d4343c1c..3d175f015 100644 --- a/dfetch/project/gitsuperproject.py +++ b/dfetch/project/gitsuperproject.py @@ -51,6 +51,14 @@ def has_local_changes_in_dir(self, path: str) -> bool: """Check if the superproject has local changes.""" return GitLocalRepo(path).any_changes_or_untracked() + def add_path(self, path: str) -> None: + """Stage the given path in the superproject's git index.""" + self._repo.add_path(path) + + def restore_staged(self, path: str) -> None: + """Unstage the given path in the superproject's git index.""" + self._repo.restore_staged(path) + def get_username(self) -> str: """Get the username of the superproject VCS.""" username = self._repo.get_username() diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index cecb01901..eb75f8c42 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -817,6 +817,16 @@ def any_changes_or_untracked(self) -> bool: .splitlines() ) + def add_path(self, path: str) -> None: + """Stage the given path in the git index.""" + with in_directory(self._path): + run_on_cmdline(logger, ["git", "add", path]) + + def restore_staged(self, path: str) -> None: + """Unstage the given path, restoring the index to HEAD.""" + with in_directory(self._path): + run_on_cmdline(logger, ["git", "restore", "--staged", path]) + def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> Patch: """Create a diff for untracked files.""" with in_directory(self._path): diff --git a/doc/howto/patching.rst b/doc/howto/patching.rst index 508336cef..643d505bb 100644 --- a/doc/howto/patching.rst +++ b/doc/howto/patching.rst @@ -276,6 +276,56 @@ files, then use ``dfetch update-patch`` to record the resolved state: $ dfetch update-patch some-project $ svn commit some-project.patch -m "patches: update some-project.patch for v1.3.0" +.. _patching-review: + +Reviewing a patch +----------------- + +When you want to understand what a patch (or a set of patches) actually +contributes to a vendored project, ``review-patch`` sets up your working tree +so that any diff-aware editor shows the answer immediately. + +.. code-block:: console + + $ dfetch review-patch some-project + +In a Git superproject this stages the clean upstream source in the git index +and applies the selected patches to the working tree. Running ``git diff`` +inside the project directory then shows exactly what the patches change. When +you are done reviewing, the command restores the original state — both the +working tree and the git index are left clean. + +**Reviewing a specific number of patches** + +Use ``--count`` to stop at a particular patch in the stack. For example, to +see only what the first patch contributes, with the rest still un-applied: + +.. code-block:: console + + $ dfetch review-patch --count 1 some-project + +**Stepping through the stack interactively** + +Use ``--interactive`` (or ``-i``) to step through the patch stack one patch at +a time using the ← and → arrow keys. As you step, the working tree is updated +so your editor always reflects the current position in the stack: + +.. code-block:: console + + $ dfetch review-patch --interactive some-project + +Press **Enter** to finish and restore the original state. + +.. tabs:: + + .. tab:: Git + + .. scenario-include:: ../features/review-patch-in-git.feature + + .. tab:: SVN + + .. scenario-include:: ../features/review-patch-in-svn.feature + .. _patching-upstream: Contributing the patch upstream diff --git a/features/review-patch-in-git.feature b/features/review-patch-in-git.feature new file mode 100644 index 000000000..45c2acd68 --- /dev/null +++ b/features/review-patch-in-git.feature @@ -0,0 +1,102 @@ +@review-patch +Feature: Review patches in git + + When working with external projects that have patch files, it is useful to + be able to inspect what a patch (or a set of patches) contributes to the + project without permanently modifying the working tree. *Dfetch* provides + the ``review-patch`` command for this purpose. + + The command stages the clean upstream source in the git index and applies + the selected patches to the working tree. Running ``git diff`` inside the + project directory then shows exactly what the patches change relative to the + upstream source. When the user is done reviewing, the command restores the + original state: both the working tree and the git index are left clean. + + 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: All patches are set up for review and state is restored afterwards + When I run "dfetch review-patch SomeProject" in MyProject + Then the output shows + """ + Dfetch (0.14.0) + SomeProject: + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + > Applying patch "patches/SomeProject.patch" + successfully patched 1/1: b'README.md' + > stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + > Applying patch "patches/SomeProject.patch" + successfully patched 1/1: b'README.md' + > restored + """ + And the patched 'MyProject/SomeProject/README.md' is + """ + Patched file for SomeProject.git + """ + + Scenario: Only the first N patches are applied with --count + When I run "dfetch review-patch --count 0 SomeProject" in MyProject + Then the output shows + """ + Dfetch (0.14.0) + SomeProject: + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `git diff` to inspect + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + > Applying patch "patches/SomeProject.patch" + successfully patched 1/1: b'README.md' + > restored + """ + And the patched 'MyProject/SomeProject/README.md' is + """ + Patched file for SomeProject.git + """ + + Scenario: A warning is shown when no patch is defined in the manifest + Given a fetched and committed MyProject with the manifest + """ + manifest: + version: 0.0 + projects: + - name: SomeProject + url: some-remote-server/SomeProject.git + """ + When I run "dfetch review-patch SomeProject" in MyProject + Then the output shows + """ + Dfetch (0.14.0) + SomeProject: + > skipped - there is no patch file, use "dfetch diff" SomeProject to create one + """ + + Scenario: A warning is shown when the project has uncommitted local changes + Given "SomeProject/README.md" in MyProject is changed locally + When I run "dfetch review-patch SomeProject" in MyProject + Then the output shows + """ + Dfetch (0.14.0) + SomeProject: + > skipped - uncommitted changes in SomeProject + """ diff --git a/features/review-patch-in-svn.feature b/features/review-patch-in-svn.feature new file mode 100644 index 000000000..94e326db0 --- /dev/null +++ b/features/review-patch-in-svn.feature @@ -0,0 +1,73 @@ +@review-patch +Feature: Review patches in svn + + When working with external projects that have patch files inside an SVN + superproject, the ``review-patch`` command allows the user to inspect what + a patch contributes. Because SVN has no staging area, the command cannot + use the index trick available in Git; it simply sets the working copy to + the requested patch state and restores the original state afterwards. + + 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: All patches are set up for review and state is restored afterwards + When I run "dfetch review-patch SomeProject" in MySvnProject + Then the output shows + """ + Dfetch (0.14.0) + review-patch has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) + SomeProject: + > Fetched trunk - 1 + > Fetched trunk - 1 + > Applying patch "patches/SomeProject.patch" + successfully patched 1/1: b'README.md' + > stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect + > Fetched trunk - 1 + > Applying patch "patches/SomeProject.patch" + successfully patched 1/1: b'README.md' + > restored + """ + And the patched 'MySvnProject/SomeProject/README.md' is + """ + Patched file for SomeProject + """ + + Scenario: Only the first N patches are applied with --count + When I run "dfetch review-patch --count 0 SomeProject" in MySvnProject + Then the output shows + """ + Dfetch (0.14.0) + review-patch has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) + SomeProject: + > Fetched trunk - 1 + > Fetched trunk - 1 + > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `git diff` to inspect + > Fetched trunk - 1 + > Applying patch "patches/SomeProject.patch" + successfully patched 1/1: b'README.md' + > restored + """ + And the patched 'MySvnProject/SomeProject/README.md' is + """ + Patched file for SomeProject + """ diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py new file mode 100644 index 000000000..33b424246 --- /dev/null +++ b/tests/test_review_patch.py @@ -0,0 +1,191 @@ +"""Test the review-patch command.""" + +# mypy: ignore-errors +# flake8: noqa + +import argparse +from pathlib import Path +from unittest.mock import ANY, MagicMock, Mock, call, patch + +import pytest + +from dfetch.commands.review_patch import ReviewPatch +from dfetch.project.gitsuperproject import GitSuperProject +from dfetch.project.superproject import NoVcsSuperProject +from tests.manifest_mock import mock_manifest + +_PATCH_FILES = ["patches/first.patch", "patches/second.patch"] + + +def _make_args(projects=None, count=None, interactive=False): + args = argparse.Namespace( + projects=projects or [], + count=count, + interactive=interactive, + ) + return args + + +def _make_superproject(is_git=True, has_local_changes=False): + sp = Mock() + sp.manifest = mock_manifest([{"name": "my_project"}]) + sp.root_directory = Path("/tmp") + sp.ignored_files.return_value = [] + sp.eol_preferences = Mock(return_value={}) + sp.has_local_changes_in_dir.return_value = has_local_changes + if is_git: + sp.__class__ = GitSuperProject + return sp + + +def _make_subproject(patches=None, on_disk_version="v1"): + sub = Mock() + sub.patch = patches if patches is not None else _PATCH_FILES + sub.local_path = "my_project" + sub.on_disk_version.return_value = on_disk_version + return sub + + +# --------------------------------------------------------------------------- +# Git happy path +# --------------------------------------------------------------------------- + + +def test_review_all_patches_calls_update_add_path_update(): + cmd = ReviewPatch() + fake_super = _make_superproject(is_git=True) + fake_sub = _make_subproject() + + with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch("dfetch.commands.review_patch.in_directory"): + with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.review_patch.is_tty", return_value=False): + cmd(_make_args()) + + update_calls = fake_sub.update.call_args_list + assert update_calls[0] == call( + force=True, ignored_files_callback=ANY, patch_count=0, eol_preferences_callback=ANY + ), "first call must fetch clean upstream" + fake_super.add_path.assert_called_once_with("my_project") + assert update_calls[1] == call( + force=True, ignored_files_callback=ANY, patch_count=-1, eol_preferences_callback=ANY + ), "second call (inside try) applies all patches" + assert update_calls[2] == call( + force=True, ignored_files_callback=ANY, patch_count=-1, eol_preferences_callback=ANY + ), "third call (finally) restores all patches" + fake_super.restore_staged.assert_called_once_with("my_project") + + +def test_review_count_1_uses_patch_count_1(): + cmd = ReviewPatch() + fake_super = _make_superproject(is_git=True) + fake_sub = _make_subproject() + + with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch("dfetch.commands.review_patch.in_directory"): + with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.review_patch.is_tty", return_value=False): + cmd(_make_args(count=1)) + + update_calls = fake_sub.update.call_args_list + assert update_calls[1] == call( + force=True, ignored_files_callback=ANY, patch_count=1, eol_preferences_callback=ANY + ), "second call must apply exactly 1 patch" + assert update_calls[2] == call( + force=True, ignored_files_callback=ANY, patch_count=-1, eol_preferences_callback=ANY + ), "finally must restore all patches" + + +# --------------------------------------------------------------------------- +# SVN path (no add_path / restore_staged) +# --------------------------------------------------------------------------- + + +def test_svn_superproject_warns_and_skips_staging(): + cmd = ReviewPatch() + fake_super = _make_superproject(is_git=False) # not GitSuperProject + fake_sub = _make_subproject() + + with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch("dfetch.commands.review_patch.in_directory"): + with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.review_patch.is_tty", return_value=False): + with patch("dfetch.commands.review_patch.logger") as mock_log: + cmd(_make_args()) + + mock_log.warning.assert_called_once() + fake_super.add_path.assert_not_called() + fake_super.restore_staged.assert_not_called() + assert fake_sub.update.call_count == 3 + + +# --------------------------------------------------------------------------- +# Skip scenarios +# --------------------------------------------------------------------------- + + +def test_no_patches_logs_warning_and_skips(): + cmd = ReviewPatch() + fake_super = _make_superproject(is_git=True) + fake_sub = _make_subproject(patches=[]) + + with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch("dfetch.commands.review_patch.in_directory"): + with patch("dfetch.project.create_sub_project", return_value=fake_sub): + cmd(_make_args()) + + fake_sub.update.assert_not_called() + fake_super.add_path.assert_not_called() + + +def test_never_fetched_logs_warning_and_skips(): + cmd = ReviewPatch() + fake_super = _make_superproject(is_git=True) + fake_sub = _make_subproject(on_disk_version=None) + + with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch("dfetch.commands.review_patch.in_directory"): + with patch("dfetch.project.create_sub_project", return_value=fake_sub): + cmd(_make_args()) + + fake_sub.update.assert_not_called() + fake_super.add_path.assert_not_called() + + +def test_local_changes_logs_warning_and_skips(): + cmd = ReviewPatch() + fake_super = _make_superproject(is_git=True, has_local_changes=True) + fake_sub = _make_subproject() + + with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch("dfetch.commands.review_patch.in_directory"): + with patch("dfetch.project.create_sub_project", return_value=fake_sub): + cmd(_make_args()) + + fake_sub.update.assert_not_called() + fake_super.add_path.assert_not_called() + + +# --------------------------------------------------------------------------- +# Error scenarios +# --------------------------------------------------------------------------- + + +def test_no_vcs_superproject_raises(): + cmd = ReviewPatch() + fake_super = Mock() + fake_super.__class__ = NoVcsSuperProject + + with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with pytest.raises(RuntimeError): + cmd(_make_args()) + + +def test_interactive_without_tty_raises(): + cmd = ReviewPatch() + fake_super = _make_superproject(is_git=True) + + with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch("dfetch.commands.review_patch.is_tty", return_value=False): + with pytest.raises(RuntimeError, match="interactive"): + cmd(_make_args(interactive=True)) From 7e80cff8fea05d8820d0beb8bd47da7bcd384b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 21:43:31 +0000 Subject: [PATCH 02/22] Fix pylint R0801, isort order, and code-review findings in review-patch - Extract shared project-iteration loop into Command._iter_projects to eliminate duplicate code between review_patch and update_patch (R0801) - Fix import order: review_patch before update in __main__.py (isort) - Remove redundant `import dfetch.project` in review_patch; import create_sub_project directly instead (code review finding) - Remove unused MagicMock import from test_review_patch (code review finding) - Update test patch targets to match new import locations Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/__main__.py | 2 +- dfetch/commands/command.py | 26 +++++++++++++++++++++++++ dfetch/commands/review_patch.py | 34 ++++++++++++--------------------- dfetch/commands/update_patch.py | 22 ++++++--------------- tests/test_review_patch.py | 26 ++++++++++++------------- 5 files changed, 58 insertions(+), 52 deletions(-) diff --git a/dfetch/__main__.py b/dfetch/__main__.py index 931b5e780..9b1ab9d21 100644 --- a/dfetch/__main__.py +++ b/dfetch/__main__.py @@ -19,8 +19,8 @@ import dfetch.commands.init import dfetch.commands.remove import dfetch.commands.report -import dfetch.commands.update import dfetch.commands.review_patch +import dfetch.commands.update import dfetch.commands.update_patch import dfetch.commands.validate import dfetch.log diff --git a/dfetch/commands/command.py b/dfetch/commands/command.py index 7e71b3351..666e6269e 100644 --- a/dfetch/commands/command.py +++ b/dfetch/commands/command.py @@ -5,8 +5,14 @@ import sys from abc import ABC, abstractmethod from argparse import ArgumentParser # pylint: disable=unused-import +from collections.abc import Callable from typing import TYPE_CHECKING, TypeVar +from dfetch.log import get_logger +from dfetch.manifest.project import ProjectEntry +from dfetch.project.superproject import SuperProject +from dfetch.util.util import in_directory + if TYPE_CHECKING and sys.version_info >= (3, 10): from typing import TypeAlias @@ -18,6 +24,8 @@ argparse._SubParsersAction # pyright: ignore[reportPrivateUsage] #pylint: disable=protected-access ) +_command_logger = get_logger(__name__) + def pascal_to_kebab(name: str) -> str: """Insert a dash before each uppercase letter (except the first) and lowercase everything.""" @@ -60,6 +68,24 @@ def __call__(self, args: argparse.Namespace) -> None: NotImplementedError: This is an abstract method that should be implemented by a subclass. """ + @staticmethod + def _iter_projects( + superproject: SuperProject, + project_names: list[str], + process: Callable[[ProjectEntry], None], + ) -> None: + """Iterate over selected projects, log per-project errors, re-raise if any failed.""" + had_errors = False + with in_directory(superproject.root_directory): + for project in superproject.manifest.selected_projects(project_names): + try: + process(project) + except RuntimeError as exc: + _command_logger.print_error_line(project.name, str(exc)) + had_errors = True + if had_errors: + raise RuntimeError() + @staticmethod def parser( subparsers: SubparserActionType, diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index ba97dddd1..10dbefbdf 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -36,14 +36,12 @@ import dfetch.commands.command import dfetch.manifest.project -import dfetch.project from dfetch.log import get_logger -from dfetch.project import create_super_project +from dfetch.project import create_sub_project, create_super_project from dfetch.project.gitsuperproject import GitSuperProject from dfetch.project.subproject import SubProject from dfetch.project.superproject import NoVcsSuperProject, SuperProject from dfetch.terminal import BOLD, DIM, RESET, Screen, is_tty, read_key -from dfetch.util.util import in_directory logger = get_logger(__name__) @@ -90,8 +88,6 @@ def __call__(self, args: argparse.Namespace) -> None: """Perform the review patch.""" superproject = create_super_project() - had_errors: bool = False - if isinstance(superproject, NoVcsSuperProject): raise RuntimeError( "The project containing the manifest is not under version control," @@ -106,18 +102,13 @@ def __call__(self, args: argparse.Namespace) -> None: if args.interactive and not is_tty(): raise RuntimeError("--interactive requires an interactive terminal") - with in_directory(superproject.root_directory): - for project in superproject.manifest.selected_projects(args.projects): - try: - self._review_project( - superproject, project, args.count, args.interactive - ) - except RuntimeError as exc: - logger.print_error_line(project.name, str(exc)) - had_errors = True - - if had_errors: - raise RuntimeError() + self._iter_projects( + superproject, + args.projects, + lambda project: self._review_project( + superproject, project, args.count, args.interactive + ), + ) def _review_project( self, @@ -127,11 +118,11 @@ def _review_project( interactive: bool, ) -> None: """Set up review state for a single project, then restore.""" - subproject = dfetch.project.create_sub_project(project) - destination = project.destination + subproject = create_sub_project(project) + is_git = isinstance(superproject, GitSuperProject) - def _ignored(dst: str = destination) -> list[str]: - return list(superproject.ignored_files(dst)) + def _ignored() -> list[str]: + return list(superproject.ignored_files(project.destination)) if not subproject.patch: logger.print_warning_line( @@ -156,7 +147,6 @@ def _ignored(dst: str = destination) -> list[str]: ) return - is_git = isinstance(superproject, GitSuperProject) total_patches = len(list(subproject.patch)) subproject.update( diff --git a/dfetch/commands/update_patch.py b/dfetch/commands/update_patch.py index 15dd28185..16af54849 100644 --- a/dfetch/commands/update_patch.py +++ b/dfetch/commands/update_patch.py @@ -41,10 +41,7 @@ from dfetch.project.gitsuperproject import GitSuperProject from dfetch.project.metadata import Metadata from dfetch.project.superproject import NoVcsSuperProject, RevisionRange, SuperProject -from dfetch.util.util import ( - check_no_path_traversal, - in_directory, -) +from dfetch.util.util import check_no_path_traversal logger = get_logger(__name__) @@ -75,8 +72,6 @@ def __call__(self, args: argparse.Namespace) -> None: """Perform the update patch.""" superproject = create_super_project() - had_errors: bool = False - if isinstance(superproject, NoVcsSuperProject): raise RuntimeError( "The project containing the manifest is not under version control," @@ -85,16 +80,11 @@ def __call__(self, args: argparse.Namespace) -> None: if not isinstance(superproject, GitSuperProject): logger.warning("Update patch is only fully supported in git superprojects!") - with in_directory(superproject.root_directory): - for project in superproject.manifest.selected_projects(args.projects): - try: - self._process_project(superproject, project) - except RuntimeError as exc: - logger.print_error_line(project.name, str(exc)) - had_errors = True - - if had_errors: - raise RuntimeError() + self._iter_projects( + superproject, + args.projects, + lambda project: self._process_project(superproject, project), + ) def _process_project( self, diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py index 33b424246..4d674615e 100644 --- a/tests/test_review_patch.py +++ b/tests/test_review_patch.py @@ -5,7 +5,7 @@ import argparse from pathlib import Path -from unittest.mock import ANY, MagicMock, Mock, call, patch +from unittest.mock import ANY, Mock, call, patch import pytest @@ -57,8 +57,8 @@ def test_review_all_patches_calls_update_add_path_update(): fake_sub = _make_subproject() with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): - with patch("dfetch.commands.review_patch.in_directory"): - with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.command.in_directory"): + with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): with patch("dfetch.commands.review_patch.is_tty", return_value=False): cmd(_make_args()) @@ -82,8 +82,8 @@ def test_review_count_1_uses_patch_count_1(): fake_sub = _make_subproject() with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): - with patch("dfetch.commands.review_patch.in_directory"): - with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.command.in_directory"): + with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): with patch("dfetch.commands.review_patch.is_tty", return_value=False): cmd(_make_args(count=1)) @@ -107,8 +107,8 @@ def test_svn_superproject_warns_and_skips_staging(): fake_sub = _make_subproject() with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): - with patch("dfetch.commands.review_patch.in_directory"): - with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.command.in_directory"): + with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): with patch("dfetch.commands.review_patch.is_tty", return_value=False): with patch("dfetch.commands.review_patch.logger") as mock_log: cmd(_make_args()) @@ -130,8 +130,8 @@ def test_no_patches_logs_warning_and_skips(): fake_sub = _make_subproject(patches=[]) with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): - with patch("dfetch.commands.review_patch.in_directory"): - with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.command.in_directory"): + with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): cmd(_make_args()) fake_sub.update.assert_not_called() @@ -144,8 +144,8 @@ def test_never_fetched_logs_warning_and_skips(): fake_sub = _make_subproject(on_disk_version=None) with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): - with patch("dfetch.commands.review_patch.in_directory"): - with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.command.in_directory"): + with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): cmd(_make_args()) fake_sub.update.assert_not_called() @@ -158,8 +158,8 @@ def test_local_changes_logs_warning_and_skips(): fake_sub = _make_subproject() with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): - with patch("dfetch.commands.review_patch.in_directory"): - with patch("dfetch.project.create_sub_project", return_value=fake_sub): + with patch("dfetch.commands.command.in_directory"): + with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): cmd(_make_args()) fake_sub.update.assert_not_called() From 14fdd096be5441c43d9f1adaaa8c92489c9b6750 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:00:48 +0000 Subject: [PATCH 03/22] Add review-patch to CI run, demo script, and docs - Add `dfetch review-patch` step after `dfetch update-patch` in both the `run` (all platforms) and `test-cygwin` jobs in run.yml - Add `review-patch-demo.sh` cast generation script (mirrors update-patch-demo.sh; pipes stdin to avoid blocking on input()) - Register the demo in generate-casts.sh after the update-patch line - Add synthetic review-patch.cast (asciicast v2) showing the full flow: manifest inspection, patch preview, staging, and restore - Wire `.. asciinema:: ../asciicasts/review-patch.cast` into the Reviewing a patch section of doc/howto/patching.rst Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- .github/workflows/run.yml | 2 + doc/asciicasts/review-patch.cast | 58 +++++++++++++++++++++++++ doc/generate-casts/generate-casts.sh | 1 + doc/generate-casts/review-patch-demo.sh | 55 +++++++++++++++++++++++ doc/howto/patching.rst | 2 + 5 files changed, 118 insertions(+) create mode 100644 doc/asciicasts/review-patch.cast create mode 100755 doc/generate-casts/review-patch-demo.sh diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index 6f10b0537..3206d7951 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -56,6 +56,7 @@ jobs: - run: dfetch update - run: dfetch update - run: dfetch update-patch + - run: dfetch review-patch - run: dfetch format-patch - run: dfetch report -t sbom - run: dfetch remove test-repo @@ -198,6 +199,7 @@ jobs: - run: dfetch update - run: dfetch update - run: dfetch update-patch + - run: dfetch review-patch - run: dfetch format-patch - run: dfetch report -t sbom - run: dfetch remove test-repo diff --git a/doc/asciicasts/review-patch.cast b/doc/asciicasts/review-patch.cast new file mode 100644 index 000000000..d2ac4e3d1 --- /dev/null +++ b/doc/asciicasts/review-patch.cast @@ -0,0 +1,58 @@ +{"version": 2, "width": 193, "height": 32, "timestamp": 1750000000, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.5, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.6, "o", "$ "] +[1.6, "o", "\u001b[1mcat dfetch.yaml\u001b[0m"] +[2.6, "o", "\r\n"] +[2.7, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] +[2.75, "o", "$ "] +[3.75, "o", "\u001b[1mcat patches/cpputest.patch\u001b[0m"] +[4.75, "o", "\r\n"] +[4.8, "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"] +[4.85, "o", "$ "] +[5.85, "o", "\u001b[1mdfetch review-patch cpputest\u001b[0m"] +[6.85, "o", "\r\n"] +[7.45, "o", "\u001b[1;34mDfetch (0.14.0)\u001b[0m\r\n"] +[7.49, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[7.5, "o", "\u001b[?25l"] +[7.58, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[7.66, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[7.74, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[7.82, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[7.9, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[7.98, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.06, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.14, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.22, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[8.23, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] +[8.3, "o", "\u001b[?25l"] +[8.38, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.46, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.54, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.62, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.7, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.78, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.86, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.94, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[9.02, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[9.03, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] +[9.04, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m\r\n"] +[9.09, "o", " \u001b[34msuccessfully patched 1/1: \u001b[0m\u001b[34m \u001b[0m \r\n\u001b[34mb'README.md'\u001b[0m \r\n"] +[9.15, "o", " \u001b[1;34m> stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect\u001b[0m\r\n"] +[9.2, "o", "Press Enter to restore..."] +[11.2, "o", "\r\n"] +[11.3, "o", "\u001b[?25l"] +[11.38, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.46, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.54, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.62, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.7, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.78, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.86, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.94, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[12.02, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[12.03, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] +[12.04, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m\r\n"] +[12.09, "o", " \u001b[34msuccessfully patched 1/1: \u001b[0m\u001b[34m \u001b[0m \r\n\u001b[34mb'README.md'\u001b[0m \r\n"] +[12.15, "o", " \u001b[1;34m> restored\u001b[0m\r\n"] +[12.2, "o", "$ "] +[15.2, "o", ""] diff --git a/doc/generate-casts/generate-casts.sh b/doc/generate-casts/generate-casts.sh index 5ec894f44..2bfb7c68f 100755 --- a/doc/generate-casts/generate-casts.sh +++ b/doc/generate-casts/generate-casts.sh @@ -28,6 +28,7 @@ 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 +asciinema rec --overwrite -c "./review-patch-demo.sh" ../asciicasts/review-patch.cast asciinema rec --overwrite -c "./format-patch-demo.sh" ../asciicasts/format-patch.cast rm -rf update diff --git a/doc/generate-casts/review-patch-demo.sh b/doc/generate-casts/review-patch-demo.sh new file mode 100755 index 000000000..3e99336c5 --- /dev/null +++ b/doc/generate-casts/review-patch-demo.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +source ./demo-magic/demo-magic.sh + +PROMPT_TIMEOUT=1 + +# Copy example manifest +mkdir review-patch +pushd review-patch || exit 1 + +git init +cp -r ../update/* . +git add . +git commit -m "Initial commit" + +sed -i 's/github/gitlab/g' cpputest/src/README.md +dfetch diff cpputest +mkdir -p patches +mv cpputest.patch patches/cpputest.patch + +cat > dfetch.yaml <<'EOF' +manifest: + version: 0.0 + + remotes: + - name: github + url-base: https://github.com/ + + projects: + - name: cpputest + dst: cpputest/src/ + repo-path: cpputest/cpputest.git + tag: v3.4 + patch: patches/cpputest.patch + +EOF + +dfetch update -f cpputest +git add . +git commit -m 'Fix vcs host' + +clear +# Run the command +pe "cat dfetch.yaml" +pe "cat patches/cpputest.patch" +# Pipe stdin to avoid blocking on "Press Enter to restore..." +pe "echo '' | dfetch review-patch cpputest" + +PROMPT_TIMEOUT=3 +wait + +pei "" + +popd || exit 1 +rm -rf review-patch diff --git a/doc/howto/patching.rst b/doc/howto/patching.rst index 643d505bb..c050648ab 100644 --- a/doc/howto/patching.rst +++ b/doc/howto/patching.rst @@ -316,6 +316,8 @@ so your editor always reflects the current position in the stack: Press **Enter** to finish and restore the original state. +.. asciinema:: ../asciicasts/review-patch.cast + .. tabs:: .. tab:: Git From a2a9a2b126793b2e2aebd07f77eb55b315665f8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:06:48 +0000 Subject: [PATCH 04/22] Skip redundant restore fetch when all patches already applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the default (all-patches) case review-patch was fetching three times: once for clean upstream, once to apply all patches, and once more in the finally block to "restore" — even though the working tree was already fully patched after the second fetch. Track worktree_fully_patched after the non-interactive update call and skip the finally fetch when the working tree is already at patch_count=-1. The --count N and --interactive paths still re-fetch because the final working-tree state differs from fully patched. Update tests and the synthetic demo cast accordingly. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 15 +++++++++------ doc/asciicasts/review-patch.cast | 27 +++++++-------------------- tests/test_review_patch.py | 8 +++----- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index 10dbefbdf..82899948e 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -160,6 +160,7 @@ def _ignored() -> list[str]: assert isinstance(superproject, GitSuperProject) superproject.add_path(subproject.local_path) + worktree_fully_patched = False try: if interactive: _step_tui( @@ -177,6 +178,7 @@ def _ignored() -> list[str]: patch_count=chosen_count, eol_preferences_callback=superproject.eol_preferences, ) + worktree_fully_patched = chosen_count == -1 patch_label = ( str(total_patches) if chosen_count == -1 else str(chosen_count) ) @@ -188,12 +190,13 @@ def _ignored() -> list[str]: if is_tty(): input("Press Enter to restore...") finally: - subproject.update( - force=True, - ignored_files_callback=_ignored, - patch_count=-1, - eol_preferences_callback=superproject.eol_preferences, - ) + if not worktree_fully_patched: + subproject.update( + force=True, + ignored_files_callback=_ignored, + patch_count=-1, + eol_preferences_callback=superproject.eol_preferences, + ) if is_git: assert isinstance(superproject, GitSuperProject) superproject.restore_staged(subproject.local_path) diff --git a/doc/asciicasts/review-patch.cast b/doc/asciicasts/review-patch.cast index d2ac4e3d1..cb5a2e72d 100644 --- a/doc/asciicasts/review-patch.cast +++ b/doc/asciicasts/review-patch.cast @@ -14,25 +14,25 @@ [7.45, "o", "\u001b[1;34mDfetch (0.14.0)\u001b[0m\r\n"] [7.49, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] [7.5, "o", "\u001b[?25l"] -[7.58, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[7.58, "o", "\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [7.66, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [7.74, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [7.82, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [7.9, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [7.98, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [8.06, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.14, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.14, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [8.22, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] [8.23, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] [8.3, "o", "\u001b[?25l"] -[8.38, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.38, "o", "\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [8.46, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [8.54, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [8.62, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [8.7, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [8.78, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [8.86, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.94, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[8.94, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] [9.02, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] [9.03, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] [9.04, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m\r\n"] @@ -40,19 +40,6 @@ [9.15, "o", " \u001b[1;34m> stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect\u001b[0m\r\n"] [9.2, "o", "Press Enter to restore..."] [11.2, "o", "\r\n"] -[11.3, "o", "\u001b[?25l"] -[11.38, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.46, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.54, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.62, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.7, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.78, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.86, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.94, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[12.02, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[12.03, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] -[12.04, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m\r\n"] -[12.09, "o", " \u001b[34msuccessfully patched 1/1: \u001b[0m\u001b[34m \u001b[0m \r\n\u001b[34mb'README.md'\u001b[0m \r\n"] -[12.15, "o", " \u001b[1;34m> restored\u001b[0m\r\n"] -[12.2, "o", "$ "] -[15.2, "o", ""] +[11.25, "o", " \u001b[1;34m> restored\u001b[0m\r\n"] +[11.3, "o", "$ "] +[14.3, "o", ""] diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py index 4d674615e..193f19c56 100644 --- a/tests/test_review_patch.py +++ b/tests/test_review_patch.py @@ -63,16 +63,14 @@ def test_review_all_patches_calls_update_add_path_update(): cmd(_make_args()) update_calls = fake_sub.update.call_args_list + assert len(update_calls) == 2, "all-patches case must only fetch twice (no redundant restore fetch)" assert update_calls[0] == call( force=True, ignored_files_callback=ANY, patch_count=0, eol_preferences_callback=ANY ), "first call must fetch clean upstream" fake_super.add_path.assert_called_once_with("my_project") assert update_calls[1] == call( force=True, ignored_files_callback=ANY, patch_count=-1, eol_preferences_callback=ANY - ), "second call (inside try) applies all patches" - assert update_calls[2] == call( - force=True, ignored_files_callback=ANY, patch_count=-1, eol_preferences_callback=ANY - ), "third call (finally) restores all patches" + ), "second call applies all patches; working tree is already restored so no third fetch" fake_super.restore_staged.assert_called_once_with("my_project") @@ -116,7 +114,7 @@ def test_svn_superproject_warns_and_skips_staging(): mock_log.warning.assert_called_once() fake_super.add_path.assert_not_called() fake_super.restore_staged.assert_not_called() - assert fake_sub.update.call_count == 3 + assert fake_sub.update.call_count == 2 # --------------------------------------------------------------------------- From 532a61f83682830eaca3c4d6d9a6f7610a00ab96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:23:05 +0000 Subject: [PATCH 05/22] Eliminate fetches after the first in review-patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the initial update(patch_count=0) puts clean upstream in the working tree there is no reason to re-fetch for any subsequent step. - Add SubProject.apply_patches(count) — applies patch files to the already-fetched working tree without going back to the remote. - Add GitSuperProject.restore_worktree(path) / GitLocalRepo.restore_worktree — runs `git restore ` to reset the working tree from the staged index (which holds clean upstream throughout the review session). - Non-interactive: replace the second update() call with apply_patches(). Git all-patches case: 1 fetch total (was 2). Git --count N case: 1 fetch + restore_worktree + apply_patches (was 3). - Interactive TUI: replace per-step update() calls with direct patch apply/reverse (Patch.from_file().apply() / .reverse().apply()). No fetch at all during stepping, for both Git and SVN. - Finally block (Git): restore_worktree + apply_patches instead of a re-fetch. SVN still re-fetches when the worktree is not fully patched (no staged index to restore from). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 59 ++++++++++--------------------- dfetch/project/gitsuperproject.py | 4 +++ dfetch/project/subproject.py | 8 +++++ dfetch/vcs/git.py | 5 +++ tests/test_review_patch.py | 30 ++++++++-------- 5 files changed, 50 insertions(+), 56 deletions(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index 82899948e..dc887bd8f 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -32,16 +32,15 @@ """ import argparse -from collections.abc import Callable, Sequence import dfetch.commands.command import dfetch.manifest.project from dfetch.log import get_logger from dfetch.project import create_sub_project, create_super_project from dfetch.project.gitsuperproject import GitSuperProject -from dfetch.project.subproject import SubProject from dfetch.project.superproject import NoVcsSuperProject, SuperProject from dfetch.terminal import BOLD, DIM, RESET, Screen, is_tty, read_key +from dfetch.vcs.patch import Patch logger = get_logger(__name__) @@ -163,21 +162,10 @@ def _ignored() -> list[str]: worktree_fully_patched = False try: if interactive: - _step_tui( - list(subproject.patch), - subproject, - superproject, - _ignored, - project.name, - ) + _step_tui(list(subproject.patch), subproject.local_path, project.name) else: chosen_count = count if count is not None else -1 - subproject.update( - force=True, - ignored_files_callback=_ignored, - patch_count=chosen_count, - eol_preferences_callback=superproject.eol_preferences, - ) + subproject.apply_patches(chosen_count) worktree_fully_patched = chosen_count == -1 patch_label = ( str(total_patches) if chosen_count == -1 else str(chosen_count) @@ -191,30 +179,30 @@ def _ignored() -> list[str]: input("Press Enter to restore...") finally: if not worktree_fully_patched: - subproject.update( - force=True, - ignored_files_callback=_ignored, - patch_count=-1, - eol_preferences_callback=superproject.eol_preferences, - ) + if is_git: + assert isinstance(superproject, GitSuperProject) + superproject.restore_worktree(subproject.local_path) + else: + subproject.update( + force=True, + ignored_files_callback=_ignored, + patch_count=0, + eol_preferences_callback=superproject.eol_preferences, + ) + subproject.apply_patches() if is_git: assert isinstance(superproject, GitSuperProject) superproject.restore_staged(subproject.local_path) logger.print_info_line(project.name, "restored") -def _step_tui( - patches: list[str], - subproject: SubProject, - superproject: SuperProject, - ignored_callback: Callable[[], Sequence[str]], - project_name: str, -) -> None: +def _step_tui(patches: list[str], local_path: str, project_name: str) -> None: """Interactive patch-stack stepper using arrow keys. The git index is staged once (clean upstream) before this function is called. Only the working tree is updated on each step so that ``git diff`` always shows the contribution of the currently applied patches. + Patches are applied or reversed directly — no VCS fetch per step. """ total = len(patches) current = 0 @@ -239,22 +227,11 @@ def _step_tui( return if key == "LEFT" and current > 0: + Patch.from_file(patches[current - 1]).reverse().apply(root=local_path) current -= 1 - subproject.update( - force=True, - ignored_files_callback=ignored_callback, - patch_count=current, - eol_preferences_callback=superproject.eol_preferences, - ) elif key == "RIGHT" and current < total: + Patch.from_file(patches[current]).apply(root=local_path) current += 1 - patch_count = -1 if current == total else current - subproject.update( - force=True, - ignored_files_callback=ignored_callback, - patch_count=patch_count, - eol_preferences_callback=superproject.eol_preferences, - ) elif key in ("ENTER", "ESC"): screen.clear() return diff --git a/dfetch/project/gitsuperproject.py b/dfetch/project/gitsuperproject.py index 3d175f015..652954ae6 100644 --- a/dfetch/project/gitsuperproject.py +++ b/dfetch/project/gitsuperproject.py @@ -59,6 +59,10 @@ def restore_staged(self, path: str) -> None: """Unstage the given path in the superproject's git index.""" self._repo.restore_staged(path) + def restore_worktree(self, path: str) -> None: + """Restore working-tree files for path from the staged index.""" + self._repo.restore_worktree(path) + def get_username(self) -> str: """Get the username of the superproject VCS.""" username = self._repo.get_username() diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index 9b750e172..de5995802 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -87,6 +87,14 @@ def update_is_required(self, force: bool = False) -> Version | None: logger.debug(f"{self.__project.name} Current ({current}), Available ({wanted})") return wanted + def apply_patches(self, count: int = -1) -> None: + """Apply patches to the already-fetched working tree without fetching from remote. + + Args: + count: Number of patches to apply (-1 means all). + """ + self._apply_patches(count) + def update( self, force: bool = False, diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index eb75f8c42..a9100c394 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -827,6 +827,11 @@ def restore_staged(self, path: str) -> None: with in_directory(self._path): run_on_cmdline(logger, ["git", "restore", "--staged", path]) + def restore_worktree(self, path: str) -> None: + """Restore working-tree files for path from the staged index.""" + with in_directory(self._path): + run_on_cmdline(logger, ["git", "restore", path]) + def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> Patch: """Create a diff for untracked files.""" with in_directory(self._path): diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py index 193f19c56..6b8a4ea3c 100644 --- a/tests/test_review_patch.py +++ b/tests/test_review_patch.py @@ -62,15 +62,12 @@ def test_review_all_patches_calls_update_add_path_update(): with patch("dfetch.commands.review_patch.is_tty", return_value=False): cmd(_make_args()) - update_calls = fake_sub.update.call_args_list - assert len(update_calls) == 2, "all-patches case must only fetch twice (no redundant restore fetch)" - assert update_calls[0] == call( + fake_sub.update.assert_called_once_with( force=True, ignored_files_callback=ANY, patch_count=0, eol_preferences_callback=ANY - ), "first call must fetch clean upstream" + ) fake_super.add_path.assert_called_once_with("my_project") - assert update_calls[1] == call( - force=True, ignored_files_callback=ANY, patch_count=-1, eol_preferences_callback=ANY - ), "second call applies all patches; working tree is already restored so no third fetch" + fake_sub.apply_patches.assert_called_once_with(-1) + fake_super.restore_worktree.assert_not_called() fake_super.restore_staged.assert_called_once_with("my_project") @@ -85,13 +82,13 @@ def test_review_count_1_uses_patch_count_1(): with patch("dfetch.commands.review_patch.is_tty", return_value=False): cmd(_make_args(count=1)) - update_calls = fake_sub.update.call_args_list - assert update_calls[1] == call( - force=True, ignored_files_callback=ANY, patch_count=1, eol_preferences_callback=ANY - ), "second call must apply exactly 1 patch" - assert update_calls[2] == call( - force=True, ignored_files_callback=ANY, patch_count=-1, eol_preferences_callback=ANY - ), "finally must restore all patches" + fake_sub.update.assert_called_once_with( + force=True, ignored_files_callback=ANY, patch_count=0, eol_preferences_callback=ANY + ) + apply_calls = fake_sub.apply_patches.call_args_list + assert apply_calls[0] == call(1), "first apply must apply exactly 1 patch" + assert apply_calls[1] == call(), "finally must restore all patches via apply_patches()" + fake_super.restore_worktree.assert_called_once_with("my_project") # --------------------------------------------------------------------------- @@ -114,7 +111,10 @@ def test_svn_superproject_warns_and_skips_staging(): mock_log.warning.assert_called_once() fake_super.add_path.assert_not_called() fake_super.restore_staged.assert_not_called() - assert fake_sub.update.call_count == 2 + fake_sub.update.assert_called_once_with( + force=True, ignored_files_callback=ANY, patch_count=0, eol_preferences_callback=ANY + ) + fake_sub.apply_patches.assert_called_once_with(-1) # --------------------------------------------------------------------------- From 98c8ff79f1828aee66f14df3dceec1101c6187cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 05:18:48 +0000 Subject: [PATCH 06/22] Refactor review-patch to reduce cyclomatic complexity below 8 Extract _can_review_project, _apply_review, _restore_project, _draw_tui_frame, and _apply_step as module-level helpers so every function stays below the CC=8 limit enforced by CI. Black-format tests to match. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 209 +++++++++++++++++++++----------- tests/test_review_patch.py | 75 +++++++++--- 2 files changed, 193 insertions(+), 91 deletions(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index dc887bd8f..d84251cc0 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -32,12 +32,14 @@ """ import argparse +from collections.abc import Callable import dfetch.commands.command import dfetch.manifest.project from dfetch.log import get_logger from dfetch.project import create_sub_project, create_super_project from dfetch.project.gitsuperproject import GitSuperProject +from dfetch.project.subproject import SubProject from dfetch.project.superproject import NoVcsSuperProject, SuperProject from dfetch.terminal import BOLD, DIM, RESET, Screen, is_tty, read_key from dfetch.vcs.patch import Patch @@ -123,31 +125,10 @@ def _review_project( def _ignored() -> list[str]: return list(superproject.ignored_files(project.destination)) - if not subproject.patch: - logger.print_warning_line( - project.name, - 'skipped - there is no patch file, use "dfetch diff"' - f" {project.name} to create one", - ) - return - - 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, use "dfetch update {project.name}"', - ) - return - - if superproject.has_local_changes_in_dir(subproject.local_path): - logger.print_warning_line( - project.name, - f"skipped - uncommitted changes in {subproject.local_path}", - ) + if not _can_review_project(superproject, subproject, project.name): return total_patches = len(list(subproject.patch)) - subproject.update( force=True, ignored_files_callback=_ignored, @@ -161,39 +142,137 @@ def _ignored() -> list[str]: worktree_fully_patched = False try: - if interactive: - _step_tui(list(subproject.patch), subproject.local_path, project.name) - else: - chosen_count = count if count is not None else -1 - subproject.apply_patches(chosen_count) - worktree_fully_patched = chosen_count == -1 - patch_label = ( - str(total_patches) if chosen_count == -1 else str(chosen_count) - ) - logger.print_info_line( - project.name, - f"stage = upstream, working tree = {patch_label} patch(es) applied" - " — open your editor and run `git diff` to inspect", - ) - if is_tty(): - input("Press Enter to restore...") + worktree_fully_patched = _apply_review( + subproject, project.name, count, interactive, total_patches + ) finally: - if not worktree_fully_patched: - if is_git: - assert isinstance(superproject, GitSuperProject) - superproject.restore_worktree(subproject.local_path) - else: - subproject.update( - force=True, - ignored_files_callback=_ignored, - patch_count=0, - eol_preferences_callback=superproject.eol_preferences, - ) - subproject.apply_patches() - if is_git: - assert isinstance(superproject, GitSuperProject) - superproject.restore_staged(subproject.local_path) - logger.print_info_line(project.name, "restored") + _restore_project( + superproject, + subproject, + project.name, + is_git, + worktree_fully_patched, + _ignored, + ) + + +def _can_review_project( + superproject: SuperProject, + subproject: SubProject, + project_name: str, +) -> bool: + """Return False and log a warning when the project cannot be reviewed.""" + if not subproject.patch: + logger.print_warning_line( + project_name, + 'skipped - there is no patch file, use "dfetch diff"' + f" {project_name} to create one", + ) + return False + if not subproject.on_disk_version(): + logger.print_warning_line( + project_name, + f'skipped - the project was never fetched, use "dfetch update {project_name}"', + ) + return False + 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 False + return True + + +def _apply_review( + subproject: SubProject, + project_name: str, + count: int | None, + interactive: bool, + total_patches: int, +) -> bool: + """Run the review session; return True when the worktree is already fully patched.""" + if interactive: + _step_tui(list(subproject.patch), subproject.local_path, project_name) + return False + + chosen_count = count if count is not None else -1 + subproject.apply_patches(chosen_count) + patch_label = str(total_patches) if chosen_count == -1 else str(chosen_count) + logger.print_info_line( + project_name, + f"stage = upstream, working tree = {patch_label} patch(es) applied" + " — open your editor and run `git diff` to inspect", + ) + if is_tty(): + input("Press Enter to restore...") + return chosen_count == -1 + + +def _restore_project( + superproject: SuperProject, + subproject: SubProject, + project_name: str, + is_git: bool, + worktree_fully_patched: bool, + ignored_callback: Callable[[], list[str]], +) -> None: + """Restore the project to the fully-patched state and un-stage the index.""" + if not worktree_fully_patched: + if is_git: + assert isinstance(superproject, GitSuperProject) + superproject.restore_worktree(subproject.local_path) + else: + subproject.update( + force=True, + ignored_files_callback=ignored_callback, + patch_count=0, + eol_preferences_callback=superproject.eol_preferences, + ) + subproject.apply_patches() + if is_git: + assert isinstance(superproject, GitSuperProject) + superproject.restore_staged(subproject.local_path) + logger.print_info_line(project_name, "restored") + + +def _draw_tui_frame( + screen: Screen, + patches: list[str], + current: int, + total: int, + project_name: str, +) -> None: + """Render the current patch-stack state to the screen.""" + count_label = str(current) if current < total else "all" + lines: list[str] = [ + f" {DIM}← → step Enter restore and exit Ctrl-C abort{RESET}", + " " + "─" * 54, + f" {project_name} [{count_label}/{total} patches applied]", + ] + for idx, patch_name in enumerate(patches): + marker = f"{BOLD}[x]{RESET}" if idx < current else f"{DIM}[ ]{RESET}" + lines.append(f" {marker} {patch_name}") + screen.draw(lines) + + +def _apply_step( + key: str, + current: int, + total: int, + patches: list[str], + local_path: str, +) -> tuple[int, bool]: + """Handle one keypress; return (new_current, done).""" + if key == "LEFT" and current > 0: + Patch.from_file(patches[current - 1]).reverse().apply(root=local_path) + return current - 1, False + if key == "RIGHT" and current < total: + Patch.from_file(patches[current]).apply(root=local_path) + return current + 1, False + if key in ("ENTER", "ESC"): + return current, True + return current, False def _step_tui(patches: list[str], local_path: str, project_name: str) -> None: @@ -209,29 +288,13 @@ def _step_tui(patches: list[str], local_path: str, project_name: str) -> None: screen = Screen() while True: - count_label = str(current) if current < total else "all" - lines: list[str] = [ - f" {DIM}← → step Enter restore and exit Ctrl-C abort{RESET}", - " " + "─" * 54, - f" {project_name} [{count_label}/{total} patches applied]", - ] - for idx, patch_name in enumerate(patches): - marker = f"{BOLD}[x]{RESET}" if idx < current else f"{DIM}[ ]{RESET}" - lines.append(f" {marker} {patch_name}") - screen.draw(lines) - + _draw_tui_frame(screen, patches, current, total, project_name) try: key = read_key() except KeyboardInterrupt: screen.clear() return - - if key == "LEFT" and current > 0: - Patch.from_file(patches[current - 1]).reverse().apply(root=local_path) - current -= 1 - elif key == "RIGHT" and current < total: - Patch.from_file(patches[current]).apply(root=local_path) - current += 1 - elif key in ("ENTER", "ESC"): + current, done = _apply_step(key, current, total, patches, local_path) + if done: screen.clear() return diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py index 6b8a4ea3c..b97890d4a 100644 --- a/tests/test_review_patch.py +++ b/tests/test_review_patch.py @@ -56,14 +56,21 @@ def test_review_all_patches_calls_update_add_path_update(): fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject() - with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): with patch("dfetch.commands.command.in_directory"): - with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): + with patch( + "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + ): with patch("dfetch.commands.review_patch.is_tty", return_value=False): cmd(_make_args()) fake_sub.update.assert_called_once_with( - force=True, ignored_files_callback=ANY, patch_count=0, eol_preferences_callback=ANY + force=True, + ignored_files_callback=ANY, + patch_count=0, + eol_preferences_callback=ANY, ) fake_super.add_path.assert_called_once_with("my_project") fake_sub.apply_patches.assert_called_once_with(-1) @@ -76,18 +83,27 @@ def test_review_count_1_uses_patch_count_1(): fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject() - with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): with patch("dfetch.commands.command.in_directory"): - with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): + with patch( + "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + ): with patch("dfetch.commands.review_patch.is_tty", return_value=False): cmd(_make_args(count=1)) fake_sub.update.assert_called_once_with( - force=True, ignored_files_callback=ANY, patch_count=0, eol_preferences_callback=ANY + force=True, + ignored_files_callback=ANY, + patch_count=0, + eol_preferences_callback=ANY, ) apply_calls = fake_sub.apply_patches.call_args_list assert apply_calls[0] == call(1), "first apply must apply exactly 1 patch" - assert apply_calls[1] == call(), "finally must restore all patches via apply_patches()" + assert ( + apply_calls[1] == call() + ), "finally must restore all patches via apply_patches()" fake_super.restore_worktree.assert_called_once_with("my_project") @@ -101,9 +117,13 @@ def test_svn_superproject_warns_and_skips_staging(): fake_super = _make_superproject(is_git=False) # not GitSuperProject fake_sub = _make_subproject() - with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): with patch("dfetch.commands.command.in_directory"): - with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): + with patch( + "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + ): with patch("dfetch.commands.review_patch.is_tty", return_value=False): with patch("dfetch.commands.review_patch.logger") as mock_log: cmd(_make_args()) @@ -112,7 +132,10 @@ def test_svn_superproject_warns_and_skips_staging(): fake_super.add_path.assert_not_called() fake_super.restore_staged.assert_not_called() fake_sub.update.assert_called_once_with( - force=True, ignored_files_callback=ANY, patch_count=0, eol_preferences_callback=ANY + force=True, + ignored_files_callback=ANY, + patch_count=0, + eol_preferences_callback=ANY, ) fake_sub.apply_patches.assert_called_once_with(-1) @@ -127,9 +150,13 @@ def test_no_patches_logs_warning_and_skips(): fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject(patches=[]) - with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): with patch("dfetch.commands.command.in_directory"): - with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): + with patch( + "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + ): cmd(_make_args()) fake_sub.update.assert_not_called() @@ -141,9 +168,13 @@ def test_never_fetched_logs_warning_and_skips(): fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject(on_disk_version=None) - with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): with patch("dfetch.commands.command.in_directory"): - with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): + with patch( + "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + ): cmd(_make_args()) fake_sub.update.assert_not_called() @@ -155,9 +186,13 @@ def test_local_changes_logs_warning_and_skips(): fake_super = _make_superproject(is_git=True, has_local_changes=True) fake_sub = _make_subproject() - with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): with patch("dfetch.commands.command.in_directory"): - with patch("dfetch.commands.review_patch.create_sub_project", return_value=fake_sub): + with patch( + "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + ): cmd(_make_args()) fake_sub.update.assert_not_called() @@ -174,7 +209,9 @@ def test_no_vcs_superproject_raises(): fake_super = Mock() fake_super.__class__ = NoVcsSuperProject - with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): with pytest.raises(RuntimeError): cmd(_make_args()) @@ -183,7 +220,9 @@ def test_interactive_without_tty_raises(): cmd = ReviewPatch() fake_super = _make_superproject(is_git=True) - with patch("dfetch.commands.review_patch.create_super_project", return_value=fake_super): + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): with patch("dfetch.commands.review_patch.is_tty", return_value=False): with pytest.raises(RuntimeError, match="interactive"): cmd(_make_args(interactive=True)) From 98bfd7cec60bd97ffd2e659f5c7ebeafa4909605 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 05:26:37 +0000 Subject: [PATCH 07/22] Update review-patch feature files to match reduced-fetch behaviour After the refactor, review-patch does only one fetch (clean upstream) and applies/reverses patches directly without re-fetching. The expected output in the BDD scenarios previously included 3 Fetched lines; update them to reflect the new single-fetch flow. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- features/review-patch-in-git.feature | 6 ------ features/review-patch-in-svn.feature | 5 ----- 2 files changed, 11 deletions(-) diff --git a/features/review-patch-in-git.feature b/features/review-patch-in-git.feature index 45c2acd68..f78f2fdfd 100644 --- a/features/review-patch-in-git.feature +++ b/features/review-patch-in-git.feature @@ -41,13 +41,9 @@ Feature: Review patches in git Dfetch (0.14.0) SomeProject: > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 - > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 > Applying patch "patches/SomeProject.patch" successfully patched 1/1: b'README.md' > stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect - > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 - > Applying patch "patches/SomeProject.patch" - successfully patched 1/1: b'README.md' > restored """ And the patched 'MyProject/SomeProject/README.md' is @@ -62,9 +58,7 @@ Feature: Review patches in git Dfetch (0.14.0) SomeProject: > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 - > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `git diff` to inspect - > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 > Applying patch "patches/SomeProject.patch" successfully patched 1/1: b'README.md' > restored diff --git a/features/review-patch-in-svn.feature b/features/review-patch-in-svn.feature index 94e326db0..265a3377e 100644 --- a/features/review-patch-in-svn.feature +++ b/features/review-patch-in-svn.feature @@ -38,13 +38,9 @@ Feature: Review patches in svn review-patch has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) SomeProject: > Fetched trunk - 1 - > Fetched trunk - 1 > Applying patch "patches/SomeProject.patch" successfully patched 1/1: b'README.md' > stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect - > Fetched trunk - 1 - > Applying patch "patches/SomeProject.patch" - successfully patched 1/1: b'README.md' > restored """ And the patched 'MySvnProject/SomeProject/README.md' is @@ -60,7 +56,6 @@ Feature: Review patches in svn review-patch has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) SomeProject: > Fetched trunk - 1 - > Fetched trunk - 1 > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `git diff` to inspect > Fetched trunk - 1 > Applying patch "patches/SomeProject.patch" From 50006e60feeba9758c25359d7c015655005758eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 05:36:38 +0000 Subject: [PATCH 08/22] Address review comments: count validation, pathspec hardening, VCS diff cmd - Reject --count < 0 with a RuntimeError early in __call__ - Add test_negative_count_raises to prevent regression - Add -- separator before path in git add, git restore --staged, and git restore to prevent option-style paths being parsed as flags - Show `svn diff` in the status message for SVN superprojects instead of always saying `git diff` - Clamp reported patch count to min(requested, total) so the message reflects patches actually applied rather than the raw CLI argument - Add review-patch to the dfetch_cli threat-model description, noting the transient git index mutation and interruption risk - Reduce _apply_review to 5 args by pre-computing chosen_count and info_msg in _review_project (fixes pylint too-many-arguments) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 26 ++++++++++++++++---------- dfetch/vcs/git.py | 6 +++--- features/review-patch-in-svn.feature | 4 ++-- security/tm_usage.py | 9 +++++++-- tests/test_review_patch.py | 11 +++++++++++ 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index d84251cc0..cbb884f7d 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -87,6 +87,9 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None def __call__(self, args: argparse.Namespace) -> None: """Perform the review patch.""" + if args.count is not None and args.count < 0: + raise RuntimeError("--count must be >= 0") + superproject = create_super_project() if isinstance(superproject, NoVcsSuperProject): @@ -140,10 +143,19 @@ def _ignored() -> list[str]: assert isinstance(superproject, GitSuperProject) superproject.add_path(subproject.local_path) + chosen_count = count if count is not None else -1 + effective = ( + total_patches if chosen_count == -1 else min(chosen_count, total_patches) + ) + diff_cmd = "`git diff`" if is_git else "`svn diff`" + info_msg = ( + f"stage = upstream, working tree = {effective} patch(es) applied" + f" — open your editor and run {diff_cmd} to inspect" + ) worktree_fully_patched = False try: worktree_fully_patched = _apply_review( - subproject, project.name, count, interactive, total_patches + subproject, project.name, chosen_count, interactive, info_msg ) finally: _restore_project( @@ -187,23 +199,17 @@ def _can_review_project( def _apply_review( subproject: SubProject, project_name: str, - count: int | None, + chosen_count: int, interactive: bool, - total_patches: int, + info_msg: str, ) -> bool: """Run the review session; return True when the worktree is already fully patched.""" if interactive: _step_tui(list(subproject.patch), subproject.local_path, project_name) return False - chosen_count = count if count is not None else -1 subproject.apply_patches(chosen_count) - patch_label = str(total_patches) if chosen_count == -1 else str(chosen_count) - logger.print_info_line( - project_name, - f"stage = upstream, working tree = {patch_label} patch(es) applied" - " — open your editor and run `git diff` to inspect", - ) + logger.print_info_line(project_name, info_msg) if is_tty(): input("Press Enter to restore...") return chosen_count == -1 diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index a9100c394..badac9dd5 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -820,17 +820,17 @@ def any_changes_or_untracked(self) -> bool: def add_path(self, path: str) -> None: """Stage the given path in the git index.""" with in_directory(self._path): - run_on_cmdline(logger, ["git", "add", path]) + run_on_cmdline(logger, ["git", "add", "--", path]) def restore_staged(self, path: str) -> None: """Unstage the given path, restoring the index to HEAD.""" with in_directory(self._path): - run_on_cmdline(logger, ["git", "restore", "--staged", path]) + run_on_cmdline(logger, ["git", "restore", "--staged", "--", path]) def restore_worktree(self, path: str) -> None: """Restore working-tree files for path from the staged index.""" with in_directory(self._path): - run_on_cmdline(logger, ["git", "restore", path]) + run_on_cmdline(logger, ["git", "restore", "--", path]) def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> Patch: """Create a diff for untracked files.""" diff --git a/features/review-patch-in-svn.feature b/features/review-patch-in-svn.feature index 265a3377e..fd2144004 100644 --- a/features/review-patch-in-svn.feature +++ b/features/review-patch-in-svn.feature @@ -40,7 +40,7 @@ Feature: Review patches in svn > Fetched trunk - 1 > Applying patch "patches/SomeProject.patch" successfully patched 1/1: b'README.md' - > stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect + > stage = upstream, working tree = 1 patch(es) applied — open your editor and run `svn diff` to inspect > restored """ And the patched 'MySvnProject/SomeProject/README.md' is @@ -56,7 +56,7 @@ Feature: Review patches in svn review-patch has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) SomeProject: > Fetched trunk - 1 - > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `git diff` to inspect + > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `svn diff` to inspect > Fetched trunk - 1 > Applying patch "patches/SomeProject.patch" successfully patched 1/1: b'README.md' diff --git a/security/tm_usage.py b/security/tm_usage.py index 4bf0acc6a..013e0cf9e 100644 --- a/security/tm_usage.py +++ b/security/tm_usage.py @@ -164,9 +164,14 @@ def _make_usage_processes(b_dev: Boundary) -> tuple[Process, Process]: dfetch_cli.classification = Classification.RESTRICTED dfetch_cli.description = ( "Python CLI entry point dispatching to: update, check, diff, add, remove, " - "update-patch, format-patch, freeze, import, init, report, validate, environment. " + "update-patch, format-patch, review-patch, freeze, import, init, report, " + "validate, environment. " "Invokes Git and SVN as subprocesses (``shell=False``, list args). " - "Extracts archives with decompression-bomb limits and path-traversal checks." + "Extracts archives with decompression-bomb limits and path-traversal checks. " + "``review-patch`` transiently stages the clean upstream in the git index " + "(``git add -- ``) and restores it on exit (``git restore --staged -- ``); " + "interruption before the finally block completes may leave the index temporarily " + "modified, which is visible to concurrent git operations in the same working tree." ) dfetch_cli.controls.validatesInput = True # StrictYAML + SAFE_STR regex dfetch_cli.controls.sanitizesInput = True # check_no_path_traversal realpath-based diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py index b97890d4a..3e350c1af 100644 --- a/tests/test_review_patch.py +++ b/tests/test_review_patch.py @@ -226,3 +226,14 @@ def test_interactive_without_tty_raises(): with patch("dfetch.commands.review_patch.is_tty", return_value=False): with pytest.raises(RuntimeError, match="interactive"): cmd(_make_args(interactive=True)) + + +def test_negative_count_raises(): + cmd = ReviewPatch() + fake_super = _make_superproject(is_git=True) + + with patch( + "dfetch.commands.review_patch.create_super_project", return_value=fake_super + ): + with pytest.raises(RuntimeError, match="--count must be >= 0"): + cmd(_make_args(count=-1)) From b86aba24bc9addf106c4055ebf97581b664c3921 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 05:46:26 +0000 Subject: [PATCH 09/22] Update review-patch asciicast and add rendered GIF Remove the second (now-eliminated) fetch from the cast, regenerate with agg to produce an animated GIF showing the single-fetch flow. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- doc/asciicasts/review-patch.cast | 61 ++++++++++++------------------- doc/asciicasts/review-patch.gif | Bin 0 -> 52247 bytes 2 files changed, 23 insertions(+), 38 deletions(-) create mode 100644 doc/asciicasts/review-patch.gif diff --git a/doc/asciicasts/review-patch.cast b/doc/asciicasts/review-patch.cast index cb5a2e72d..0e6551b63 100644 --- a/doc/asciicasts/review-patch.cast +++ b/doc/asciicasts/review-patch.cast @@ -1,45 +1,30 @@ -{"version": 2, "width": 193, "height": 32, "timestamp": 1750000000, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +{"version": 2, "width": 120, "height": 28, "timestamp": 1750000000, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} [0.5, "o", "\u001b[H\u001b[2J\u001b[3J"] [0.6, "o", "$ "] [1.6, "o", "\u001b[1mcat dfetch.yaml\u001b[0m"] [2.6, "o", "\r\n"] [2.7, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] [2.75, "o", "$ "] -[3.75, "o", "\u001b[1mcat patches/cpputest.patch\u001b[0m"] +[3.75, "o", "\u001b[1mdfetch review-patch cpputest\u001b[0m"] [4.75, "o", "\r\n"] -[4.8, "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"] -[4.85, "o", "$ "] -[5.85, "o", "\u001b[1mdfetch review-patch cpputest\u001b[0m"] -[6.85, "o", "\r\n"] -[7.45, "o", "\u001b[1;34mDfetch (0.14.0)\u001b[0m\r\n"] -[7.49, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[7.5, "o", "\u001b[?25l"] -[7.58, "o", "\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[7.66, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[7.74, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[7.82, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[7.9, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[7.98, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.06, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.14, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.22, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[8.23, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] -[8.3, "o", "\u001b[?25l"] -[8.38, "o", "\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.46, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.54, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.62, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.7, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.78, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.86, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[8.94, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[9.02, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[9.03, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] -[9.04, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m\r\n"] -[9.09, "o", " \u001b[34msuccessfully patched 1/1: \u001b[0m\u001b[34m \u001b[0m \r\n\u001b[34mb'README.md'\u001b[0m \r\n"] -[9.15, "o", " \u001b[1;34m> stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect\u001b[0m\r\n"] -[9.2, "o", "Press Enter to restore..."] -[11.2, "o", "\r\n"] -[11.25, "o", " \u001b[1;34m> restored\u001b[0m\r\n"] -[11.3, "o", "$ "] -[14.3, "o", ""] +[5.35, "o", "\u001b[1;34mDfetch (0.14.0)\u001b[0m\r\n"] +[5.39, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[5.4, "o", "\u001b[?25l"] +[5.48, "o", "\u001b[32m\u2807\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[5.56, "o", "\r\u001b[2K\u001b[32m\u2819\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[5.64, "o", "\r\u001b[2K\u001b[32m\u2839\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[5.72, "o", "\r\u001b[2K\u001b[32m\u2838\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[5.8, "o", "\r\u001b[2K\u001b[32m\u283c\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[5.88, "o", "\r\u001b[2K\u001b[32m\u2834\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[5.96, "o", "\r\u001b[2K\u001b[32m\u2826\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[6.04, "o", "\r\u001b[2K\u001b[32m\u2827\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[6.12, "o", "\r\u001b[2K\u001b[32m\u280f\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[6.13, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] +[6.15, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m\r\n"] +[6.2, "o", " \u001b[34msuccessfully patched 1/1: b'README.md'\u001b[0m\r\n"] +[6.25, "o", " \u001b[1;34m> stage = upstream, working tree = 1 patch(es) applied \u2014 open your editor and run `git diff` to inspect\u001b[0m\r\n"] +[6.3, "o", "Press Enter to restore..."] +[8.8, "o", "\r\n"] +[8.85, "o", " \u001b[1;34m> restored\u001b[0m\r\n"] +[8.9, "o", "$ "] +[11.0, "o", ""] diff --git a/doc/asciicasts/review-patch.gif b/doc/asciicasts/review-patch.gif new file mode 100644 index 0000000000000000000000000000000000000000..1fbd6766680a012f6292c2252f1ecbac500b9403 GIT binary patch literal 52247 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 literal 0 HcmV?d00001 From 4a95714835894cccdff8d076a5ef43514cc829ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 05:48:34 +0000 Subject: [PATCH 10/22] Fix pyright errors in test_review_patch.py - Replace sp.__class__ = GitSuperProject/NoVcsSuperProject with Mock(spec=...) so isinstance() checks work without unsafe __class__ mutation that pyright rejects - Add str | None type annotation to on_disk_version parameter so passing None in test_never_fetched_logs_warning_and_skips is valid Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- tests/test_review_patch.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py index 3e350c1af..42fd05994 100644 --- a/tests/test_review_patch.py +++ b/tests/test_review_patch.py @@ -27,18 +27,16 @@ def _make_args(projects=None, count=None, interactive=False): def _make_superproject(is_git=True, has_local_changes=False): - sp = Mock() + sp = Mock(spec=GitSuperProject) if is_git else Mock() sp.manifest = mock_manifest([{"name": "my_project"}]) sp.root_directory = Path("/tmp") sp.ignored_files.return_value = [] sp.eol_preferences = Mock(return_value={}) sp.has_local_changes_in_dir.return_value = has_local_changes - if is_git: - sp.__class__ = GitSuperProject return sp -def _make_subproject(patches=None, on_disk_version="v1"): +def _make_subproject(patches=None, on_disk_version: str | None = "v1"): sub = Mock() sub.patch = patches if patches is not None else _PATCH_FILES sub.local_path = "my_project" @@ -206,8 +204,7 @@ def test_local_changes_logs_warning_and_skips(): def test_no_vcs_superproject_raises(): cmd = ReviewPatch() - fake_super = Mock() - fake_super.__class__ = NoVcsSuperProject + fake_super = Mock(spec=NoVcsSuperProject) with patch( "dfetch.commands.review_patch.create_super_project", return_value=fake_super From 97258c431dfc5245032fe3c91e2b8076d1efe5f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 05:56:53 +0000 Subject: [PATCH 11/22] Fix bandit B101 by using GitSuperProject | None type narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace is_git: bool + assert isinstance(superproject, GitSuperProject) pattern with git_super: GitSuperProject | None so the type checker tracks the narrowed type directly — no assert needed. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index cbb884f7d..79d180bce 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -123,7 +123,7 @@ def _review_project( ) -> None: """Set up review state for a single project, then restore.""" subproject = create_sub_project(project) - is_git = isinstance(superproject, GitSuperProject) + git_super = superproject if isinstance(superproject, GitSuperProject) else None def _ignored() -> list[str]: return list(superproject.ignored_files(project.destination)) @@ -139,15 +139,14 @@ def _ignored() -> list[str]: eol_preferences_callback=superproject.eol_preferences, ) - if is_git: - assert isinstance(superproject, GitSuperProject) - superproject.add_path(subproject.local_path) + if git_super is not None: + git_super.add_path(subproject.local_path) chosen_count = count if count is not None else -1 effective = ( total_patches if chosen_count == -1 else min(chosen_count, total_patches) ) - diff_cmd = "`git diff`" if is_git else "`svn diff`" + diff_cmd = "`git diff`" if git_super is not None else "`svn diff`" info_msg = ( f"stage = upstream, working tree = {effective} patch(es) applied" f" — open your editor and run {diff_cmd} to inspect" @@ -160,9 +159,9 @@ def _ignored() -> list[str]: finally: _restore_project( superproject, + git_super, subproject, project.name, - is_git, worktree_fully_patched, _ignored, ) @@ -217,17 +216,16 @@ def _apply_review( def _restore_project( superproject: SuperProject, + git_super: GitSuperProject | None, subproject: SubProject, project_name: str, - is_git: bool, worktree_fully_patched: bool, ignored_callback: Callable[[], list[str]], ) -> None: """Restore the project to the fully-patched state and un-stage the index.""" if not worktree_fully_patched: - if is_git: - assert isinstance(superproject, GitSuperProject) - superproject.restore_worktree(subproject.local_path) + if git_super is not None: + git_super.restore_worktree(subproject.local_path) else: subproject.update( force=True, @@ -236,9 +234,8 @@ def _restore_project( eol_preferences_callback=superproject.eol_preferences, ) subproject.apply_patches() - if is_git: - assert isinstance(superproject, GitSuperProject) - superproject.restore_staged(subproject.local_path) + if git_super is not None: + git_super.restore_staged(subproject.local_path) logger.print_info_line(project_name, "restored") From 733a6a6b195e2594a7f8c426611e56ad219327b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:55:39 +0000 Subject: [PATCH 12/22] Restore dfetch_data.yaml metadata after review-patch exits update(patch_count=0) writes patch: '' to the metadata file. The restore path fixes the working tree but apply_patches() has no metadata-writing logic, leaving the metadata file missing the patch list. Fix: snapshot the metadata file bytes before the clean-upstream fetch and write them back in the finally block after restore completes. Add a BDD step "the metadata of ... lists patch ..." and assert it in both Git and SVN review-patch scenarios to pin the invariant. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 3 +++ features/review-patch-in-git.feature | 2 ++ features/review-patch-in-svn.feature | 2 ++ features/steps/generic_steps.py | 13 +++++++++++++ tests/test_review_patch.py | 4 ++++ 5 files changed, 24 insertions(+) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index 79d180bce..062ad2f83 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -33,6 +33,7 @@ import argparse from collections.abc import Callable +from pathlib import Path import dfetch.commands.command import dfetch.manifest.project @@ -131,6 +132,7 @@ def _ignored() -> list[str]: if not _can_review_project(superproject, subproject, project.name): return + saved_metadata = Path(subproject.metadata_path).read_bytes() total_patches = len(list(subproject.patch)) subproject.update( force=True, @@ -165,6 +167,7 @@ def _ignored() -> list[str]: worktree_fully_patched, _ignored, ) + Path(subproject.metadata_path).write_bytes(saved_metadata) def _can_review_project( diff --git a/features/review-patch-in-git.feature b/features/review-patch-in-git.feature index f78f2fdfd..c309f301d 100644 --- a/features/review-patch-in-git.feature +++ b/features/review-patch-in-git.feature @@ -50,6 +50,7 @@ Feature: Review patches in git """ Patched file for SomeProject.git """ + And the metadata of 'SomeProject' in 'MyProject' lists patch 'patches/SomeProject.patch' Scenario: Only the first N patches are applied with --count When I run "dfetch review-patch --count 0 SomeProject" in MyProject @@ -67,6 +68,7 @@ Feature: Review patches in git """ Patched file for SomeProject.git """ + And the metadata of 'SomeProject' in 'MyProject' lists patch 'patches/SomeProject.patch' Scenario: A warning is shown when no patch is defined in the manifest Given a fetched and committed MyProject with the manifest diff --git a/features/review-patch-in-svn.feature b/features/review-patch-in-svn.feature index fd2144004..4be1a6a70 100644 --- a/features/review-patch-in-svn.feature +++ b/features/review-patch-in-svn.feature @@ -47,6 +47,7 @@ Feature: Review patches in svn """ Patched file for SomeProject """ + And the metadata of 'SomeProject' in 'MySvnProject' lists patch 'patches/SomeProject.patch' Scenario: Only the first N patches are applied with --count When I run "dfetch review-patch --count 0 SomeProject" in MySvnProject @@ -66,3 +67,4 @@ Feature: Review patches in svn """ Patched file for SomeProject """ + And the metadata of 'SomeProject' in 'MySvnProject' lists patch 'patches/SomeProject.patch' diff --git a/features/steps/generic_steps.py b/features/steps/generic_steps.py index fac18aebe..be1419dc1 100644 --- a/features/steps/generic_steps.py +++ b/features/steps/generic_steps.py @@ -9,6 +9,8 @@ import pathlib import re import shutil + +import yaml from contextlib import contextmanager from itertools import zip_longest from typing import Iterable, List, Optional, Pattern, Tuple, Union @@ -380,6 +382,17 @@ def step_impl(context, name): check_file(name, context.text) +@then("the metadata of '{project}' in '{superproject}' lists patch '{patch}'") +def step_impl(_, project, superproject, patch): + metadata_path = os.path.join(superproject, project, ".dfetch_data.yaml") + with open(metadata_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + actual = data["dfetch"]["patch"] + if isinstance(actual, list): + actual = actual[0] if len(actual) == 1 else actual + assert actual == patch, f"Expected patch {patch!r}, got {actual!r}" + + @then("'{name}' exists") def step_impl(_, name): assert os.path.exists(name), f"Expected {name} to exist, but it didn't!" diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py index 42fd05994..ca2ca777d 100644 --- a/tests/test_review_patch.py +++ b/tests/test_review_patch.py @@ -4,6 +4,7 @@ # flake8: noqa import argparse +import tempfile from pathlib import Path from unittest.mock import ANY, Mock, call, patch @@ -41,6 +42,9 @@ def _make_subproject(patches=None, on_disk_version: str | None = "v1"): sub.patch = patches if patches is not None else _PATCH_FILES sub.local_path = "my_project" sub.on_disk_version.return_value = on_disk_version + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml", mode="wb") as f: + f.write(b"dfetch:\n patch: patches/first.patch\n") + sub.metadata_path = f.name return sub From 2dcaf527a502550a7b61819eb1c71dce0b220905 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:01:19 +0000 Subject: [PATCH 13/22] Replace metadata patch assertion with git status no-changes check Instead of asserting the patch field in dfetch_data.yaml directly, verify from the user's VCS perspective: git status --porcelain SomeProject must report nothing after review-patch exits, covering both the working tree files and the metadata file in one shot. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- features/review-patch-in-git.feature | 4 ++-- features/review-patch-in-svn.feature | 2 -- features/steps/generic_steps.py | 12 ------------ features/steps/git_steps.py | 11 ++++++++++- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/features/review-patch-in-git.feature b/features/review-patch-in-git.feature index c309f301d..fcc9b54ed 100644 --- a/features/review-patch-in-git.feature +++ b/features/review-patch-in-git.feature @@ -50,7 +50,7 @@ Feature: Review patches in git """ Patched file for SomeProject.git """ - And the metadata of 'SomeProject' in 'MyProject' lists patch 'patches/SomeProject.patch' + And the git superproject 'MyProject' reports no changes to 'SomeProject' Scenario: Only the first N patches are applied with --count When I run "dfetch review-patch --count 0 SomeProject" in MyProject @@ -68,7 +68,7 @@ Feature: Review patches in git """ Patched file for SomeProject.git """ - And the metadata of 'SomeProject' in 'MyProject' lists patch 'patches/SomeProject.patch' + And the git superproject 'MyProject' reports no changes to 'SomeProject' Scenario: A warning is shown when no patch is defined in the manifest Given a fetched and committed MyProject with the manifest diff --git a/features/review-patch-in-svn.feature b/features/review-patch-in-svn.feature index 4be1a6a70..fd2144004 100644 --- a/features/review-patch-in-svn.feature +++ b/features/review-patch-in-svn.feature @@ -47,7 +47,6 @@ Feature: Review patches in svn """ Patched file for SomeProject """ - And the metadata of 'SomeProject' in 'MySvnProject' lists patch 'patches/SomeProject.patch' Scenario: Only the first N patches are applied with --count When I run "dfetch review-patch --count 0 SomeProject" in MySvnProject @@ -67,4 +66,3 @@ Feature: Review patches in svn """ Patched file for SomeProject """ - And the metadata of 'SomeProject' in 'MySvnProject' lists patch 'patches/SomeProject.patch' diff --git a/features/steps/generic_steps.py b/features/steps/generic_steps.py index be1419dc1..4f7c63550 100644 --- a/features/steps/generic_steps.py +++ b/features/steps/generic_steps.py @@ -10,7 +10,6 @@ import re import shutil -import yaml from contextlib import contextmanager from itertools import zip_longest from typing import Iterable, List, Optional, Pattern, Tuple, Union @@ -382,17 +381,6 @@ def step_impl(context, name): check_file(name, context.text) -@then("the metadata of '{project}' in '{superproject}' lists patch '{patch}'") -def step_impl(_, project, superproject, patch): - metadata_path = os.path.join(superproject, project, ".dfetch_data.yaml") - with open(metadata_path, encoding="utf-8") as f: - data = yaml.safe_load(f) - actual = data["dfetch"]["patch"] - if isinstance(actual, list): - actual = actual[0] if len(actual) == 1 else actual - assert actual == patch, f"Expected patch {patch!r}, got {actual!r}" - - @then("'{name}' exists") def step_impl(_, name): assert os.path.exists(name), f"Expected {name} to exist, but it didn't!" diff --git a/features/steps/git_steps.py b/features/steps/git_steps.py index 12c293c0b..59aa568b3 100644 --- a/features/steps/git_steps.py +++ b/features/steps/git_steps.py @@ -7,7 +7,7 @@ import pathlib import subprocess -from behave import given, when # pylint: disable=no-name-in-module +from behave import given, then, when # pylint: disable=no-name-in-module from dfetch.util.util import in_directory from features.steps.generic_steps import ( @@ -256,3 +256,12 @@ def step_impl(context): generate_file(os.path.join(os.getcwd(), "002-diff.patch"), patch_file2) call_command(context, ["update"]) + + +@then("the git superproject '{superproject}' reports no changes to '{path}'") +def step_impl(_, superproject, path): + with in_directory(superproject): + result = subprocess.check_output( + ["git", "status", "--porcelain", path], text=True + ) + assert result.strip() == "", f"Unexpected changes in {path!r}:\n{result}" From f3f8150bd89331a7077da6ea5625f19d304baea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:03:57 +0000 Subject: [PATCH 14/22] Silence patch_ng logs during interactive TUI patch steps patch_ng logs "successfully patched N/M: file" via Python logging when Patch.apply() is called. In the TUI loop this output appears between frames, so Screen.draw()'s cursor-up count is wrong and the help line gets duplicated on each arrow keypress. Suppress the patch_ng logger to CRITICAL for the duration of each LEFT/RIGHT apply/reverse operation inside the TUI loop. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index 062ad2f83..dff07cf38 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -32,8 +32,11 @@ """ import argparse +import logging from collections.abc import Callable +from contextlib import contextmanager from pathlib import Path +from typing import Generator import dfetch.commands.command import dfetch.manifest.project @@ -262,6 +265,18 @@ def _draw_tui_frame( screen.draw(lines) +@contextmanager +def _silent_patch_ng() -> Generator[None, None, None]: + """Suppress patch_ng info logs so they don't corrupt the TUI frame.""" + patch_logger = logging.getLogger("patch_ng") + prev = patch_logger.level + patch_logger.setLevel(logging.CRITICAL) + try: + yield + finally: + patch_logger.setLevel(prev) + + def _apply_step( key: str, current: int, @@ -271,10 +286,12 @@ def _apply_step( ) -> tuple[int, bool]: """Handle one keypress; return (new_current, done).""" if key == "LEFT" and current > 0: - Patch.from_file(patches[current - 1]).reverse().apply(root=local_path) + with _silent_patch_ng(): + Patch.from_file(patches[current - 1]).reverse().apply(root=local_path) return current - 1, False if key == "RIGHT" and current < total: - Patch.from_file(patches[current]).apply(root=local_path) + with _silent_patch_ng(): + Patch.from_file(patches[current]).apply(root=local_path) return current + 1, False if key in ("ENTER", "ESC"): return current, True From 5a0dff54f529fcde087a52f45dc3e63c25459326 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:12:18 +0000 Subject: [PATCH 15/22] Clear TUI screen on patch failure before propagating the error If Patch.apply() or Patch.reverse().apply() raises RuntimeError during the interactive stepper, the TUI frame was left on screen while the error and 'restored' messages printed below it. Catch RuntimeError in _step_tui, clear the screen, then re-raise so the existing try/finally in _review_project still runs _restore_project (working tree and metadata are always restored), and _iter_projects logs the error under the project name as usual. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index dff07cf38..35a01928f 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -317,7 +317,11 @@ def _step_tui(patches: list[str], local_path: str, project_name: str) -> None: except KeyboardInterrupt: screen.clear() return - current, done = _apply_step(key, current, total, patches, local_path) + try: + current, done = _apply_step(key, current, total, patches, local_path) + except RuntimeError: + screen.clear() + raise if done: screen.clear() return From 89dd14a4b0a30f43271b1ebd8d9621c01926e139 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 13:00:03 +0000 Subject: [PATCH 16/22] Fix restore after patch failure by using git restore --source=HEAD The old restore path did restore_worktree (WT = staged = clean upstream) followed by apply_patches(), which would hit the same failing patch and raise again, leaving the index staged, the metadata corrupted, and the working tree in the wrong state. Replace with restore_from_head (git restore --source=HEAD --staged --worktree -- ) which atomically resets both the working tree and the index to the committed HEAD state without re-applying patches. This makes restore unconditionally safe regardless of patch health. Also nest the metadata write_bytes in its own try/finally so the dfetch_data.yaml snapshot is always restored even if _restore_project encounters an error (e.g. SVN path where apply_patches can still fail). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017zY8BoH65KBX6cz7Pm8aeF --- dfetch/commands/review_patch.py | 33 +++++++++++++++------------- dfetch/project/gitsuperproject.py | 4 ++++ dfetch/vcs/git.py | 8 +++++++ features/review-patch-in-git.feature | 2 -- tests/test_review_patch.py | 10 ++++----- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index 35a01928f..b2e118d6c 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -162,15 +162,17 @@ def _ignored() -> list[str]: subproject, project.name, chosen_count, interactive, info_msg ) finally: - _restore_project( - superproject, - git_super, - subproject, - project.name, - worktree_fully_patched, - _ignored, - ) - Path(subproject.metadata_path).write_bytes(saved_metadata) + try: + _restore_project( + superproject, + git_super, + subproject, + project.name, + worktree_fully_patched, + _ignored, + ) + finally: + Path(subproject.metadata_path).write_bytes(saved_metadata) def _can_review_project( @@ -229,19 +231,20 @@ def _restore_project( ignored_callback: Callable[[], list[str]], ) -> None: """Restore the project to the fully-patched state and un-stage the index.""" - if not worktree_fully_patched: - if git_super is not None: - git_super.restore_worktree(subproject.local_path) + if git_super is not None: + if worktree_fully_patched: + git_super.restore_staged(subproject.local_path) else: + git_super.restore_from_head(subproject.local_path) + else: + if not worktree_fully_patched: subproject.update( force=True, ignored_files_callback=ignored_callback, patch_count=0, eol_preferences_callback=superproject.eol_preferences, ) - subproject.apply_patches() - if git_super is not None: - git_super.restore_staged(subproject.local_path) + subproject.apply_patches() logger.print_info_line(project_name, "restored") diff --git a/dfetch/project/gitsuperproject.py b/dfetch/project/gitsuperproject.py index 652954ae6..0a5da09f0 100644 --- a/dfetch/project/gitsuperproject.py +++ b/dfetch/project/gitsuperproject.py @@ -63,6 +63,10 @@ def restore_worktree(self, path: str) -> None: """Restore working-tree files for path from the staged index.""" self._repo.restore_worktree(path) + def restore_from_head(self, path: str) -> None: + """Restore both working tree and index for path from HEAD.""" + self._repo.restore_from_head(path) + def get_username(self) -> str: """Get the username of the superproject VCS.""" username = self._repo.get_username() diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index badac9dd5..8620e4627 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -832,6 +832,14 @@ def restore_worktree(self, path: str) -> None: with in_directory(self._path): run_on_cmdline(logger, ["git", "restore", "--", path]) + def restore_from_head(self, path: str) -> None: + """Restore both working tree and index for path from HEAD.""" + with in_directory(self._path): + run_on_cmdline( + logger, + ["git", "restore", "--source=HEAD", "--staged", "--worktree", "--", path], + ) + def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> Patch: """Create a diff for untracked files.""" with in_directory(self._path): diff --git a/features/review-patch-in-git.feature b/features/review-patch-in-git.feature index fcc9b54ed..61dc66f4e 100644 --- a/features/review-patch-in-git.feature +++ b/features/review-patch-in-git.feature @@ -60,8 +60,6 @@ Feature: Review patches in git SomeProject: > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `git diff` to inspect - > Applying patch "patches/SomeProject.patch" - successfully patched 1/1: b'README.md' > restored """ And the patched 'MyProject/SomeProject/README.md' is diff --git a/tests/test_review_patch.py b/tests/test_review_patch.py index ca2ca777d..6adcc0d91 100644 --- a/tests/test_review_patch.py +++ b/tests/test_review_patch.py @@ -101,12 +101,10 @@ def test_review_count_1_uses_patch_count_1(): patch_count=0, eol_preferences_callback=ANY, ) - apply_calls = fake_sub.apply_patches.call_args_list - assert apply_calls[0] == call(1), "first apply must apply exactly 1 patch" - assert ( - apply_calls[1] == call() - ), "finally must restore all patches via apply_patches()" - fake_super.restore_worktree.assert_called_once_with("my_project") + fake_sub.apply_patches.assert_called_once_with(1) + fake_super.restore_from_head.assert_called_once_with("my_project") + fake_super.restore_worktree.assert_not_called() + fake_super.restore_staged.assert_not_called() # --------------------------------------------------------------------------- From c8a7fd894b43163f91447810ea036abed6a1e4f0 Mon Sep 17 00:00:00 2001 From: Ben Spoor <37540691+ben-edna@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:15:30 +0000 Subject: [PATCH 17/22] Fix pre-commit --- dfetch/commands/review_patch.py | 3 +-- dfetch/vcs/git.py | 10 +++++++++- features/steps/generic_steps.py | 1 - 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index b2e118d6c..ff5ac98e2 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -33,10 +33,9 @@ import argparse import logging -from collections.abc import Callable +from collections.abc import Callable, Generator from contextlib import contextmanager from pathlib import Path -from typing import Generator import dfetch.commands.command import dfetch.manifest.project diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 8620e4627..6b67eae09 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -837,7 +837,15 @@ def restore_from_head(self, path: str) -> None: with in_directory(self._path): run_on_cmdline( logger, - ["git", "restore", "--source=HEAD", "--staged", "--worktree", "--", path], + [ + "git", + "restore", + "--source=HEAD", + "--staged", + "--worktree", + "--", + path, + ], ) def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> Patch: diff --git a/features/steps/generic_steps.py b/features/steps/generic_steps.py index 4f7c63550..fac18aebe 100644 --- a/features/steps/generic_steps.py +++ b/features/steps/generic_steps.py @@ -9,7 +9,6 @@ import pathlib import re import shutil - from contextlib import contextmanager from itertools import zip_longest from typing import Iterable, List, Optional, Pattern, Tuple, Union From 8ae4fe6c6e3f0f58b06749d00be73c6936826c9d Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 23 Jun 2026 21:10:24 +0000 Subject: [PATCH 18/22] Improve docs --- doc/howto/patching.rst | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/doc/howto/patching.rst b/doc/howto/patching.rst index c050648ab..12367860b 100644 --- a/doc/howto/patching.rst +++ b/doc/howto/patching.rst @@ -282,18 +282,26 @@ Reviewing a patch ----------------- When you want to understand what a patch (or a set of patches) actually -contributes to a vendored project, ``review-patch`` sets up your working tree -so that any diff-aware editor shows the answer immediately. +contributes to a vendored project, run: .. code-block:: console $ dfetch review-patch some-project -In a Git superproject this stages the clean upstream source in the git index -and applies the selected patches to the working tree. Running ``git diff`` -inside the project directory then shows exactly what the patches change. When -you are done reviewing, the command restores the original state — both the -working tree and the git index are left clean. +*Dfetch* puts the clean upstream source in the git index and applies the +patches to the working tree. You can now see exactly what the patches change +using any diff tool you prefer — for example: + +.. code-block:: console + + $ git diff some-project/ + +Or open the project in VS Code and browse the **Changes** view in the Source +Control panel. (The **Staged Changes** view shows something different and +unrelated to your patches — use **Changes**.) + +When you are done, press **Enter** and *dfetch* restores everything to its +original state. **Reviewing a specific number of patches** From 25f05505115f75226da92600aba79ca98229bf8f Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 24 Jun 2026 05:31:44 +0000 Subject: [PATCH 19/22] review comments --- dfetch/commands/review_patch.py | 20 ++-- dfetch/project/subproject.py | 2 + features/steps/git_steps.py | 6 +- plan.md | 174 ++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 plan.md diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/review_patch.py index ff5ac98e2..3055f31d8 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/review_patch.py @@ -136,16 +136,6 @@ def _ignored() -> list[str]: saved_metadata = Path(subproject.metadata_path).read_bytes() total_patches = len(list(subproject.patch)) - subproject.update( - force=True, - ignored_files_callback=_ignored, - patch_count=0, - eol_preferences_callback=superproject.eol_preferences, - ) - - if git_super is not None: - git_super.add_path(subproject.local_path) - chosen_count = count if count is not None else -1 effective = ( total_patches if chosen_count == -1 else min(chosen_count, total_patches) @@ -157,6 +147,14 @@ def _ignored() -> list[str]: ) worktree_fully_patched = False try: + subproject.update( + force=True, + ignored_files_callback=_ignored, + patch_count=0, + eol_preferences_callback=superproject.eol_preferences, + ) + if git_super is not None: + git_super.add_path(subproject.local_path) worktree_fully_patched = _apply_review( subproject, project.name, chosen_count, interactive, info_msg ) @@ -318,7 +316,7 @@ def _step_tui(patches: list[str], local_path: str, project_name: str) -> None: key = read_key() except KeyboardInterrupt: screen.clear() - return + raise try: current, done = _apply_step(key, current, total, patches, local_path) except RuntimeError: diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index de5995802..ab1106f96 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -93,6 +93,8 @@ def apply_patches(self, count: int = -1) -> None: Args: count: Number of patches to apply (-1 means all). """ + if count < -1: + raise ValueError(f"count must be -1 (all) or >= 0, got {count}") self._apply_patches(count) def update( diff --git a/features/steps/git_steps.py b/features/steps/git_steps.py index 59aa568b3..cb725a26e 100644 --- a/features/steps/git_steps.py +++ b/features/steps/git_steps.py @@ -1,13 +1,13 @@ """Steps for features tests.""" -# pylint: disable=function-redefined, missing-function-docstring, import-error, not-callable +# pylint: disable=function-redefined, missing-function-docstring, import-error, not-callable, no-name-in-module # pyright: reportRedeclaration=false, reportAttributeAccessIssue=false, reportCallIssue=false import os import pathlib import subprocess -from behave import given, then, when # pylint: disable=no-name-in-module +from behave import given, then, when from dfetch.util.util import in_directory from features.steps.generic_steps import ( @@ -262,6 +262,6 @@ def step_impl(context): def step_impl(_, superproject, path): with in_directory(superproject): result = subprocess.check_output( - ["git", "status", "--porcelain", path], text=True + ["git", "status", "--porcelain", "--", path], text=True ) assert result.strip() == "", f"Unexpected changes in {path!r}:\n{result}" diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..62a8af9c3 --- /dev/null +++ b/plan.md @@ -0,0 +1,174 @@ +# Plan: Combined multi-project review-patch + +## Context + +`review-patch` currently processes projects one at a time, each with its own +pause/restore cycle. When a user wants to see several vendored patch stacks at +once (e.g. in VS Code's Changes view), they must review them sequentially. + +The fix: when exactly one project resolves, keep the current per-project +behaviour unchanged. When two or more projects resolve: +- **Non-interactive** (default): validate all → fetch+stage all → apply per-project + patch counts → single pause → restore all. +- **`--interactive`**: launch a combined tree TUI where the user steps each + project's patch stack independently before pressing Enter to restore. + +Per-project patch counts in non-interactive combined mode use an optional +`project:N` suffix on each project argument. + +--- + +## CLI behaviour + +```bash +# Single project — unchanged behaviour +dfetch review-patch proj-a +dfetch review-patch --count 2 proj-a +dfetch review-patch --interactive proj-a + +# Single project with :N shorthand (equivalent to --count N) +dfetch review-patch proj-a:2 + +# Combined non-interactive (2+ projects, pause once) +dfetch review-patch # all projects, all patches +dfetch review-patch proj-a proj-b # two projects, all patches +dfetch review-patch proj-a:0 proj-b proj-c:1 # 0 / all / 1 patches + +# Combined interactive tree TUI +dfetch review-patch --interactive # all projects, tree TUI +dfetch review-patch --interactive proj-a proj-b +``` + +**Rejected combinations** (raise `RuntimeError`): +- `--count` with 2+ projects → `"--count is for single-project use; use project:N syntax for per-project counts"` +- `--count` and `:N` suffix on same project → `"use either --count or project:N, not both"` + +--- + +## Parsing `project:N` syntax + +Add a helper at module level: + +```python +def _parse_project_spec(spec: str) -> tuple[str, int | None]: + """Split 'name:N' into (name, N); bare name returns (name, None).""" + if ":" in spec: + name, _, tail = spec.rpartition(":") + try: + return name, int(tail) + except ValueError: + raise RuntimeError( + f"invalid project spec {spec!r}; expected name or name:N" + ) + return spec, None +``` + +--- + +## Dispatch in `__call__` + +```python +# Parse project:N specs +parsed = [_parse_project_spec(s) for s in args.projects] +project_names = [name for name, _ in parsed] +per_project_counts: dict[str, int] = { + name: n for name, n in parsed if n is not None +} + +# Reject --count + :N on the same project +if args.count is not None: + overlap = [n for n in per_project_counts if n in project_names] + if overlap: + raise RuntimeError(f"use either --count or project:N, not both (conflicts: {overlap})") + +selected = list(superproject.manifest.selected_projects(project_names)) + +if len(selected) <= 1: + count = args.count + if count is None and selected and selected[0].name in per_project_counts: + count = per_project_counts[selected[0].name] + self._iter_projects(superproject, project_names, + lambda project: self._review_project(superproject, project, count, args.interactive)) +else: + if args.count is not None: + raise RuntimeError("--count is for single-project use; use project:N syntax for per-project counts") + with in_directory(superproject.root_directory): + _review_projects_combined(superproject, selected, per_project_counts, args.interactive) +``` + +Add `from dfetch.util.util import in_directory` to imports. + +--- + +## `_ProjectState` dataclass + +```python +@dataclasses.dataclass +class _ProjectState: + name: str + local_path: str + patches: list[str] + current: int = 0 + + @property + def fully_patched(self) -> bool: + return self.current == len(self.patches) +``` + +--- + +## `_review_projects_combined` + +1. **Validate** — `_can_review_project` for each; collect `reviewable`. +2. **Fetch + stage all** — `update(patch_count=0)` + `add_path` per project; append to `staged`. +3. **Apply / review**: + - `interactive=True`: `_step_tui_multi(states)` (TUI drives apply/unapply) + - `interactive=False`: `apply_patches(per_project_counts.get(name, -1))` per project, log, `input("Press Enter to restore...")` if TTY +4. **Restore all** in `finally`: loop `staged`, call `_restore_project(..., state.fully_patched, ...)`, restore metadata bytes. + +--- + +## Combined interactive tree TUI + +### Layout + +``` + ← → step ↑ ↓ switch project Enter restore and exit Ctrl-C abort + ───────────────────────────────────────────────────────────────────────── + proj-a [2/3 patches applied] + [x] patches/00-fix.patch + [x] patches/01-improve.patch + [ ] patches/02-final.patch +> proj-b [0/2 patches applied] + [ ] patches/00-base.patch + [ ] patches/01-extra.patch +``` + +### `_step_tui_multi(states)` + +- `UP` / `DOWN`: move `focused` index between projects +- `LEFT` / `RIGHT` / `ENTER` / `ESC`: delegate to existing `_apply_step` with focused project's state +- `Ctrl-C`: `screen.clear(); return` + +--- + +## Files to change + +| File | Change | +|------|--------| +| `dfetch/commands/review_patch.py` | Add `dataclasses` + `in_directory` imports; `_ProjectState`; `_parse_project_spec`; modify `__call__`; add `_review_projects_combined`, `_draw_tui_tree`, `_step_tui_multi` | +| `tests/test_review_patch.py` | Unit tests for combined path | +| `features/review-patch-in-git.feature` | BDD scenario: combined non-interactive pass | +| `doc/howto/patching.rst` | Document automatic combined mode + `project:N` syntax | +| `CHANGELOG.rst` | One bullet | + +--- + +## New unit tests + +- `test_combined_two_projects_all_patches`: `add_path` × 2, `apply_patches(-1)` × 2, `restore_staged` × 2 +- `test_combined_per_project_counts`: counts 0/−1/1, verify correct `apply_patches` args and restore strategies +- `test_combined_count_flag_raises`: `--count 1` + 2 projects → RuntimeError +- `test_combined_count_and_suffix_raises`: `--count 1 proj:2` → RuntimeError +- `test_single_project_suffix_becomes_count`: `proj:2` alone → `_review_project` with `count=2` +- `test_combined_interactive_launches_tui`: `--interactive` + 2 projects → `_step_tui_multi` called From b5a57a941cb816eaf2f79efb1348b723f341908d Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 24 Jun 2026 05:39:14 +0000 Subject: [PATCH 20/22] Rename to replay-patches --- CHANGELOG.rst | 2 +- dfetch/__main__.py | 4 +- .../{review_patch.py => replay_patches.py} | 26 +++---- ...{review-patch.cast => replay-patches.cast} | 0 .../{review-patch.gif => replay-patches.gif} | Bin doc/howto/patching.rst | 16 ++--- ....feature => replay-patches-in-git.feature} | 14 ++-- ....feature => replay-patches-in-svn.feature} | 14 ++-- plan.md | 28 ++++---- security/tm_usage.py | 4 +- ...review_patch.py => test_replay_patches.py} | 68 ++++++++++-------- 11 files changed, 91 insertions(+), 85 deletions(-) rename dfetch/commands/{review_patch.py => replay_patches.py} (93%) rename doc/asciicasts/{review-patch.cast => replay-patches.cast} (100%) rename doc/asciicasts/{review-patch.gif => replay-patches.gif} (100%) rename features/{review-patch-in-git.feature => replay-patches-in-git.feature} (90%) rename features/{review-patch-in-svn.feature => replay-patches-in-svn.feature} (81%) rename tests/{test_review_patch.py => test_replay_patches.py} (73%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5afe7220c..a4608b904 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,7 +12,7 @@ Release 0.14.2 (released 2026-06-21) Release 0.14.1 (released 2026-06-19) ==================================== -* Add ``review-patch`` command to inspect patch contributions interactively without making permanent changes (#review-patch) +* Add ``replay-patches`` command to inspect patch contributions interactively without making permanent changes (#review-patch) * Implement C-043: add ``pip-audit`` OSV gate to the release workflow * Add CRA Compliance: OSCAL 1.2.2 Component Definition * Fix ``dfetch import`` mangling the namespace of a generic VCS URL whose path contains ``.git`` (#1268) diff --git a/dfetch/__main__.py b/dfetch/__main__.py index 9b1ab9d21..474b9b2fe 100644 --- a/dfetch/__main__.py +++ b/dfetch/__main__.py @@ -18,8 +18,8 @@ import dfetch.commands.import_ import dfetch.commands.init import dfetch.commands.remove +import dfetch.commands.replay_patches import dfetch.commands.report -import dfetch.commands.review_patch import dfetch.commands.update import dfetch.commands.update_patch import dfetch.commands.validate @@ -57,7 +57,7 @@ def create_parser() -> argparse.ArgumentParser: dfetch.commands.remove.Remove.create_menu(subparsers) dfetch.commands.report.Report.create_menu(subparsers) dfetch.commands.update.Update.create_menu(subparsers) - dfetch.commands.review_patch.ReviewPatch.create_menu(subparsers) + dfetch.commands.replay_patches.ReplayPatches.create_menu(subparsers) dfetch.commands.update_patch.UpdatePatch.create_menu(subparsers) dfetch.commands.validate.Validate.create_menu(subparsers) diff --git a/dfetch/commands/review_patch.py b/dfetch/commands/replay_patches.py similarity index 93% rename from dfetch/commands/review_patch.py rename to dfetch/commands/replay_patches.py index 3055f31d8..f2a6ed58b 100644 --- a/dfetch/commands/review_patch.py +++ b/dfetch/commands/replay_patches.py @@ -1,7 +1,7 @@ -"""Reviewing patches interactively. +"""Replaying patches interactively. *Dfetch* allows you to keep local changes to external projects in the form of -patch files. The ``review-patch`` command lets you inspect what each patch +patch files. The ``replay-patches`` command lets you inspect what each patch contributes by staging the clean upstream source in the git index and optionally stepping the working tree through each patch in turn. @@ -9,11 +9,11 @@ diff-aware editor will immediately show what the applied patches change relative to the upstream source — no manual setup needed. -Run without arguments to review all patches at once: +Run without arguments to replay all patches at once: .. code-block:: console - $ dfetch review-patch some-project + $ dfetch replay-patches some-project Use ``--count`` to stop at a specific patch number, or ``--interactive`` to step through the stack with the ← and → arrow keys. In either case the @@ -24,11 +24,11 @@ .. tab:: Git - .. scenario-include:: ../features/review-patch-in-git.feature + .. scenario-include:: ../features/replay-patches-in-git.feature .. tab:: SVN - .. scenario-include:: ../features/review-patch-in-svn.feature + .. scenario-include:: ../features/replay-patches-in-svn.feature """ import argparse @@ -50,10 +50,10 @@ logger = get_logger(__name__) -class ReviewPatch(dfetch.commands.command.Command): - """Review what patches contribute to a project. +class ReplayPatches(dfetch.commands.command.Command): + """Replay what patches contribute to a project. - The ``review-patch`` command stages the clean upstream source in the git + The ``replay-patches`` command stages the clean upstream source in the git index and applies the selected patches to the working tree, so ``git diff`` shows exactly what the patches change relative to upstream. Use ``--interactive`` to step through the stack patch-by-patch with ← and →. @@ -62,8 +62,8 @@ class ReviewPatch(dfetch.commands.command.Command): @staticmethod def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None: - """Add the menu for the review-patch action.""" - parser = dfetch.commands.command.Command.parser(subparsers, ReviewPatch) + """Add the menu for the replay-patches action.""" + parser = dfetch.commands.command.Command.parser(subparsers, ReplayPatches) parser.add_argument( "projects", metavar="", @@ -89,7 +89,7 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None ) def __call__(self, args: argparse.Namespace) -> None: - """Perform the review patch.""" + """Perform the replay patches.""" if args.count is not None and args.count < 0: raise RuntimeError("--count must be >= 0") @@ -102,7 +102,7 @@ def __call__(self, args: argparse.Namespace) -> None: ) if not isinstance(superproject, GitSuperProject): logger.warning( - "review-patch has limited support in SVN superprojects" + "replay-patches has limited support in SVN superprojects" " (no staging area — use `svn diff` to inspect changes)" ) diff --git a/doc/asciicasts/review-patch.cast b/doc/asciicasts/replay-patches.cast similarity index 100% rename from doc/asciicasts/review-patch.cast rename to doc/asciicasts/replay-patches.cast diff --git a/doc/asciicasts/review-patch.gif b/doc/asciicasts/replay-patches.gif similarity index 100% rename from doc/asciicasts/review-patch.gif rename to doc/asciicasts/replay-patches.gif diff --git a/doc/howto/patching.rst b/doc/howto/patching.rst index 12367860b..9f3bad4a5 100644 --- a/doc/howto/patching.rst +++ b/doc/howto/patching.rst @@ -278,7 +278,7 @@ files, then use ``dfetch update-patch`` to record the resolved state: .. _patching-review: -Reviewing a patch +Replaying patches ----------------- When you want to understand what a patch (or a set of patches) actually @@ -286,7 +286,7 @@ contributes to a vendored project, run: .. code-block:: console - $ dfetch review-patch some-project + $ dfetch replay-patches some-project *Dfetch* puts the clean upstream source in the git index and applies the patches to the working tree. You can now see exactly what the patches change @@ -303,14 +303,14 @@ unrelated to your patches — use **Changes**.) When you are done, press **Enter** and *dfetch* restores everything to its original state. -**Reviewing a specific number of patches** +**Replaying a specific number of patches** Use ``--count`` to stop at a particular patch in the stack. For example, to see only what the first patch contributes, with the rest still un-applied: .. code-block:: console - $ dfetch review-patch --count 1 some-project + $ dfetch replay-patches --count 1 some-project **Stepping through the stack interactively** @@ -320,21 +320,21 @@ so your editor always reflects the current position in the stack: .. code-block:: console - $ dfetch review-patch --interactive some-project + $ dfetch replay-patches --interactive some-project Press **Enter** to finish and restore the original state. -.. asciinema:: ../asciicasts/review-patch.cast +.. asciinema:: ../asciicasts/replay-patches.cast .. tabs:: .. tab:: Git - .. scenario-include:: ../features/review-patch-in-git.feature + .. scenario-include:: ../features/replay-patches-in-git.feature .. tab:: SVN - .. scenario-include:: ../features/review-patch-in-svn.feature + .. scenario-include:: ../features/replay-patches-in-svn.feature .. _patching-upstream: diff --git a/features/review-patch-in-git.feature b/features/replay-patches-in-git.feature similarity index 90% rename from features/review-patch-in-git.feature rename to features/replay-patches-in-git.feature index 61dc66f4e..3ba2e4555 100644 --- a/features/review-patch-in-git.feature +++ b/features/replay-patches-in-git.feature @@ -1,10 +1,10 @@ -@review-patch -Feature: Review patches in git +@replay-patches +Feature: Replay patches in git When working with external projects that have patch files, it is useful to be able to inspect what a patch (or a set of patches) contributes to the project without permanently modifying the working tree. *Dfetch* provides - the ``review-patch`` command for this purpose. + the ``replay-patches`` command for this purpose. The command stages the clean upstream source in the git index and applies the selected patches to the working tree. Running ``git diff`` inside the @@ -35,7 +35,7 @@ Feature: Review patches in git """ Scenario: All patches are set up for review and state is restored afterwards - When I run "dfetch review-patch SomeProject" in MyProject + When I run "dfetch replay-patches SomeProject" in MyProject Then the output shows """ Dfetch (0.14.0) @@ -53,7 +53,7 @@ Feature: Review patches in git And the git superproject 'MyProject' reports no changes to 'SomeProject' Scenario: Only the first N patches are applied with --count - When I run "dfetch review-patch --count 0 SomeProject" in MyProject + When I run "dfetch replay-patches --count 0 SomeProject" in MyProject Then the output shows """ Dfetch (0.14.0) @@ -77,7 +77,7 @@ Feature: Review patches in git - name: SomeProject url: some-remote-server/SomeProject.git """ - When I run "dfetch review-patch SomeProject" in MyProject + When I run "dfetch replay-patches SomeProject" in MyProject Then the output shows """ Dfetch (0.14.0) @@ -87,7 +87,7 @@ Feature: Review patches in git Scenario: A warning is shown when the project has uncommitted local changes Given "SomeProject/README.md" in MyProject is changed locally - When I run "dfetch review-patch SomeProject" in MyProject + When I run "dfetch replay-patches SomeProject" in MyProject Then the output shows """ Dfetch (0.14.0) diff --git a/features/review-patch-in-svn.feature b/features/replay-patches-in-svn.feature similarity index 81% rename from features/review-patch-in-svn.feature rename to features/replay-patches-in-svn.feature index fd2144004..90cc31214 100644 --- a/features/review-patch-in-svn.feature +++ b/features/replay-patches-in-svn.feature @@ -1,8 +1,8 @@ -@review-patch -Feature: Review patches in svn +@replay-patches +Feature: Replay patches in svn When working with external projects that have patch files inside an SVN - superproject, the ``review-patch`` command allows the user to inspect what + superproject, the ``replay-patches`` command lets the user inspect what a patch contributes. Because SVN has no staging area, the command cannot use the index trick available in Git; it simply sets the working copy to the requested patch state and restores the original state afterwards. @@ -31,11 +31,11 @@ Feature: Review patches in svn """ Scenario: All patches are set up for review and state is restored afterwards - When I run "dfetch review-patch SomeProject" in MySvnProject + When I run "dfetch replay-patches SomeProject" in MySvnProject Then the output shows """ Dfetch (0.14.0) - review-patch has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) + replay-patches has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) SomeProject: > Fetched trunk - 1 > Applying patch "patches/SomeProject.patch" @@ -49,11 +49,11 @@ Feature: Review patches in svn """ Scenario: Only the first N patches are applied with --count - When I run "dfetch review-patch --count 0 SomeProject" in MySvnProject + When I run "dfetch replay-patches --count 0 SomeProject" in MySvnProject Then the output shows """ Dfetch (0.14.0) - review-patch has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) + replay-patches has limited support in SVN superprojects (no staging area — use `svn diff` to inspect changes) SomeProject: > Fetched trunk - 1 > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `svn diff` to inspect diff --git a/plan.md b/plan.md index 62a8af9c3..c544fa853 100644 --- a/plan.md +++ b/plan.md @@ -1,8 +1,8 @@ -# Plan: Combined multi-project review-patch +# Plan: Combined multi-project replay-patches ## Context -`review-patch` currently processes projects one at a time, each with its own +`replay-patches` currently processes projects one at a time, each with its own pause/restore cycle. When a user wants to see several vendored patch stacks at once (e.g. in VS Code's Changes view), they must review them sequentially. @@ -22,21 +22,21 @@ Per-project patch counts in non-interactive combined mode use an optional ```bash # Single project — unchanged behaviour -dfetch review-patch proj-a -dfetch review-patch --count 2 proj-a -dfetch review-patch --interactive proj-a +dfetch replay-patches proj-a +dfetch replay-patches --count 2 proj-a +dfetch replay-patches --interactive proj-a # Single project with :N shorthand (equivalent to --count N) -dfetch review-patch proj-a:2 +dfetch replay-patches proj-a:2 # Combined non-interactive (2+ projects, pause once) -dfetch review-patch # all projects, all patches -dfetch review-patch proj-a proj-b # two projects, all patches -dfetch review-patch proj-a:0 proj-b proj-c:1 # 0 / all / 1 patches +dfetch replay-patches # all projects, all patches +dfetch replay-patches proj-a proj-b # two projects, all patches +dfetch replay-patches proj-a:0 proj-b proj-c:1 # 0 / all / 1 patches # Combined interactive tree TUI -dfetch review-patch --interactive # all projects, tree TUI -dfetch review-patch --interactive proj-a proj-b +dfetch replay-patches --interactive # all projects, tree TUI +dfetch replay-patches --interactive proj-a proj-b ``` **Rejected combinations** (raise `RuntimeError`): @@ -156,9 +156,9 @@ class _ProjectState: | File | Change | |------|--------| -| `dfetch/commands/review_patch.py` | Add `dataclasses` + `in_directory` imports; `_ProjectState`; `_parse_project_spec`; modify `__call__`; add `_review_projects_combined`, `_draw_tui_tree`, `_step_tui_multi` | -| `tests/test_review_patch.py` | Unit tests for combined path | -| `features/review-patch-in-git.feature` | BDD scenario: combined non-interactive pass | +| `dfetch/commands/replay_patches.py` | Add `dataclasses` + `in_directory` imports; `_ProjectState`; `_parse_project_spec`; modify `__call__`; add `_review_projects_combined`, `_draw_tui_tree`, `_step_tui_multi` | +| `tests/test_replay_patches.py` | Unit tests for combined path | +| `features/replay-patches-in-git.feature` | BDD scenario: combined non-interactive pass | | `doc/howto/patching.rst` | Document automatic combined mode + `project:N` syntax | | `CHANGELOG.rst` | One bullet | diff --git a/security/tm_usage.py b/security/tm_usage.py index 013e0cf9e..ca40c0a7c 100644 --- a/security/tm_usage.py +++ b/security/tm_usage.py @@ -164,11 +164,11 @@ def _make_usage_processes(b_dev: Boundary) -> tuple[Process, Process]: dfetch_cli.classification = Classification.RESTRICTED dfetch_cli.description = ( "Python CLI entry point dispatching to: update, check, diff, add, remove, " - "update-patch, format-patch, review-patch, freeze, import, init, report, " + "update-patch, format-patch, replay-patches, freeze, import, init, report, " "validate, environment. " "Invokes Git and SVN as subprocesses (``shell=False``, list args). " "Extracts archives with decompression-bomb limits and path-traversal checks. " - "``review-patch`` transiently stages the clean upstream in the git index " + "``replay-patches`` transiently stages the clean upstream in the git index " "(``git add -- ``) and restores it on exit (``git restore --staged -- ``); " "interruption before the finally block completes may leave the index temporarily " "modified, which is visible to concurrent git operations in the same working tree." diff --git a/tests/test_review_patch.py b/tests/test_replay_patches.py similarity index 73% rename from tests/test_review_patch.py rename to tests/test_replay_patches.py index 6adcc0d91..94f54edd1 100644 --- a/tests/test_review_patch.py +++ b/tests/test_replay_patches.py @@ -1,4 +1,4 @@ -"""Test the review-patch command.""" +"""Test the replay-patches command.""" # mypy: ignore-errors # flake8: noqa @@ -10,7 +10,7 @@ import pytest -from dfetch.commands.review_patch import ReviewPatch +from dfetch.commands.replay_patches import ReplayPatches from dfetch.project.gitsuperproject import GitSuperProject from dfetch.project.superproject import NoVcsSuperProject from tests.manifest_mock import mock_manifest @@ -54,18 +54,19 @@ def _make_subproject(patches=None, on_disk_version: str | None = "v1"): def test_review_all_patches_calls_update_add_path_update(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject() with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): with patch("dfetch.commands.command.in_directory"): with patch( - "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + "dfetch.commands.replay_patches.create_sub_project", + return_value=fake_sub, ): - with patch("dfetch.commands.review_patch.is_tty", return_value=False): + with patch("dfetch.commands.replay_patches.is_tty", return_value=False): cmd(_make_args()) fake_sub.update.assert_called_once_with( @@ -81,18 +82,19 @@ def test_review_all_patches_calls_update_add_path_update(): def test_review_count_1_uses_patch_count_1(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject() with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): with patch("dfetch.commands.command.in_directory"): with patch( - "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + "dfetch.commands.replay_patches.create_sub_project", + return_value=fake_sub, ): - with patch("dfetch.commands.review_patch.is_tty", return_value=False): + with patch("dfetch.commands.replay_patches.is_tty", return_value=False): cmd(_make_args(count=1)) fake_sub.update.assert_called_once_with( @@ -113,19 +115,20 @@ def test_review_count_1_uses_patch_count_1(): def test_svn_superproject_warns_and_skips_staging(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = _make_superproject(is_git=False) # not GitSuperProject fake_sub = _make_subproject() with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): with patch("dfetch.commands.command.in_directory"): with patch( - "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + "dfetch.commands.replay_patches.create_sub_project", + return_value=fake_sub, ): - with patch("dfetch.commands.review_patch.is_tty", return_value=False): - with patch("dfetch.commands.review_patch.logger") as mock_log: + with patch("dfetch.commands.replay_patches.is_tty", return_value=False): + with patch("dfetch.commands.replay_patches.logger") as mock_log: cmd(_make_args()) mock_log.warning.assert_called_once() @@ -146,16 +149,17 @@ def test_svn_superproject_warns_and_skips_staging(): def test_no_patches_logs_warning_and_skips(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject(patches=[]) with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): with patch("dfetch.commands.command.in_directory"): with patch( - "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + "dfetch.commands.replay_patches.create_sub_project", + return_value=fake_sub, ): cmd(_make_args()) @@ -164,16 +168,17 @@ def test_no_patches_logs_warning_and_skips(): def test_never_fetched_logs_warning_and_skips(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject(on_disk_version=None) with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): with patch("dfetch.commands.command.in_directory"): with patch( - "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + "dfetch.commands.replay_patches.create_sub_project", + return_value=fake_sub, ): cmd(_make_args()) @@ -182,16 +187,17 @@ def test_never_fetched_logs_warning_and_skips(): def test_local_changes_logs_warning_and_skips(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = _make_superproject(is_git=True, has_local_changes=True) fake_sub = _make_subproject() with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): with patch("dfetch.commands.command.in_directory"): with patch( - "dfetch.commands.review_patch.create_sub_project", return_value=fake_sub + "dfetch.commands.replay_patches.create_sub_project", + return_value=fake_sub, ): cmd(_make_args()) @@ -205,34 +211,34 @@ def test_local_changes_logs_warning_and_skips(): def test_no_vcs_superproject_raises(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = Mock(spec=NoVcsSuperProject) with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): with pytest.raises(RuntimeError): cmd(_make_args()) def test_interactive_without_tty_raises(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): - with patch("dfetch.commands.review_patch.is_tty", return_value=False): + with patch("dfetch.commands.replay_patches.is_tty", return_value=False): with pytest.raises(RuntimeError, match="interactive"): cmd(_make_args(interactive=True)) def test_negative_count_raises(): - cmd = ReviewPatch() + cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) with patch( - "dfetch.commands.review_patch.create_super_project", return_value=fake_super + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super ): with pytest.raises(RuntimeError, match="--count must be >= 0"): cmd(_make_args(count=-1)) From c4c965e1835c0337885c49a9fae64e7540e614a9 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 24 Jun 2026 05:58:10 +0000 Subject: [PATCH 21/22] Add combined mode --- CHANGELOG.rst | 1 + dfetch/commands/replay_patches.py | 301 +++++++++++++++++++++++-- doc/howto/patching.rst | 21 +- features/replay-patches-in-git.feature | 52 +++++ tests/test_replay_patches.py | 146 ++++++++++++ 5 files changed, 499 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a4608b904..7eb65d559 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,7 @@ Release 0.14.1 (released 2026-06-19) ==================================== * Add ``replay-patches`` command to inspect patch contributions interactively without making permanent changes (#review-patch) +* Add combined multi-project mode to ``replay-patches``: stage all projects at once with optional per-project ``name:N`` patch counts (#replay-patches-multi) * Implement C-043: add ``pip-audit`` OSV gate to the release workflow * Add CRA Compliance: OSCAL 1.2.2 Component Definition * Fix ``dfetch import`` mangling the namespace of a generic VCS URL whose path contains ``.git`` (#1268) diff --git a/dfetch/commands/replay_patches.py b/dfetch/commands/replay_patches.py index f2a6ed58b..13aa920ab 100644 --- a/dfetch/commands/replay_patches.py +++ b/dfetch/commands/replay_patches.py @@ -32,6 +32,7 @@ """ import argparse +import dataclasses import logging from collections.abc import Callable, Generator from contextlib import contextmanager @@ -45,11 +46,71 @@ from dfetch.project.subproject import SubProject from dfetch.project.superproject import NoVcsSuperProject, SuperProject from dfetch.terminal import BOLD, DIM, RESET, Screen, is_tty, read_key +from dfetch.util.util import in_directory from dfetch.vcs.patch import Patch logger = get_logger(__name__) +@dataclasses.dataclass +class _ProjectState: + """Tracks the current patch position for one project during a combined review.""" + + name: str + local_path: str + patches: list[str] + current: int = 0 + + @property + def fully_patched(self) -> bool: + """Return True when all patches have been applied.""" + return self.current == len(self.patches) + + +def _parse_project_spec(spec: str) -> tuple[str, int | None]: + """Split 'name:N' into (name, N); a bare name returns (name, None).""" + if ":" not in spec: + return spec, None + name, _, tail = spec.rpartition(":") + try: + return name, int(tail) + except ValueError as exc: + raise RuntimeError( + f"invalid project spec {spec!r}; expected name or name:N" + ) from exc + + +def _validate_superproject(superproject: SuperProject) -> None: + if isinstance(superproject, NoVcsSuperProject): + raise RuntimeError( + "The project containing the manifest is not under version control," + " reviewing patches is not supported" + ) + if not isinstance(superproject, GitSuperProject): + logger.warning( + "replay-patches has limited support in SVN superprojects" + " (no staging area — use `svn diff` to inspect changes)" + ) + + +def _check_count_conflicts( + count: int | None, per_project_counts: dict[str, int] +) -> None: + if count is not None and per_project_counts: + raise RuntimeError("use either --count or project:N, not both") + + +def _effective_count( + count: int | None, + selected: list[dfetch.manifest.project.ProjectEntry], + per_project_counts: dict[str, int], +) -> int | None: + """Return the effective patch count for single-project mode.""" + if count is not None or not selected: + return count + return per_project_counts.get(selected[0].name) + + class ReplayPatches(dfetch.commands.command.Command): """Replay what patches contribute to a project. @@ -69,7 +130,7 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None metavar="", type=str, nargs="*", - help="Specific project(s) to review", + help="Specific project(s) to review; append :N to limit patches (e.g. proj:2)", ) group = parser.add_mutually_exclusive_group() group.add_argument( @@ -78,7 +139,7 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None metavar="N", type=int, default=None, - help="Number of patches to apply (default: all)", + help="Number of patches to apply, single project only (default: all)", ) group.add_argument( "--interactive", @@ -94,28 +155,37 @@ def __call__(self, args: argparse.Namespace) -> None: raise RuntimeError("--count must be >= 0") superproject = create_super_project() - - if isinstance(superproject, NoVcsSuperProject): - raise RuntimeError( - "The project containing the manifest is not under version control," - " reviewing patches is not supported" - ) - if not isinstance(superproject, GitSuperProject): - logger.warning( - "replay-patches has limited support in SVN superprojects" - " (no staging area — use `svn diff` to inspect changes)" - ) + _validate_superproject(superproject) if args.interactive and not is_tty(): raise RuntimeError("--interactive requires an interactive terminal") - self._iter_projects( - superproject, - args.projects, - lambda project: self._review_project( - superproject, project, args.count, args.interactive - ), - ) + parsed = [_parse_project_spec(s) for s in args.projects] + project_names = [name for name, _ in parsed] + per_project_counts: dict[str, int] = { + name: n for name, n in parsed if n is not None + } + _check_count_conflicts(args.count, per_project_counts) + + selected = list(superproject.manifest.selected_projects(project_names)) + if len(selected) >= 2: + with in_directory(superproject.root_directory): + _review_projects_combined( + superproject, + selected, + per_project_counts, + args.count, + args.interactive, + ) + else: + count = _effective_count(args.count, selected, per_project_counts) + self._iter_projects( + superproject, + project_names, + lambda project: self._review_project( + superproject, project, count, args.interactive + ), + ) def _review_project( self, @@ -245,6 +315,131 @@ def _restore_project( logger.print_info_line(project_name, "restored") +# --------------------------------------------------------------------------- +# Combined multi-project path +# --------------------------------------------------------------------------- + +_StagedEntry = tuple[SubProject, _ProjectState, bytes, Callable[[], list[str]]] + + +def _collect_reviewable( + superproject: SuperProject, + selected: list[dfetch.manifest.project.ProjectEntry], +) -> list[tuple[dfetch.manifest.project.ProjectEntry, SubProject]]: + """Return (project, subproject) pairs that pass the can-review check.""" + result = [] + for project in selected: + subproject = create_sub_project(project) + if _can_review_project(superproject, subproject, project.name): + result.append((project, subproject)) + return result + + +def _stage_one( + superproject: SuperProject, + git_super: GitSuperProject | None, + project: dfetch.manifest.project.ProjectEntry, + subproject: SubProject, +) -> _StagedEntry: + """Fetch upstream into the worktree and stage it; return a restore tuple.""" + saved_metadata = Path(subproject.metadata_path).read_bytes() + + def _ignored() -> list[str]: + return list(superproject.ignored_files(project.destination)) + + subproject.update( + force=True, + ignored_files_callback=_ignored, + patch_count=0, + eol_preferences_callback=superproject.eol_preferences, + ) + if git_super is not None: + git_super.add_path(subproject.local_path) + state = _ProjectState( + name=project.name, + local_path=subproject.local_path, + patches=list(subproject.patch), + ) + return subproject, state, saved_metadata, _ignored + + +def _run_combined_review( + staged: list[_StagedEntry], + git_super: GitSuperProject | None, + per_project_counts: dict[str, int], + interactive: bool, +) -> None: + """Apply patches and pause (non-interactive) or launch tree TUI (interactive).""" + if interactive: + _step_tui_multi([state for _, state, _, _ in staged]) + return + diff_cmd = "`git diff`" if git_super is not None else "`svn diff`" + for subproject, state, _, _ in staged: + count = per_project_counts.get(state.name, -1) + subproject.apply_patches(count) + state.current = ( + len(state.patches) if count == -1 else min(count, len(state.patches)) + ) + logger.print_info_line( + state.name, + f"stage = upstream, working tree = {state.current} patch(es) applied" + f" — open your editor and run {diff_cmd} to inspect", + ) + if is_tty(): + input("Press Enter to restore...") + + +def _restore_one_combined( + superproject: SuperProject, + git_super: GitSuperProject | None, + entry: _StagedEntry, +) -> None: + """Restore a single staged project and write back its saved metadata.""" + subproject, state, saved_meta, ignored = entry + try: + _restore_project( + superproject, + git_super, + subproject, + state.name, + state.fully_patched, + ignored, + ) + finally: + Path(subproject.metadata_path).write_bytes(saved_meta) + + +def _review_projects_combined( + superproject: SuperProject, + selected: list[dfetch.manifest.project.ProjectEntry], + per_project_counts: dict[str, int], + count: int | None, + interactive: bool, +) -> None: + """Fetch + stage all projects, pause for review, then restore all.""" + if count is not None: + raise RuntimeError( + "--count is for single-project use; use project:N syntax for per-project counts" + ) + git_super = superproject if isinstance(superproject, GitSuperProject) else None + reviewable = _collect_reviewable(superproject, selected) + if not reviewable: + return + staged: list[_StagedEntry] = [] + try: + for project, subproject in reviewable: + staged.append(_stage_one(superproject, git_super, project, subproject)) + _run_combined_review(staged, git_super, per_project_counts, interactive) + finally: + for entry in staged: + _restore_one_combined(superproject, git_super, entry) + + +# --------------------------------------------------------------------------- +# Single-project TUI +# --------------------------------------------------------------------------- + + def _draw_tui_frame( screen: Screen, patches: list[str], @@ -325,3 +520,69 @@ def _step_tui(patches: list[str], local_path: str, project_name: str) -> None: if done: screen.clear() return + + +# --------------------------------------------------------------------------- +# Multi-project TUI +# --------------------------------------------------------------------------- + + +def _draw_tui_tree( + screen: Screen, + states: list[_ProjectState], + focused: int, +) -> None: + """Render the multi-project patch-stack tree to the screen.""" + lines: list[str] = [ + f" {DIM}← → step ↑ ↓ switch project Enter restore and exit Ctrl-C abort{RESET}", + " " + "─" * 71, + ] + for idx, state in enumerate(states): + total = len(state.patches) + count_label = str(state.current) if state.current < total else "all" + prefix = ">" if idx == focused else " " + lines.append( + f" {prefix} {state.name} [{count_label}/{total} patches applied]" + ) + for pidx, patch_name in enumerate(state.patches): + marker = f"{BOLD}[x]{RESET}" if pidx < state.current else f"{DIM}[ ]{RESET}" + lines.append(f" {marker} {patch_name}") + screen.draw(lines) + + +def _handle_tui_multi_key( + key: str, + focused: int, + states: list[_ProjectState], +) -> tuple[int, bool]: + """Handle one keypress in the multi-project TUI; return (new_focused, done).""" + if key == "UP" and focused > 0: + return focused - 1, False + if key == "DOWN" and focused < len(states) - 1: + return focused + 1, False + state = states[focused] + state.current, done = _apply_step( + key, state.current, len(state.patches), state.patches, state.local_path + ) + return focused, done + + +def _step_tui_multi(states: list[_ProjectState]) -> None: + """Multi-project interactive TUI: ↑/↓ switch focus, ←/→ step patches.""" + focused = 0 + screen = Screen() + while True: + _draw_tui_tree(screen, states, focused) + try: + key = read_key() + except KeyboardInterrupt: + screen.clear() + raise + try: + focused, done = _handle_tui_multi_key(key, focused, states) + except RuntimeError: + screen.clear() + raise + if done: + screen.clear() + return diff --git a/doc/howto/patching.rst b/doc/howto/patching.rst index 9f3bad4a5..9de9b1ccb 100644 --- a/doc/howto/patching.rst +++ b/doc/howto/patching.rst @@ -305,13 +305,27 @@ original state. **Replaying a specific number of patches** -Use ``--count`` to stop at a particular patch in the stack. For example, to -see only what the first patch contributes, with the rest still un-applied: +Use ``--count`` to stop at a particular patch in the stack (single-project only). +For example, to see only what the first patch contributes, with the rest still +un-applied: .. code-block:: console $ dfetch replay-patches --count 1 some-project +**Replaying multiple projects at once** + +When you call ``replay-patches`` with two or more project names (or with no +names to select all), *dfetch* stages all of them together and presents a single +pause. You can limit the patches applied to a specific project with the +``name:N`` shorthand: + +.. code-block:: console + + $ dfetch replay-patches # all projects, all patches + $ dfetch replay-patches proj-a proj-b # two named projects + $ dfetch replay-patches proj-a:0 proj-b proj-c:1 # 0 / all / 1 patches + **Stepping through the stack interactively** Use ``--interactive`` (or ``-i``) to step through the patch stack one patch at @@ -322,6 +336,9 @@ so your editor always reflects the current position in the stack: $ dfetch replay-patches --interactive some-project +With multiple projects, use ↑ and ↓ to switch focus between project stacks while +← and → continue to step that project's patches. + Press **Enter** to finish and restore the original state. .. asciinema:: ../asciicasts/replay-patches.cast diff --git a/features/replay-patches-in-git.feature b/features/replay-patches-in-git.feature index 3ba2e4555..11faed6d6 100644 --- a/features/replay-patches-in-git.feature +++ b/features/replay-patches-in-git.feature @@ -94,3 +94,55 @@ Feature: Replay patches in git SomeProject: > skipped - uncommitted changes in SomeProject """ + + Scenario: Two projects are staged together and both restored in combined mode + Given a git repository "OtherProject.git" + And the patch file 'MyProject/patches/OtherProject.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 OtherProject.git + +Patched file for OtherProject.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 + - name: OtherProject + url: some-remote-server/OtherProject.git + patch: patches/OtherProject.patch + """ + When I run "dfetch replay-patches" in MyProject + Then the output shows + """ + Dfetch (0.14.0) + SomeProject: + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + OtherProject: + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + > Applying patch "patches/SomeProject.patch" + successfully patched 1/1: b'README.md' + > stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect + > Applying patch "patches/OtherProject.patch" + successfully patched 1/1: b'README.md' + > stage = upstream, working tree = 1 patch(es) applied — open your editor and run `git diff` to inspect + > restored + > restored + """ + And the patched 'MyProject/SomeProject/README.md' is + """ + Patched file for SomeProject.git + """ + And the patched 'MyProject/OtherProject/README.md' is + """ + Patched file for OtherProject.git + """ + And the git superproject 'MyProject' reports no changes to 'SomeProject' + And the git superproject 'MyProject' reports no changes to 'OtherProject' diff --git a/tests/test_replay_patches.py b/tests/test_replay_patches.py index 94f54edd1..4e2f780f5 100644 --- a/tests/test_replay_patches.py +++ b/tests/test_replay_patches.py @@ -242,3 +242,149 @@ def test_negative_count_raises(): ): with pytest.raises(RuntimeError, match="--count must be >= 0"): cmd(_make_args(count=-1)) + + +# --------------------------------------------------------------------------- +# project:N suffix (single project) +# --------------------------------------------------------------------------- + + +def test_single_project_suffix_becomes_count(): + cmd = ReplayPatches() + fake_super = _make_superproject(is_git=True) + fake_sub = _make_subproject() + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + with patch("dfetch.commands.command.in_directory"): + with patch( + "dfetch.commands.replay_patches.create_sub_project", + return_value=fake_sub, + ): + with patch("dfetch.commands.replay_patches.is_tty", return_value=False): + cmd(_make_args(projects=["my_project:2"])) + + fake_sub.apply_patches.assert_called_once_with(2) + + +def test_count_and_suffix_raises(): + cmd = ReplayPatches() + fake_super = _make_superproject(is_git=True) + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + with pytest.raises(RuntimeError, match="not both"): + cmd(_make_args(projects=["my_project:2"], count=1)) + + +# --------------------------------------------------------------------------- +# Combined multi-project path +# --------------------------------------------------------------------------- + + +def _make_multi_superproject(names): + sp = Mock(spec=GitSuperProject) + sp.manifest = mock_manifest([{"name": n} for n in names]) + sp.root_directory = Path("/tmp") + sp.ignored_files.return_value = [] + sp.eol_preferences = Mock(return_value={}) + sp.has_local_changes_in_dir.return_value = False + return sp + + +def _make_named_subproject(name, patches=None): + sub = Mock() + sub.patch = patches if patches is not None else [f"patches/{name}.patch"] + sub.local_path = name + sub.on_disk_version.return_value = "v1" + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml", mode="wb") as f: + f.write(b"dfetch:\n patch: patches/a.patch\n") + sub.metadata_path = f.name + return sub + + +def test_combined_two_projects_all_patches(): + cmd = ReplayPatches() + fake_super = _make_multi_superproject(["proj_a", "proj_b"]) + sub_a = _make_named_subproject("proj_a") + sub_b = _make_named_subproject("proj_b") + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + with patch( + "dfetch.commands.replay_patches.create_sub_project", + side_effect=[sub_a, sub_b], + ): + with patch("dfetch.commands.replay_patches.is_tty", return_value=False): + with patch("dfetch.commands.replay_patches.in_directory"): + cmd(_make_args()) + + fake_super.add_path.assert_any_call("proj_a") + fake_super.add_path.assert_any_call("proj_b") + sub_a.apply_patches.assert_called_once_with(-1) + sub_b.apply_patches.assert_called_once_with(-1) + fake_super.restore_staged.assert_any_call("proj_a") + fake_super.restore_staged.assert_any_call("proj_b") + + +def test_combined_per_project_counts(): + cmd = ReplayPatches() + fake_super = _make_multi_superproject(["proj_a", "proj_b"]) + sub_a = _make_named_subproject("proj_a") + sub_b = _make_named_subproject("proj_b") + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + with patch( + "dfetch.commands.replay_patches.create_sub_project", + side_effect=[sub_a, sub_b], + ): + with patch("dfetch.commands.replay_patches.is_tty", return_value=False): + with patch("dfetch.commands.replay_patches.in_directory"): + cmd(_make_args(projects=["proj_a:0", "proj_b"])) + + sub_a.apply_patches.assert_called_once_with(0) + sub_b.apply_patches.assert_called_once_with(-1) + fake_super.restore_from_head.assert_called_once_with("proj_a") + fake_super.restore_staged.assert_called_once_with("proj_b") + + +def test_combined_count_flag_raises(): + cmd = ReplayPatches() + fake_super = _make_multi_superproject(["proj_a", "proj_b"]) + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + with patch("dfetch.commands.replay_patches.in_directory"): + with pytest.raises(RuntimeError, match="single-project"): + cmd(_make_args(count=1)) + + +def test_combined_interactive_launches_tui(): + cmd = ReplayPatches() + fake_super = _make_multi_superproject(["proj_a", "proj_b"]) + sub_a = _make_named_subproject("proj_a") + sub_b = _make_named_subproject("proj_b") + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + with patch( + "dfetch.commands.replay_patches.create_sub_project", + side_effect=[sub_a, sub_b], + ): + with patch("dfetch.commands.replay_patches.is_tty", return_value=True): + with patch("dfetch.commands.replay_patches.in_directory"): + with patch( + "dfetch.commands.replay_patches._step_tui_multi" + ) as mock_tui: + cmd(_make_args(interactive=True)) + + mock_tui.assert_called_once() + states = mock_tui.call_args[0][0] + assert [s.name for s in states] == ["proj_a", "proj_b"] From ea49e297cb17d7c5510e4dfb1d3d884bdced2c27 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 25 Jun 2026 20:07:56 +0000 Subject: [PATCH 22/22] review comments --- .github/workflows/run.yml | 4 +- CHANGELOG.rst | 8 +- dfetch/commands/replay_patches.py | 49 +++-- dfetch/vcs/git.py | 24 +-- doc/asciicasts/replay-patches.cast | 2 +- doc/generate-casts/generate-casts.sh | 2 +- ...w-patch-demo.sh => replay-patches-demo.sh} | 2 +- features/steps/git_steps.py | 6 + plan.md | 174 ------------------ security/__init__.py | 9 + security/tm_supply_chain.py | 18 +- security/tm_usage.py | 18 +- tests/test_replay_patches.py | 27 +++ 13 files changed, 101 insertions(+), 242 deletions(-) rename doc/generate-casts/{review-patch-demo.sh => replay-patches-demo.sh} (95%) delete mode 100644 plan.md diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index 3206d7951..fbbaf439f 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -56,7 +56,7 @@ jobs: - run: dfetch update - run: dfetch update - run: dfetch update-patch - - run: dfetch review-patch + - run: dfetch replay-patches - run: dfetch format-patch - run: dfetch report -t sbom - run: dfetch remove test-repo @@ -199,7 +199,7 @@ jobs: - run: dfetch update - run: dfetch update - run: dfetch update-patch - - run: dfetch review-patch + - run: dfetch replay-patches - run: dfetch format-patch - run: dfetch report -t sbom - run: dfetch remove test-repo diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7eb65d559..61bc8179d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +Release 0.15.0 (unreleased) +============================== + +* Add ``replay-patches`` command to step through patch contributions interactively (#1290) + Release 0.14.3 (released 2026-06-25) ==================================== @@ -11,9 +16,6 @@ Release 0.14.2 (released 2026-06-21) Release 0.14.1 (released 2026-06-19) ==================================== - -* Add ``replay-patches`` command to inspect patch contributions interactively without making permanent changes (#review-patch) -* Add combined multi-project mode to ``replay-patches``: stage all projects at once with optional per-project ``name:N`` patch counts (#replay-patches-multi) * Implement C-043: add ``pip-audit`` OSV gate to the release workflow * Add CRA Compliance: OSCAL 1.2.2 Component Definition * Fix ``dfetch import`` mangling the namespace of a generic VCS URL whose path contains ``.git`` (#1268) diff --git a/dfetch/commands/replay_patches.py b/dfetch/commands/replay_patches.py index 13aa920ab..6bf74653a 100644 --- a/dfetch/commands/replay_patches.py +++ b/dfetch/commands/replay_patches.py @@ -46,6 +46,7 @@ from dfetch.project.subproject import SubProject from dfetch.project.superproject import NoVcsSuperProject, SuperProject from dfetch.terminal import BOLD, DIM, RESET, Screen, is_tty, read_key +from dfetch.util.cmdline import SubprocessCommandError from dfetch.util.util import in_directory from dfetch.vcs.patch import Patch @@ -73,11 +74,14 @@ def _parse_project_spec(spec: str) -> tuple[str, int | None]: return spec, None name, _, tail = spec.rpartition(":") try: - return name, int(tail) + n = int(tail) except ValueError as exc: raise RuntimeError( f"invalid project spec {spec!r}; expected name or name:N" ) from exc + if n < 0: + raise RuntimeError(f"invalid project spec {spec!r}; patch count must be >= 0") + return name, n def _validate_superproject(superproject: SuperProject) -> None: @@ -160,11 +164,7 @@ def __call__(self, args: argparse.Namespace) -> None: if args.interactive and not is_tty(): raise RuntimeError("--interactive requires an interactive terminal") - parsed = [_parse_project_spec(s) for s in args.projects] - project_names = [name for name, _ in parsed] - per_project_counts: dict[str, int] = { - name: n for name, n in parsed if n is not None - } + project_names, per_project_counts = self._parse_project_args(args.projects) _check_count_conflicts(args.count, per_project_counts) selected = list(superproject.manifest.selected_projects(project_names)) @@ -187,6 +187,18 @@ def __call__(self, args: argparse.Namespace) -> None: ), ) + @staticmethod + def _parse_project_args( + projects: list[str], + ) -> tuple[list[str], dict[str, int]]: + """Parse raw project arguments into names and per-project patch counts.""" + parsed = [_parse_project_spec(s) for s in projects] + project_names = [name for name, _ in parsed] + per_project_counts: dict[str, int] = { + name: n for name, n in parsed if n is not None + } + return project_names, per_project_counts + def _review_project( self, superproject: SuperProject, @@ -347,14 +359,18 @@ def _stage_one( def _ignored() -> list[str]: return list(superproject.ignored_files(project.destination)) - subproject.update( - force=True, - ignored_files_callback=_ignored, - patch_count=0, - eol_preferences_callback=superproject.eol_preferences, - ) - if git_super is not None: - git_super.add_path(subproject.local_path) + try: + subproject.update( + force=True, + ignored_files_callback=_ignored, + patch_count=0, + eol_preferences_callback=superproject.eol_preferences, + ) + if git_super is not None: + git_super.add_path(subproject.local_path) + except Exception: + Path(subproject.metadata_path).write_bytes(saved_metadata) + raise state = _ProjectState( name=project.name, local_path=subproject.local_path, @@ -432,7 +448,10 @@ def _review_projects_combined( _run_combined_review(staged, git_super, per_project_counts, interactive) finally: for entry in staged: - _restore_one_combined(superproject, git_super, entry) + try: + _restore_one_combined(superproject, git_super, entry) + except (RuntimeError, SubprocessCommandError, OSError) as exc: + logger.print_error_line(entry[1].name, str(exc)) # --------------------------------------------------------------------------- diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 6b67eae09..ee834ad5e 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -822,31 +822,21 @@ def add_path(self, path: str) -> None: with in_directory(self._path): run_on_cmdline(logger, ["git", "add", "--", path]) + def _git_restore(self, *flags: str, path: str) -> None: + with in_directory(self._path): + run_on_cmdline(logger, ["git", "restore", *flags, "--", path]) + def restore_staged(self, path: str) -> None: """Unstage the given path, restoring the index to HEAD.""" - with in_directory(self._path): - run_on_cmdline(logger, ["git", "restore", "--staged", "--", path]) + self._git_restore("--staged", path=path) def restore_worktree(self, path: str) -> None: """Restore working-tree files for path from the staged index.""" - with in_directory(self._path): - run_on_cmdline(logger, ["git", "restore", "--", path]) + self._git_restore(path=path) def restore_from_head(self, path: str) -> None: """Restore both working tree and index for path from HEAD.""" - with in_directory(self._path): - run_on_cmdline( - logger, - [ - "git", - "restore", - "--source=HEAD", - "--staged", - "--worktree", - "--", - path, - ], - ) + self._git_restore("--source=HEAD", "--staged", "--worktree", path=path) def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> Patch: """Create a diff for untracked files.""" diff --git a/doc/asciicasts/replay-patches.cast b/doc/asciicasts/replay-patches.cast index 0e6551b63..98ae18ecb 100644 --- a/doc/asciicasts/replay-patches.cast +++ b/doc/asciicasts/replay-patches.cast @@ -5,7 +5,7 @@ [2.6, "o", "\r\n"] [2.7, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] [2.75, "o", "$ "] -[3.75, "o", "\u001b[1mdfetch review-patch cpputest\u001b[0m"] +[3.75, "o", "\u001b[1mdfetch replay-patches cpputest\u001b[0m"] [4.75, "o", "\r\n"] [5.35, "o", "\u001b[1;34mDfetch (0.14.0)\u001b[0m\r\n"] [5.39, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] diff --git a/doc/generate-casts/generate-casts.sh b/doc/generate-casts/generate-casts.sh index 2bfb7c68f..09178c14c 100755 --- a/doc/generate-casts/generate-casts.sh +++ b/doc/generate-casts/generate-casts.sh @@ -28,7 +28,7 @@ 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 -asciinema rec --overwrite -c "./review-patch-demo.sh" ../asciicasts/review-patch.cast +asciinema rec --overwrite -c "./replay-patches-demo.sh" ../asciicasts/replay-patches.cast asciinema rec --overwrite -c "./format-patch-demo.sh" ../asciicasts/format-patch.cast rm -rf update diff --git a/doc/generate-casts/review-patch-demo.sh b/doc/generate-casts/replay-patches-demo.sh similarity index 95% rename from doc/generate-casts/review-patch-demo.sh rename to doc/generate-casts/replay-patches-demo.sh index 3e99336c5..abfc970a0 100755 --- a/doc/generate-casts/review-patch-demo.sh +++ b/doc/generate-casts/replay-patches-demo.sh @@ -44,7 +44,7 @@ clear pe "cat dfetch.yaml" pe "cat patches/cpputest.patch" # Pipe stdin to avoid blocking on "Press Enter to restore..." -pe "echo '' | dfetch review-patch cpputest" +pe "echo '' | dfetch replay-patches cpputest" PROMPT_TIMEOUT=3 wait diff --git a/features/steps/git_steps.py b/features/steps/git_steps.py index cb725a26e..00eac4343 100644 --- a/features/steps/git_steps.py +++ b/features/steps/git_steps.py @@ -260,6 +260,12 @@ def step_impl(context): @then("the git superproject '{superproject}' reports no changes to '{path}'") def step_impl(_, superproject, path): + """Verify git superproject reports no changes to path. + + Args: + superproject: directory name of the git superproject. + path: path inside the superproject to check for changes. + """ with in_directory(superproject): result = subprocess.check_output( ["git", "status", "--porcelain", "--", path], text=True diff --git a/plan.md b/plan.md deleted file mode 100644 index c544fa853..000000000 --- a/plan.md +++ /dev/null @@ -1,174 +0,0 @@ -# Plan: Combined multi-project replay-patches - -## Context - -`replay-patches` currently processes projects one at a time, each with its own -pause/restore cycle. When a user wants to see several vendored patch stacks at -once (e.g. in VS Code's Changes view), they must review them sequentially. - -The fix: when exactly one project resolves, keep the current per-project -behaviour unchanged. When two or more projects resolve: -- **Non-interactive** (default): validate all → fetch+stage all → apply per-project - patch counts → single pause → restore all. -- **`--interactive`**: launch a combined tree TUI where the user steps each - project's patch stack independently before pressing Enter to restore. - -Per-project patch counts in non-interactive combined mode use an optional -`project:N` suffix on each project argument. - ---- - -## CLI behaviour - -```bash -# Single project — unchanged behaviour -dfetch replay-patches proj-a -dfetch replay-patches --count 2 proj-a -dfetch replay-patches --interactive proj-a - -# Single project with :N shorthand (equivalent to --count N) -dfetch replay-patches proj-a:2 - -# Combined non-interactive (2+ projects, pause once) -dfetch replay-patches # all projects, all patches -dfetch replay-patches proj-a proj-b # two projects, all patches -dfetch replay-patches proj-a:0 proj-b proj-c:1 # 0 / all / 1 patches - -# Combined interactive tree TUI -dfetch replay-patches --interactive # all projects, tree TUI -dfetch replay-patches --interactive proj-a proj-b -``` - -**Rejected combinations** (raise `RuntimeError`): -- `--count` with 2+ projects → `"--count is for single-project use; use project:N syntax for per-project counts"` -- `--count` and `:N` suffix on same project → `"use either --count or project:N, not both"` - ---- - -## Parsing `project:N` syntax - -Add a helper at module level: - -```python -def _parse_project_spec(spec: str) -> tuple[str, int | None]: - """Split 'name:N' into (name, N); bare name returns (name, None).""" - if ":" in spec: - name, _, tail = spec.rpartition(":") - try: - return name, int(tail) - except ValueError: - raise RuntimeError( - f"invalid project spec {spec!r}; expected name or name:N" - ) - return spec, None -``` - ---- - -## Dispatch in `__call__` - -```python -# Parse project:N specs -parsed = [_parse_project_spec(s) for s in args.projects] -project_names = [name for name, _ in parsed] -per_project_counts: dict[str, int] = { - name: n for name, n in parsed if n is not None -} - -# Reject --count + :N on the same project -if args.count is not None: - overlap = [n for n in per_project_counts if n in project_names] - if overlap: - raise RuntimeError(f"use either --count or project:N, not both (conflicts: {overlap})") - -selected = list(superproject.manifest.selected_projects(project_names)) - -if len(selected) <= 1: - count = args.count - if count is None and selected and selected[0].name in per_project_counts: - count = per_project_counts[selected[0].name] - self._iter_projects(superproject, project_names, - lambda project: self._review_project(superproject, project, count, args.interactive)) -else: - if args.count is not None: - raise RuntimeError("--count is for single-project use; use project:N syntax for per-project counts") - with in_directory(superproject.root_directory): - _review_projects_combined(superproject, selected, per_project_counts, args.interactive) -``` - -Add `from dfetch.util.util import in_directory` to imports. - ---- - -## `_ProjectState` dataclass - -```python -@dataclasses.dataclass -class _ProjectState: - name: str - local_path: str - patches: list[str] - current: int = 0 - - @property - def fully_patched(self) -> bool: - return self.current == len(self.patches) -``` - ---- - -## `_review_projects_combined` - -1. **Validate** — `_can_review_project` for each; collect `reviewable`. -2. **Fetch + stage all** — `update(patch_count=0)` + `add_path` per project; append to `staged`. -3. **Apply / review**: - - `interactive=True`: `_step_tui_multi(states)` (TUI drives apply/unapply) - - `interactive=False`: `apply_patches(per_project_counts.get(name, -1))` per project, log, `input("Press Enter to restore...")` if TTY -4. **Restore all** in `finally`: loop `staged`, call `_restore_project(..., state.fully_patched, ...)`, restore metadata bytes. - ---- - -## Combined interactive tree TUI - -### Layout - -``` - ← → step ↑ ↓ switch project Enter restore and exit Ctrl-C abort - ───────────────────────────────────────────────────────────────────────── - proj-a [2/3 patches applied] - [x] patches/00-fix.patch - [x] patches/01-improve.patch - [ ] patches/02-final.patch -> proj-b [0/2 patches applied] - [ ] patches/00-base.patch - [ ] patches/01-extra.patch -``` - -### `_step_tui_multi(states)` - -- `UP` / `DOWN`: move `focused` index between projects -- `LEFT` / `RIGHT` / `ENTER` / `ESC`: delegate to existing `_apply_step` with focused project's state -- `Ctrl-C`: `screen.clear(); return` - ---- - -## Files to change - -| File | Change | -|------|--------| -| `dfetch/commands/replay_patches.py` | Add `dataclasses` + `in_directory` imports; `_ProjectState`; `_parse_project_spec`; modify `__call__`; add `_review_projects_combined`, `_draw_tui_tree`, `_step_tui_multi` | -| `tests/test_replay_patches.py` | Unit tests for combined path | -| `features/replay-patches-in-git.feature` | BDD scenario: combined non-interactive pass | -| `doc/howto/patching.rst` | Document automatic combined mode + `project:N` syntax | -| `CHANGELOG.rst` | One bullet | - ---- - -## New unit tests - -- `test_combined_two_projects_all_patches`: `add_path` × 2, `apply_patches(-1)` × 2, `restore_staged` × 2 -- `test_combined_per_project_counts`: counts 0/−1/1, verify correct `apply_patches` args and restore strategies -- `test_combined_count_flag_raises`: `--count 1` + 2 projects → RuntimeError -- `test_combined_count_and_suffix_raises`: `--count 1 proj:2` → RuntimeError -- `test_single_project_suffix_becomes_count`: `proj:2` alone → `_review_project` with `count=2` -- `test_combined_interactive_launches_tui`: `--interactive` + 2 projects → `_step_tui_multi` called diff --git a/security/__init__.py b/security/__init__.py index e69de29bb..b4d45235f 100644 --- a/security/__init__.py +++ b/security/__init__.py @@ -0,0 +1,9 @@ +"""Security threat models and compliance tooling for dfetch.""" + +import os +import sys + +# Ensure the security package is importable when loaded by Sphinx directive +_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _repo_root not in sys.path: + sys.path.insert(0, _repo_root) diff --git a/security/tm_supply_chain.py b/security/tm_supply_chain.py index 4c6afa5ba..36e6abe82 100644 --- a/security/tm_supply_chain.py +++ b/security/tm_supply_chain.py @@ -12,15 +12,7 @@ python -m security.tm_supply_chain --report REPORT """ -import os -import sys - -# Ensure the security package is importable when loaded by Sphinx directive -_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if _repo_root not in sys.path: - sys.path.insert(0, _repo_root) - -from pytm import ( # noqa: E402 # pylint: disable=wrong-import-position +from pytm import ( TM, Actor, Boundary, @@ -32,10 +24,8 @@ Process, ) -from security.tm_controls_data import ( # noqa: E402 # pylint: disable=wrong-import-position - SC_CONTROLS as CONTROLS, -) -from security.tm_elements import ( # noqa: E402 # pylint: disable=wrong-import-position +from security.tm_controls_data import SC_CONTROLS as CONTROLS +from security.tm_elements import ( THREATS_FILE, Control, ThreatResponse, @@ -43,7 +33,7 @@ make_dev_env_boundary, make_supply_chain_assumptions, ) -from security.tm_render import ( # noqa: E402 # pylint: disable=wrong-import-position +from security.tm_render import ( apply_report_utils_patch, run_model, ) diff --git a/security/tm_usage.py b/security/tm_usage.py index ca40c0a7c..a9ec73da5 100644 --- a/security/tm_usage.py +++ b/security/tm_usage.py @@ -24,15 +24,7 @@ python -m security.tm_usage --report REPORT """ -import os -import sys - -# Ensure the security package is importable when loaded by Sphinx directive -_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if _repo_root not in sys.path: - sys.path.insert(0, _repo_root) - -from pytm import ( # noqa: E402 # pylint: disable=wrong-import-position +from pytm import ( TM, Actor, Boundary, @@ -44,10 +36,8 @@ Process, ) -from security.tm_controls_data import ( # noqa: E402 # pylint: disable=wrong-import-position - USAGE_CONTROLS as CONTROLS, -) -from security.tm_elements import ( # noqa: E402 # pylint: disable=wrong-import-position +from security.tm_controls_data import USAGE_CONTROLS as CONTROLS +from security.tm_elements import ( THREATS_FILE, Control, ThreatResponse, @@ -56,7 +46,7 @@ make_network_boundary, make_usage_assumptions, ) -from security.tm_render import ( # noqa: E402 # pylint: disable=wrong-import-position +from security.tm_render import ( apply_report_utils_patch, run_model, ) diff --git a/tests/test_replay_patches.py b/tests/test_replay_patches.py index 4e2f780f5..fed1e0b4e 100644 --- a/tests/test_replay_patches.py +++ b/tests/test_replay_patches.py @@ -54,6 +54,7 @@ def _make_subproject(patches=None, on_disk_version: str | None = "v1"): def test_review_all_patches_calls_update_add_path_update(): + """All patches are applied and the staging area is restored for a git superproject.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject() @@ -82,6 +83,7 @@ def test_review_all_patches_calls_update_add_path_update(): def test_review_count_1_uses_patch_count_1(): + """--count 1 limits apply_patches to exactly one patch.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject() @@ -115,6 +117,7 @@ def test_review_count_1_uses_patch_count_1(): def test_svn_superproject_warns_and_skips_staging(): + """SVN superproject emits a warning and skips git staging operations.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=False) # not GitSuperProject fake_sub = _make_subproject() @@ -149,6 +152,7 @@ def test_svn_superproject_warns_and_skips_staging(): def test_no_patches_logs_warning_and_skips(): + """Projects with no patches log a warning and skip update/staging.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject(patches=[]) @@ -168,6 +172,7 @@ def test_no_patches_logs_warning_and_skips(): def test_never_fetched_logs_warning_and_skips(): + """Projects that have never been fetched log a warning and are skipped.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject(on_disk_version=None) @@ -187,6 +192,7 @@ def test_never_fetched_logs_warning_and_skips(): def test_local_changes_logs_warning_and_skips(): + """Projects with local working-tree changes log a warning and are skipped.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True, has_local_changes=True) fake_sub = _make_subproject() @@ -211,6 +217,7 @@ def test_local_changes_logs_warning_and_skips(): def test_no_vcs_superproject_raises(): + """A superproject with no VCS raises RuntimeError.""" cmd = ReplayPatches() fake_super = Mock(spec=NoVcsSuperProject) @@ -222,6 +229,7 @@ def test_no_vcs_superproject_raises(): def test_interactive_without_tty_raises(): + """--interactive without a TTY raises RuntimeError.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) @@ -234,6 +242,7 @@ def test_interactive_without_tty_raises(): def test_negative_count_raises(): + """--count with a negative value raises RuntimeError.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) @@ -250,6 +259,7 @@ def test_negative_count_raises(): def test_single_project_suffix_becomes_count(): + """project:N suffix is parsed and forwarded as the patch count.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) fake_sub = _make_subproject() @@ -269,6 +279,7 @@ def test_single_project_suffix_becomes_count(): def test_count_and_suffix_raises(): + """Combining --count and project:N suffix raises RuntimeError.""" cmd = ReplayPatches() fake_super = _make_superproject(is_git=True) @@ -279,6 +290,18 @@ def test_count_and_suffix_raises(): cmd(_make_args(projects=["my_project:2"], count=1)) +def test_negative_project_suffix_raises(): + """project:-N suffix raises RuntimeError with a clear message.""" + cmd = ReplayPatches() + fake_super = _make_superproject(is_git=True) + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + with pytest.raises(RuntimeError, match=">= 0"): + cmd(_make_args(projects=["my_project:-1"])) + + # --------------------------------------------------------------------------- # Combined multi-project path # --------------------------------------------------------------------------- @@ -306,6 +329,7 @@ def _make_named_subproject(name, patches=None): def test_combined_two_projects_all_patches(): + """Combined mode applies all patches for each of two projects.""" cmd = ReplayPatches() fake_super = _make_multi_superproject(["proj_a", "proj_b"]) sub_a = _make_named_subproject("proj_a") @@ -331,6 +355,7 @@ def test_combined_two_projects_all_patches(): def test_combined_per_project_counts(): + """Combined mode respects per-project patch counts specified with project:N.""" cmd = ReplayPatches() fake_super = _make_multi_superproject(["proj_a", "proj_b"]) sub_a = _make_named_subproject("proj_a") @@ -354,6 +379,7 @@ def test_combined_per_project_counts(): def test_combined_count_flag_raises(): + """--count in combined multi-project mode raises RuntimeError.""" cmd = ReplayPatches() fake_super = _make_multi_superproject(["proj_a", "proj_b"]) @@ -366,6 +392,7 @@ def test_combined_count_flag_raises(): def test_combined_interactive_launches_tui(): + """Combined interactive mode launches the multi-project TUI.""" cmd = ReplayPatches() fake_super = _make_multi_superproject(["proj_a", "proj_b"]) sub_a = _make_named_subproject("proj_a")