diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index 6f10b0537..fbbaf439f 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 replay-patches - 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 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 a761f753a..8b83abf2e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,12 @@ +Release 0.15.0 (unreleased) +============================== + +* Add ``replay-patches`` command to step through patch contributions interactively (#1290) + Release 0.14.3 (unreleased) ==================================== -* Update ``dfetch environment`` to show newer version inline (`#1310`) +* Update ``dfetch environment`` to show newer version inline (#1310) Release 0.14.2 (released 2026-06-21) ==================================== @@ -11,7 +16,6 @@ Release 0.14.2 (released 2026-06-21) Release 0.14.1 (released 2026-06-19) ==================================== - * 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..474b9b2fe 100644 --- a/dfetch/__main__.py +++ b/dfetch/__main__.py @@ -18,6 +18,7 @@ import dfetch.commands.import_ import dfetch.commands.init import dfetch.commands.remove +import dfetch.commands.replay_patches import dfetch.commands.report import dfetch.commands.update import dfetch.commands.update_patch @@ -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.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/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/replay_patches.py b/dfetch/commands/replay_patches.py new file mode 100644 index 000000000..6bf74653a --- /dev/null +++ b/dfetch/commands/replay_patches.py @@ -0,0 +1,607 @@ +"""Replaying patches interactively. + +*Dfetch* allows you to keep local changes to external projects in the form of +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. + +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 replay all patches at once: + +.. code-block:: console + + $ 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 +command always restores the original working tree and index before it exits — +no permanent changes are made. + +.. tabs:: + + .. tab:: Git + + .. scenario-include:: ../features/replay-patches-in-git.feature + + .. tab:: SVN + + .. scenario-include:: ../features/replay-patches-in-svn.feature +""" + +import argparse +import dataclasses +import logging +from collections.abc import Callable, Generator +from contextlib import contextmanager +from pathlib import Path + +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.util.cmdline import SubprocessCommandError +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: + 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: + 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. + + 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 →. + 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 replay-patches action.""" + parser = dfetch.commands.command.Command.parser(subparsers, ReplayPatches) + parser.add_argument( + "projects", + metavar="", + type=str, + nargs="*", + help="Specific project(s) to review; append :N to limit patches (e.g. proj:2)", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--count", + "-n", + metavar="N", + type=int, + default=None, + help="Number of patches to apply, single project only (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 replay patches.""" + if args.count is not None and args.count < 0: + raise RuntimeError("--count must be >= 0") + + superproject = create_super_project() + _validate_superproject(superproject) + + if args.interactive and not is_tty(): + raise RuntimeError("--interactive requires an interactive terminal") + + 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)) + 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 + ), + ) + + @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, + project: dfetch.manifest.project.ProjectEntry, + count: int | None, + interactive: bool, + ) -> None: + """Set up review state for a single project, then restore.""" + subproject = create_sub_project(project) + git_super = superproject if isinstance(superproject, GitSuperProject) else None + + def _ignored() -> list[str]: + return list(superproject.ignored_files(project.destination)) + + if not _can_review_project(superproject, subproject, project.name): + return + + saved_metadata = Path(subproject.metadata_path).read_bytes() + total_patches = len(list(subproject.patch)) + 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 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" + ) + 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 + ) + finally: + 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( + 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, + chosen_count: int, + interactive: bool, + 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 + + subproject.apply_patches(chosen_count) + logger.print_info_line(project_name, info_msg) + if is_tty(): + input("Press Enter to restore...") + return chosen_count == -1 + + +def _restore_project( + superproject: SuperProject, + git_super: GitSuperProject | None, + subproject: SubProject, + project_name: str, + worktree_fully_patched: bool, + ignored_callback: Callable[[], list[str]], +) -> None: + """Restore the project to the fully-patched state and un-stage the index.""" + 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() + 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)) + + 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, + 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: + try: + _restore_one_combined(superproject, git_super, entry) + except (RuntimeError, SubprocessCommandError, OSError) as exc: + logger.print_error_line(entry[1].name, str(exc)) + + +# --------------------------------------------------------------------------- +# Single-project TUI +# --------------------------------------------------------------------------- + + +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) + + +@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, + total: int, + patches: list[str], + local_path: str, +) -> tuple[int, bool]: + """Handle one keypress; return (new_current, done).""" + if key == "LEFT" and current > 0: + 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: + 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 + return current, False + + +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 + screen = Screen() + + while True: + _draw_tui_frame(screen, patches, current, total, project_name) + try: + key = read_key() + except KeyboardInterrupt: + screen.clear() + raise + try: + current, done = _apply_step(key, current, total, patches, local_path) + except RuntimeError: + screen.clear() + raise + 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/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/dfetch/project/gitsuperproject.py b/dfetch/project/gitsuperproject.py index 3d4343c1c..0a5da09f0 100644 --- a/dfetch/project/gitsuperproject.py +++ b/dfetch/project/gitsuperproject.py @@ -51,6 +51,22 @@ 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 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/project/subproject.py b/dfetch/project/subproject.py index 9b750e172..ab1106f96 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -87,6 +87,16 @@ 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). + """ + if count < -1: + raise ValueError(f"count must be -1 (all) or >= 0, got {count}") + self._apply_patches(count) + def update( self, force: bool = False, diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index cecb01901..ee834ad5e 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -817,6 +817,27 @@ 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 _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.""" + self._git_restore("--staged", path=path) + + def restore_worktree(self, path: str) -> None: + """Restore working-tree files for path from the staged index.""" + self._git_restore(path=path) + + def restore_from_head(self, path: str) -> None: + """Restore both working tree and index for path from HEAD.""" + 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.""" with in_directory(self._path): diff --git a/doc/asciicasts/replay-patches.cast b/doc/asciicasts/replay-patches.cast new file mode 100644 index 000000000..98ae18ecb --- /dev/null +++ b/doc/asciicasts/replay-patches.cast @@ -0,0 +1,30 @@ +{"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[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"] +[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/replay-patches.gif b/doc/asciicasts/replay-patches.gif new file mode 100644 index 000000000..1fbd67666 Binary files /dev/null and b/doc/asciicasts/replay-patches.gif differ diff --git a/doc/generate-casts/generate-casts.sh b/doc/generate-casts/generate-casts.sh index 5ec894f44..09178c14c 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 "./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/replay-patches-demo.sh b/doc/generate-casts/replay-patches-demo.sh new file mode 100755 index 000000000..abfc970a0 --- /dev/null +++ b/doc/generate-casts/replay-patches-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 replay-patches 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 508336cef..9de9b1ccb 100644 --- a/doc/howto/patching.rst +++ b/doc/howto/patching.rst @@ -276,6 +276,83 @@ 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: + +Replaying patches +----------------- + +When you want to understand what a patch (or a set of patches) actually +contributes to a vendored project, run: + +.. code-block:: console + + $ 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 +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. + +**Replaying a specific number of patches** + +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 +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 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 + +.. tabs:: + + .. tab:: Git + + .. scenario-include:: ../features/replay-patches-in-git.feature + + .. tab:: SVN + + .. scenario-include:: ../features/replay-patches-in-svn.feature + .. _patching-upstream: Contributing the patch upstream diff --git a/features/replay-patches-in-git.feature b/features/replay-patches-in-git.feature new file mode 100644 index 000000000..11faed6d6 --- /dev/null +++ b/features/replay-patches-in-git.feature @@ -0,0 +1,148 @@ +@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 ``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 + 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 replay-patches SomeProject" in MyProject + Then the output shows + """ + Dfetch (0.14.0) + SomeProject: + > 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 + > restored + """ + And the patched 'MyProject/SomeProject/README.md' is + """ + Patched file for SomeProject.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 replay-patches --count 0 SomeProject" in MyProject + Then the output shows + """ + Dfetch (0.14.0) + SomeProject: + > Fetched master - f9b88b8259d9a7fb48327bf23beabe40c150d474 + > stage = upstream, working tree = 0 patch(es) applied — open your editor and run `git diff` to inspect + > restored + """ + And the patched 'MyProject/SomeProject/README.md' is + """ + Patched file for SomeProject.git + """ + 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 + """ + manifest: + version: 0.0 + projects: + - name: SomeProject + url: some-remote-server/SomeProject.git + """ + When I run "dfetch replay-patches 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 replay-patches SomeProject" in MyProject + Then the output shows + """ + Dfetch (0.14.0) + 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/features/replay-patches-in-svn.feature b/features/replay-patches-in-svn.feature new file mode 100644 index 000000000..90cc31214 --- /dev/null +++ b/features/replay-patches-in-svn.feature @@ -0,0 +1,68 @@ +@replay-patches +Feature: Replay patches in svn + + When working with external projects that have patch files inside an SVN + 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. + + 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 replay-patches SomeProject" in MySvnProject + Then the output shows + """ + Dfetch (0.14.0) + 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" + successfully patched 1/1: b'README.md' + > 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 + """ + Patched file for SomeProject + """ + + Scenario: Only the first N patches are applied with --count + When I run "dfetch replay-patches --count 0 SomeProject" in MySvnProject + Then the output shows + """ + Dfetch (0.14.0) + 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 + > 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/features/steps/git_steps.py b/features/steps/git_steps.py index 12c293c0b..00eac4343 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, 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 ( @@ -256,3 +256,18 @@ 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): + """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 + ) + assert result.strip() == "", f"Unexpected changes in {path!r}:\n{result}" 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 0bbb89e36..cbb486670 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 4bf0acc6a..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, ) @@ -164,9 +154,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, 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." + "Extracts archives with decompression-bomb limits and path-traversal checks. " + "``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." ) 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_replay_patches.py b/tests/test_replay_patches.py new file mode 100644 index 000000000..fed1e0b4e --- /dev/null +++ b/tests/test_replay_patches.py @@ -0,0 +1,417 @@ +"""Test the replay-patches command.""" + +# mypy: ignore-errors +# flake8: noqa + +import argparse +import tempfile +from pathlib import Path +from unittest.mock import ANY, Mock, call, patch + +import pytest + +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 + +_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(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 + return sp + + +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" + 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 + + +# --------------------------------------------------------------------------- +# Git happy path +# --------------------------------------------------------------------------- + + +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() + + 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()) + + fake_sub.update.assert_called_once_with( + 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) + fake_super.restore_worktree.assert_not_called() + fake_super.restore_staged.assert_called_once_with("my_project") + + +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() + + 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(count=1)) + + 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) + 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() + + +# --------------------------------------------------------------------------- +# SVN path (no add_path / restore_staged) +# --------------------------------------------------------------------------- + + +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() + + 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): + with patch("dfetch.commands.replay_patches.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() + 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) + + +# --------------------------------------------------------------------------- +# Skip scenarios +# --------------------------------------------------------------------------- + + +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=[]) + + 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, + ): + cmd(_make_args()) + + fake_sub.update.assert_not_called() + fake_super.add_path.assert_not_called() + + +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) + + 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, + ): + cmd(_make_args()) + + fake_sub.update.assert_not_called() + fake_super.add_path.assert_not_called() + + +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() + + 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, + ): + cmd(_make_args()) + + fake_sub.update.assert_not_called() + fake_super.add_path.assert_not_called() + + +# --------------------------------------------------------------------------- +# Error scenarios +# --------------------------------------------------------------------------- + + +def test_no_vcs_superproject_raises(): + """A superproject with no VCS raises RuntimeError.""" + cmd = ReplayPatches() + fake_super = Mock(spec=NoVcsSuperProject) + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + with pytest.raises(RuntimeError): + cmd(_make_args()) + + +def test_interactive_without_tty_raises(): + """--interactive without a TTY raises RuntimeError.""" + cmd = ReplayPatches() + fake_super = _make_superproject(is_git=True) + + with patch( + "dfetch.commands.replay_patches.create_super_project", return_value=fake_super + ): + 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(): + """--count with a negative value raises RuntimeError.""" + 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="--count must be >= 0"): + cmd(_make_args(count=-1)) + + +# --------------------------------------------------------------------------- +# project:N suffix (single project) +# --------------------------------------------------------------------------- + + +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() + + 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(): + """Combining --count and project:N suffix raises RuntimeError.""" + 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)) + + +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 +# --------------------------------------------------------------------------- + + +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(): + """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") + 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(): + """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") + 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(): + """--count in combined multi-project mode raises RuntimeError.""" + 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(): + """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") + 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"]