Parse and apply a ``*** Begin Patch`` block to a skill directory. Two-phase: validate all hunks first, then write to disk.
(patch_text: str, skill_dir: Path)
| 771 | return "\n".join(new_lines) |
| 772 | |
| 773 | def _apply_multi_file_patch(patch_text: str, skill_dir: Path) -> None: |
| 774 | """Parse and apply a ``*** Begin Patch`` block to a skill directory. |
| 775 | |
| 776 | Two-phase: validate all hunks first, then write to disk. |
| 777 | """ |
| 778 | parsed = parse_patch(patch_text) |
| 779 | if not parsed.hunks: |
| 780 | raise PatchParseError("Patch contains no file operations") |
| 781 | |
| 782 | resolved_dir = skill_dir.resolve() |
| 783 | |
| 784 | # Phase 1: validate and compute new contents |
| 785 | changes: List[Tuple[str, Path, str, str]] = [] # (type, abs_path, old, new) |
| 786 | |
| 787 | for hunk in parsed.hunks: |
| 788 | abs_path = (skill_dir / hunk.path).resolve() |
| 789 | |
| 790 | # Security check |
| 791 | if not str(abs_path).startswith(str(resolved_dir)): |
| 792 | raise PatchError(f"Path escapes skill directory: {hunk.path}") |
| 793 | |
| 794 | if hunk.type == "add": |
| 795 | new_content = hunk.contents |
| 796 | if new_content and not new_content.endswith("\n"): |
| 797 | new_content += "\n" |
| 798 | changes.append(("add", abs_path, "", new_content)) |
| 799 | |
| 800 | elif hunk.type == "delete": |
| 801 | if not abs_path.exists(): |
| 802 | raise PatchError(f"Cannot delete non-existent file: {hunk.path}") |
| 803 | changes.append(("delete", abs_path, "", "")) |
| 804 | |
| 805 | elif hunk.type == "update": |
| 806 | if not abs_path.exists(): |
| 807 | raise PatchError(f"Cannot update non-existent file: {hunk.path}") |
| 808 | old_content = abs_path.read_text(encoding="utf-8") |
| 809 | new_content = apply_update_chunks(str(hunk.path), old_content, hunk.chunks) |
| 810 | changes.append(("update", abs_path, old_content, new_content)) |
| 811 | |
| 812 | # Phase 2: write all changes |
| 813 | for change_type, abs_path, _, new_content in changes: |
| 814 | if change_type == "add": |
| 815 | abs_path.parent.mkdir(parents=True, exist_ok=True) |
| 816 | abs_path.write_text(new_content, encoding="utf-8") |
| 817 | logger.debug(f"PATCH add: {abs_path.relative_to(resolved_dir)}") |
| 818 | |
| 819 | elif change_type == "delete": |
| 820 | if abs_path.exists(): |
| 821 | abs_path.unlink() |
| 822 | logger.debug(f"PATCH delete: {abs_path.relative_to(resolved_dir)}") |
| 823 | |
| 824 | elif change_type == "update": |
| 825 | abs_path.write_text(new_content, encoding="utf-8") |
| 826 | logger.debug(f"PATCH update: {abs_path.relative_to(resolved_dir)}") |
| 827 | |
| 828 | |
| 829 | # SEARCH/REPLACE (single-file DIFF) |
no test coverage detected