From 600f41abef0af7aa50708cc85cc2859e216c7662 Mon Sep 17 00:00:00 2001 From: nomyfan Date: Sat, 23 May 2026 12:45:37 +0800 Subject: [PATCH 1/2] feat: add --amend flag to carry over existing patch into new workspace When `p12y patch --amend` is used and a matching patch file exists in patches/, it is applied to the new workspace so users can continue editing from where they left off. Default behavior remains clean (no carry-over). Recovery handles partial failures via dry-run validation followed by git add/reset/clean. Co-Authored-By: Claude Opus 4.6 --- patch_package_py/cli.py | 9 +- patch_package_py/core.py | 62 +++++++++++++- tests/test_core.py | 27 ++++++ tests/test_e2e.py | 178 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 tests/test_e2e.py 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..ec9a3bf 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" @@ -181,6 +193,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 == "" From 0bccd50b07335b820509f7be590095736c6a7734 Mon Sep 17 00:00:00 2001 From: nomyfan Date: Sat, 23 May 2026 12:54:14 +0800 Subject: [PATCH 2/2] fix: use --link-mode=copy to prevent uv cache contamination uv hardlinks installed files to its cache by default. When users edit files in the patch workspace, the hardlink causes the cache entry to be modified too, corrupting subsequent installs of the same package. Co-Authored-By: Claude Opus 4.6 --- patch_package_py/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/patch_package_py/core.py b/patch_package_py/core.py index ec9a3bf..1228698 100644 --- a/patch_package_py/core.py +++ b/patch_package_py/core.py @@ -117,6 +117,7 @@ def prepare_patch_workspace( "pip", "install", "--no-deps", + "--link-mode=copy", f"{package_name}=={version}", "--python", str(