diff --git a/patch_package_py/cli.py b/patch_package_py/cli.py index 2fb8274..0f570bc 100644 --- a/patch_package_py/cli.py +++ b/patch_package_py/cli.py @@ -33,7 +33,9 @@ def cmd_patch(args): ) sys.exit(1) module_path, version = package - prepare_patch_workspace(module_path, package_name, version, env_path) + prepare_patch_workspace( + module_path, package_name, version, env_path, amend=args.amend + ) def cmd_commit(args): @@ -110,6 +112,11 @@ def cli(): ) workspace_parser.add_argument("package", help="Package name") workspace_parser.add_argument("-e", "--env-path", help="Environment Path") + workspace_parser.add_argument( + "--amend", + action="store_true", + help="Apply existing patch file to the workspace so you can continue editing", + ) workspace_parser.set_defaults(func=cmd_patch) # commit command diff --git a/patch_package_py/core.py b/patch_package_py/core.py index 630e007..1228698 100644 --- a/patch_package_py/core.py +++ b/patch_package_py/core.py @@ -89,8 +89,20 @@ def _find_commonpath(self, files: list[PurePosixPath]) -> PurePosixPath: return PurePosixPath(common_path_str) +def find_existing_patch(package_name: str, version: str) -> Union[Path, None]: + patch_file = Path.cwd() / "patches" / f"{package_name}+{version}.patch" + if patch_file.exists(): + return patch_file + return None + + def prepare_patch_workspace( - module_path: PurePosixPath, package_name: str, version: str, target_env_path: Path + module_path: PurePosixPath, + package_name: str, + version: str, + target_env_path: Path, + *, + amend: bool = False, ): temp_dir = Path(tempfile.mkdtemp(prefix=f"patch-{package_name}-{version}-")) venv_path = temp_dir / "venv" @@ -105,6 +117,7 @@ def prepare_patch_workspace( "pip", "install", "--no-deps", + "--link-mode=copy", f"{package_name}=={version}", "--python", str( @@ -181,6 +194,54 @@ def prepare_patch_workspace( stdout=subprocess.DEVNULL, ) + if amend: + existing_patch = find_existing_patch(package_name, version) + if existing_patch: + logger.info(f"Applying existing patch: {existing_patch.name}") + patch_args = [ + "patch", + "-p1", + "-N", + "--forward", + "-i", + str(existing_patch.absolute()), + ] + try: + # Validate before modifying the workspace; recovery below + # handles residue if the real apply still fails unexpectedly. + subprocess.check_call( + [*patch_args, "--dry-run"], + cwd=site_packages_path, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) + subprocess.check_call( + patch_args, + cwd=site_packages_path, + ) + except subprocess.CalledProcessError: + logger.warning( + "Failed to apply existing patch. Starting from clean state." + ) + subprocess.check_call( + ["git", "add", "."], + cwd=git_path, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) + subprocess.check_call( + ["git", "reset", "--hard", "HEAD"], + cwd=git_path, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) + subprocess.check_call( + ["git", "clean", "-fdX"], + cwd=git_path, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + ) + logger.info( f"You can now edit the package in: {edit_path}. When done, run `{CLI_NAME} commit {edit_path}` in this directory to create the patch file." ) diff --git a/tests/test_core.py b/tests/test_core.py index 2a2acb0..ee74ba7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,6 +9,7 @@ Resolver, apply_patch, commit_changes, + find_existing_patch, find_site_packages, restore_clean_package, venv_python, @@ -346,3 +347,29 @@ def test_restore_clean_package_uses_target_env_python(self, tmp_path: Path): str(venv_python(target_env)), ] ) + + +class TestFindExistingPatch: + def test_finds_existing_patch(self, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + patches_dir = tmp_path / "patches" + patches_dir.mkdir() + patch_file = patches_dir / "mypackage+1.0.0.patch" + patch_file.write_text("some diff") + + result = find_existing_patch("mypackage", "1.0.0") + assert result == patch_file + + def test_returns_none_when_no_patch(self, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = find_existing_patch("mypackage", "1.0.0") + assert result is None + + def test_returns_none_for_different_version(self, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + patches_dir = tmp_path / "patches" + patches_dir.mkdir() + (patches_dir / "mypackage+2.0.0.patch").write_text("some diff") + + result = find_existing_patch("mypackage", "1.0.0") + assert result is None diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..b29d083 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,178 @@ +import subprocess +import sys +import tempfile + +import pytest + +from patch_package_py.core import ( + Resolver, + commit_changes, + find_site_packages, + prepare_patch_workspace, + venv_python, +) + +PACKAGE = "six" +PACKAGE_VERSION = "1.16.0" + + +def _make_mock_mkdtemp(target_dir): + def mock_mkdtemp(*args, **kwargs): + target_dir.mkdir(exist_ok=True) + return str(target_dir) + + return mock_mkdtemp + + +@pytest.fixture() +def project(tmp_path, monkeypatch): + project_dir = tmp_path / "project" + project_dir.mkdir() + monkeypatch.chdir(project_dir) + + target_env = project_dir / ".venv" + subprocess.check_call( + ["uv", "venv", str(target_env), "--python", sys.executable] + ) + subprocess.check_call( + [ + "uv", + "pip", + "install", + "--no-deps", + f"{PACKAGE}=={PACKAGE_VERSION}", + "--python", + str(venv_python(target_env)), + ] + ) + + resolver = Resolver() + module_path, version = resolver.resolve_in_venv(target_env, PACKAGE) + assert version == PACKAGE_VERSION + + return { + "dir": project_dir, + "target_env": target_env, + "module_path": module_path, + "version": version, + } + + +class TestCarryOverPatchE2E: + def test_new_workspace_has_existing_patch_applied( + self, tmp_path, monkeypatch, project + ): + module_path = project["module_path"] + version = project["version"] + target_env = project["target_env"] + + # Workspace 1: create and commit a patch + ws1 = tmp_path / "ws1" + monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws1)) + prepare_patch_workspace(module_path, PACKAGE, version, target_env) + + ws1_sp = find_site_packages(ws1 / "venv") + six_py = ws1_sp / "six.py" + original = six_py.read_text() + six_py.write_text(original + "\n# patched by e2e test\n") + + commit_changes(PACKAGE, version, ws1_sp, target_env) + + patch_file = project["dir"] / "patches" / f"{PACKAGE}+{version}.patch" + assert patch_file.exists() + + # Workspace 2 with amend: should carry over the patch + ws2 = tmp_path / "ws2" + monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws2)) + prepare_patch_workspace( + module_path, PACKAGE, version, target_env, amend=True + ) + + ws2_sp = find_site_packages(ws2 / "venv") + assert "# patched by e2e test" in (ws2_sp / "six.py").read_text() + + diff = subprocess.check_output( + ["git", "diff", "--relative"], cwd=ws2_sp, text=True + ) + assert "patched by e2e test" in diff + + def test_default_is_clean(self, tmp_path, monkeypatch, project): + module_path = project["module_path"] + version = project["version"] + target_env = project["target_env"] + + # Workspace 1: create and commit a patch + ws1 = tmp_path / "ws1" + monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws1)) + prepare_patch_workspace(module_path, PACKAGE, version, target_env) + + ws1_sp = find_site_packages(ws1 / "venv") + six_py = ws1_sp / "six.py" + original = six_py.read_text() + six_py.write_text(original + "\n# should not appear\n") + + commit_changes(PACKAGE, version, ws1_sp, target_env) + + # Workspace 2 without --amend: should NOT have the patch + ws2 = tmp_path / "ws2" + monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws2)) + prepare_patch_workspace(module_path, PACKAGE, version, target_env) + + ws2_sp = find_site_packages(ws2 / "venv") + assert "# should not appear" not in (ws2_sp / "six.py").read_text() + + diff = subprocess.check_output( + ["git", "diff", "--relative"], cwd=ws2_sp, text=True + ) + assert diff == "" + + def test_amend_with_bad_patch_recovers_to_clean_state( + self, tmp_path, monkeypatch, project + ): + module_path = project["module_path"] + version = project["version"] + target_env = project["target_env"] + + # Write a corrupt patch file + patches_dir = project["dir"] / "patches" + patches_dir.mkdir() + patch_file = patches_dir / f"{PACKAGE}+{version}.patch" + patch_file.write_text( + "--- a/six.py\n" + "+++ b/six.py\n" + "@@ -1,3 +1,3 @@\n" + " this context does not exist in six.py\n" + "-nor does this line\n" + "+so the patch will fail\n" + ) + + ws = tmp_path / "ws" + monkeypatch.setattr(tempfile, "mkdtemp", _make_mock_mkdtemp(ws)) + prepare_patch_workspace( + module_path, PACKAGE, version, target_env, amend=True + ) + + ws_sp = find_site_packages(ws / "venv") + git_path = ws_sp.parent + + # No modified or staged files + diff = subprocess.check_output( + ["git", "diff", "--relative"], cwd=ws_sp, text=True + ) + assert diff == "" + + # No untracked files (.rej, etc.) + untracked = subprocess.check_output( + ["git", "ls-files", "--others", "--exclude-standard"], + cwd=git_path, + text=True, + ) + assert untracked == "" + + # No ignored residue + ignored = subprocess.check_output( + ["git", "ls-files", "--others", "--ignored", "--exclude-standard"], + cwd=git_path, + text=True, + ) + assert ignored == ""