Patch Bluepad32 in build-local copy
This commit is contained in:
parent
200ffc92be
commit
3f6e2c9e12
5 changed files with 249 additions and 335 deletions
|
|
@ -56,26 +56,29 @@ pico_sdk_init()
|
|||
|
||||
# Configure BLUEPAD32 input backend if selected
|
||||
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
# Ensure Python3 is available and execute patch preparation
|
||||
find_package(Python3 REQUIRED COMPONENTS Interpreter)
|
||||
|
||||
|
||||
set(BLUEPAD32_PREP_SCRIPT ${CMAKE_CURRENT_LIST_DIR}/tools/prepare_bluepad32.py)
|
||||
set(BLUEPAD32_SOURCE ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32)
|
||||
set(BLUEPAD32_PATCH ${CMAKE_CURRENT_LIST_DIR}/patches/bluepad32-sdl3-imu.patch)
|
||||
set(BLUEPAD32_ROOT ${CMAKE_CURRENT_BINARY_DIR}/_deps/bluepad32-src)
|
||||
execute_process(
|
||||
COMMAND ${Python3_EXECUTABLE} ${BLUEPAD32_PREP_SCRIPT}
|
||||
--bluepad32 ${BLUEPAD32_SOURCE}
|
||||
--patch ${BLUEPAD32_PATCH}
|
||||
--output ${BLUEPAD32_ROOT}
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
|
||||
RESULT_VARIABLE BLUEPAD32_PREP_RESULT
|
||||
OUTPUT_VARIABLE BLUEPAD32_PREP_OUTPUT
|
||||
ERROR_VARIABLE BLUEPAD32_PREP_ERROR
|
||||
)
|
||||
|
||||
|
||||
if(NOT BLUEPAD32_PREP_RESULT EQUAL 0)
|
||||
message(FATAL_ERROR
|
||||
"Failed to prepare Bluepad32: Patch application or validation failed. "
|
||||
"Failed to prepare isolated Bluepad32 source. "
|
||||
"Details: ${BLUEPAD32_PREP_ERROR}")
|
||||
endif()
|
||||
|
||||
# Configure Bluepad32 include paths and subdirectory
|
||||
set(BLUEPAD32_ROOT ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32)
|
||||
|
||||
set(BTSTACK_ROOT ${PICO_SDK_PATH}/lib/btstack)
|
||||
include_directories(
|
||||
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ This uses an isolated `build-aio/` CMake cache and publishes:
|
|||
|
||||
The default `python3 build.py` command and `firmware/switch-pico.*` artifacts remain the UART/Pico build. The AIO build requires `PICO_BOARD=pico2_w`; it is not interchangeable with the original non-wireless Pico firmware.
|
||||
|
||||
Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0.
|
||||
Both `build.py --aio` and direct AIO CMake configuration copy the pinned Bluepad32 source into the active build directory and apply `patches/bluepad32-sdl3-imu.patch` there before compiling. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. The `external/bluepad32` submodule remains pristine; patch or source-revision drift fails configuration.
|
||||
|
||||
### Pairing up to four controllers
|
||||
|
||||
|
|
|
|||
1
build.py
1
build.py
|
|
@ -366,7 +366,6 @@ def build(
|
|||
firmware_uf2_path,
|
||||
):
|
||||
if aio or adapter_feasibility:
|
||||
run_cmd([sys.executable, str(SCRIPT_DIR / "tools" / "prepare_bluepad32.py")])
|
||||
definitions = [
|
||||
"-DSWITCH_PICO_LOG=OFF",
|
||||
"-DPICO_BOARD=pico2_w",
|
||||
|
|
|
|||
|
|
@ -1,339 +1,194 @@
|
|||
"""
|
||||
Tests for prepare_bluepad32.py patch preparation tool.
|
||||
|
||||
Tests cover:
|
||||
- Fresh patch application
|
||||
- Idempotence (second invocation succeeds without changing content)
|
||||
- Missing paths validation
|
||||
- Diverged/ambiguous repository states
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "tools"))
|
||||
|
||||
from prepare_bluepad32 import (
|
||||
from prepare_bluepad32 import ( # noqa: E402
|
||||
PatchError,
|
||||
resolve_paths,
|
||||
check_paths,
|
||||
is_patch_applied,
|
||||
apply_patch,
|
||||
main,
|
||||
prepare_bluepad32,
|
||||
resolve_paths,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_repo_structure():
|
||||
"""Create a temporary directory structure with git repositories."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
|
||||
# Create bluepad32 repo
|
||||
bp_dir = root / "external" / "bluepad32"
|
||||
bp_dir.mkdir(parents=True)
|
||||
subprocess.run(["git", "init"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=bp_dir, check=True, capture_output=True)
|
||||
|
||||
# Create a file to patch
|
||||
test_file = bp_dir / "test.txt"
|
||||
test_file.write_text("line 1\n")
|
||||
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "initial"], cwd=bp_dir, check=True, capture_output=True)
|
||||
|
||||
# Create patches dir
|
||||
patches_dir = root / "patches"
|
||||
patches_dir.mkdir()
|
||||
|
||||
yield root, bp_dir, patches_dir
|
||||
|
||||
|
||||
def create_simple_patch(repo_path: Path, patch_path: Path, file_to_patch: str = "test.txt") -> str:
|
||||
"""
|
||||
Create a simple patch file that modifies a file in the repository.
|
||||
|
||||
Returns the patch content as a string.
|
||||
"""
|
||||
# Create the modification
|
||||
test_file = repo_path / file_to_patch
|
||||
original_content = test_file.read_text()
|
||||
modified_content = original_content + "line 2\n"
|
||||
|
||||
# Generate patch using git diff
|
||||
test_file.write_text(modified_content)
|
||||
result = subprocess.run(
|
||||
["git", "diff", file_to_patch],
|
||||
cwd=repo_path,
|
||||
def run_git(repository: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repository), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
patch_content = result.stdout
|
||||
|
||||
# Reset the file to original state
|
||||
test_file.write_text(original_content)
|
||||
|
||||
# Write patch to file
|
||||
patch_path.write_text(patch_content)
|
||||
return patch_content
|
||||
|
||||
|
||||
def test_resolve_paths_with_defaults():
|
||||
"""Test that resolve_paths returns expected default paths."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bp_path, patch_path = resolve_paths(Path(tmpdir))
|
||||
assert bp_path == Path(tmpdir) / "external" / "bluepad32"
|
||||
assert patch_path == Path(tmpdir) / "patches" / "bluepad32-sdl3-imu.patch"
|
||||
@pytest.fixture
|
||||
def bluepad32_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
|
||||
source = tmp_path / "external" / "bluepad32"
|
||||
source.mkdir(parents=True)
|
||||
run_git(source, "init")
|
||||
run_git(source, "config", "user.email", "test@example.com")
|
||||
run_git(source, "config", "user.name", "Test User")
|
||||
(source / "test.txt").write_text("line 1\n", encoding="utf-8")
|
||||
run_git(source, "add", "test.txt")
|
||||
run_git(source, "commit", "-m", "initial")
|
||||
|
||||
patches = tmp_path / "patches"
|
||||
patches.mkdir()
|
||||
patch = patches / "bluepad32-sdl3-imu.patch"
|
||||
original = (source / "test.txt").read_text(encoding="utf-8")
|
||||
(source / "test.txt").write_text(f"{original}line 2\n", encoding="utf-8")
|
||||
patch.write_text(run_git(source, "diff", "--", "test.txt").stdout, encoding="utf-8")
|
||||
run_git(source, "restore", "test.txt")
|
||||
|
||||
output = tmp_path / "build-aio" / "_deps" / "bluepad32-src"
|
||||
return tmp_path, source, patch, output
|
||||
|
||||
|
||||
def test_resolve_paths_no_root():
|
||||
"""Test resolve_paths with no root uses current directory."""
|
||||
bp_path, patch_path = resolve_paths()
|
||||
assert bp_path.is_absolute()
|
||||
assert patch_path.is_absolute()
|
||||
def source_status(source: Path) -> str:
|
||||
return run_git(source, "status", "--porcelain", "--untracked-files=all").stdout
|
||||
|
||||
|
||||
def test_check_paths_missing_bluepad32(temp_repo_structure):
|
||||
"""Test that check_paths fails if bluepad32 dir is missing."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
# Remove bluepad32
|
||||
import shutil
|
||||
shutil.rmtree(bp_dir)
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
patch_file.write_text("dummy")
|
||||
|
||||
def test_resolve_paths_uses_project_build_tree(tmp_path: Path) -> None:
|
||||
source, patch, output = resolve_paths(tmp_path)
|
||||
|
||||
assert source == tmp_path / "external" / "bluepad32"
|
||||
assert patch == tmp_path / "patches" / "bluepad32-sdl3-imu.patch"
|
||||
assert output == tmp_path / "build" / "_deps" / "bluepad32-src"
|
||||
|
||||
|
||||
def test_check_paths_rejects_missing_source(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, source, patch, output = bluepad32_fixture
|
||||
shutil.rmtree(source)
|
||||
|
||||
with pytest.raises(PatchError, match="bluepad32 directory does not exist"):
|
||||
check_paths(bp_dir, patch_file)
|
||||
check_paths(source, patch, output)
|
||||
|
||||
|
||||
def test_check_paths_missing_patch(temp_repo_structure):
|
||||
"""Test that check_paths fails if patch file is missing."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "nonexistent.patch"
|
||||
|
||||
def test_check_paths_rejects_missing_patch(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, source, patch, output = bluepad32_fixture
|
||||
patch.unlink()
|
||||
|
||||
with pytest.raises(PatchError, match="patch file does not exist"):
|
||||
check_paths(bp_dir, patch_file)
|
||||
check_paths(source, patch, output)
|
||||
|
||||
|
||||
def test_check_paths_bluepad32_not_git_repo(temp_repo_structure):
|
||||
"""Test that check_paths fails if bluepad32 is not a git repo."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
# Remove .git to make it not a git repo
|
||||
import shutil
|
||||
shutil.rmtree(bp_dir / ".git")
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
patch_file.write_text("dummy")
|
||||
|
||||
def test_check_paths_rejects_non_repository(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, source, patch, output = bluepad32_fixture
|
||||
shutil.rmtree(source / ".git")
|
||||
|
||||
with pytest.raises(PatchError, match="not a git repository"):
|
||||
check_paths(bp_dir, patch_file)
|
||||
check_paths(source, patch, output)
|
||||
|
||||
|
||||
def test_fresh_patch_application(temp_repo_structure):
|
||||
"""Test applying a fresh patch to a clean repository."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Verify test.txt before patch
|
||||
test_file = bp_dir / "test.txt"
|
||||
original = test_file.read_text()
|
||||
assert "line 2" not in original
|
||||
|
||||
# Apply patch
|
||||
apply_patch(bp_dir, patch_file)
|
||||
|
||||
# Verify test.txt after patch
|
||||
patched = test_file.read_text()
|
||||
assert "line 2" in patched
|
||||
def test_check_paths_rejects_output_inside_source(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, source, patch, _ = bluepad32_fixture
|
||||
|
||||
with pytest.raises(PatchError, match="outside the Bluepad32 source tree"):
|
||||
check_paths(source, patch, source / "patched")
|
||||
|
||||
|
||||
def test_idempotent_patch_application(temp_repo_structure):
|
||||
"""Test that applying the same patch twice succeeds (idempotence)."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# First application
|
||||
apply_patch(bp_dir, patch_file)
|
||||
test_file = bp_dir / "test.txt"
|
||||
after_first = test_file.read_text()
|
||||
|
||||
# Second application should succeed without changing content
|
||||
apply_patch(bp_dir, patch_file)
|
||||
after_second = test_file.read_text()
|
||||
|
||||
assert after_first == after_second
|
||||
def test_prepare_patches_copy_and_preserves_pristine_source(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, source, patch, output = bluepad32_fixture
|
||||
|
||||
result = prepare_bluepad32(source, patch, output)
|
||||
|
||||
assert result == output
|
||||
assert (source / "test.txt").read_text(encoding="utf-8") == "line 1\n"
|
||||
assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n"
|
||||
assert not (output / ".git").exists()
|
||||
assert source_status(source) == ""
|
||||
|
||||
|
||||
def test_is_patch_applied_not_applied(temp_repo_structure):
|
||||
"""Test is_patch_applied returns False for unapplied patch."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Patch not applied yet
|
||||
assert is_patch_applied(bp_dir, patch_file) is False
|
||||
def test_prepare_replaces_existing_output_idempotently(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, source, patch, output = bluepad32_fixture
|
||||
prepare_bluepad32(source, patch, output)
|
||||
(output / "stale.txt").write_text("stale", encoding="utf-8")
|
||||
|
||||
prepare_bluepad32(source, patch, output)
|
||||
|
||||
assert not (output / "stale.txt").exists()
|
||||
assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n"
|
||||
assert source_status(source) == ""
|
||||
|
||||
|
||||
def test_is_patch_applied_already_applied(temp_repo_structure):
|
||||
"""Test is_patch_applied returns True for already applied patch."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Apply patch first
|
||||
apply_patch(bp_dir, patch_file)
|
||||
|
||||
# Now check should detect it's applied
|
||||
assert is_patch_applied(bp_dir, patch_file) is True
|
||||
def test_prepare_rejects_dirty_source_without_replacing_output(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, source, patch, output = bluepad32_fixture
|
||||
output.mkdir(parents=True)
|
||||
sentinel = output / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
(source / "test.txt").write_text("local edit\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(PatchError, match="must be pristine"):
|
||||
prepare_bluepad32(source, patch, output)
|
||||
|
||||
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||
|
||||
|
||||
def test_diverged_repository_state(temp_repo_structure):
|
||||
"""Test that diverged repository (patch doesn't apply cleanly) is rejected."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Diverge the repository by modifying the file such that the patch conflicts
|
||||
test_file = bp_dir / "test.txt"
|
||||
test_file.write_text("completely different line 1\n")
|
||||
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "divergence"], cwd=bp_dir, check=True, capture_output=True)
|
||||
|
||||
# Try to apply patch - should fail because file content doesn't match
|
||||
def test_prepare_rejects_diverged_source_revision(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, source, patch, output = bluepad32_fixture
|
||||
(source / "test.txt").write_text("different base\n", encoding="utf-8")
|
||||
run_git(source, "add", "test.txt")
|
||||
run_git(source, "commit", "-m", "diverge")
|
||||
|
||||
with pytest.raises(PatchError, match="Patch validation failed"):
|
||||
apply_patch(bp_dir, patch_file)
|
||||
prepare_bluepad32(source, patch, output)
|
||||
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_missing_bluepad32_path(temp_repo_structure):
|
||||
"""Test prepare_bluepad32 fails gracefully with missing bluepad32."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(bp_dir)
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
patch_file.write_text("dummy")
|
||||
|
||||
with pytest.raises(PatchError, match="bluepad32 directory does not exist"):
|
||||
prepare_bluepad32(bp_dir, patch_file)
|
||||
def test_prepare_uses_default_paths(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root, source, _, _ = bluepad32_fixture
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
output = prepare_bluepad32()
|
||||
|
||||
assert output == root / "build" / "_deps" / "bluepad32-src"
|
||||
assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n"
|
||||
assert source_status(source) == ""
|
||||
|
||||
|
||||
def test_missing_patch_file(temp_repo_structure):
|
||||
"""Test prepare_bluepad32 fails gracefully with missing patch file."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "nonexistent.patch"
|
||||
|
||||
with pytest.raises(PatchError, match="patch file does not exist"):
|
||||
prepare_bluepad32(bp_dir, patch_file)
|
||||
def test_cli_accepts_explicit_output(
|
||||
bluepad32_fixture: tuple[Path, Path, Path, Path], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_, source, patch, output = bluepad32_fixture
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"prepare_bluepad32.py",
|
||||
"--bluepad32",
|
||||
str(source),
|
||||
"--patch",
|
||||
str(patch),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_bluepad32_full_workflow(temp_repo_structure):
|
||||
"""Test complete prepare_bluepad32 workflow: apply then idempotent re-apply."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
test_file = bp_dir / "test.txt"
|
||||
original = test_file.read_text()
|
||||
|
||||
# First prepare (should apply patch)
|
||||
prepare_bluepad32(bp_dir, patch_file)
|
||||
after_first = test_file.read_text()
|
||||
assert after_first != original
|
||||
assert "line 2" in after_first
|
||||
|
||||
# Second prepare (should be idempotent)
|
||||
prepare_bluepad32(bp_dir, patch_file)
|
||||
after_second = test_file.read_text()
|
||||
assert after_first == after_second
|
||||
|
||||
|
||||
def test_prepare_bluepad32_with_defaults(temp_repo_structure):
|
||||
"""Test prepare_bluepad32 uses correct defaults when paths not provided."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "bluepad32-sdl3-imu.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Change to root directory and call with defaults
|
||||
import os
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(root)
|
||||
prepare_bluepad32() # Use defaults
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
# Verify patch was applied
|
||||
test_file = bp_dir / "test.txt"
|
||||
assert "line 2" in test_file.read_text()
|
||||
|
||||
|
||||
def test_patch_application_with_conflicting_content(temp_repo_structure):
|
||||
"""Test that patch with conflicting content is rejected."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
# Create a patch that adds a specific change
|
||||
patch_content = """--- a/test.txt
|
||||
+++ b/test.txt
|
||||
@@ -1 +1,3 @@
|
||||
line 1
|
||||
+line 2
|
||||
+line 3
|
||||
"""
|
||||
|
||||
patch_file = patches_dir / "conflict.patch"
|
||||
patch_file.write_text(patch_content)
|
||||
|
||||
# Modify the file to have different content that won't match the patch context
|
||||
test_file = bp_dir / "test.txt"
|
||||
test_file.write_text("modified line 1\n")
|
||||
subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "modify"], cwd=bp_dir, check=True, capture_output=True)
|
||||
|
||||
# Try to apply patch - should fail due to context mismatch
|
||||
with pytest.raises(PatchError):
|
||||
apply_patch(bp_dir, patch_file)
|
||||
|
||||
|
||||
def test_cli_with_explicit_paths(temp_repo_structure):
|
||||
"""Test CLI argument parsing with explicit paths."""
|
||||
root, bp_dir, patches_dir = temp_repo_structure
|
||||
|
||||
patch_file = patches_dir / "test.patch"
|
||||
create_simple_patch(bp_dir, patch_file, "test.txt")
|
||||
|
||||
# Simulate CLI call
|
||||
sys.argv = [
|
||||
"prepare_bluepad32.py",
|
||||
"--bluepad32", str(bp_dir),
|
||||
"--patch", str(patch_file),
|
||||
]
|
||||
|
||||
from prepare_bluepad32 import main
|
||||
|
||||
# Should not raise
|
||||
try:
|
||||
main()
|
||||
except SystemExit as e:
|
||||
# main() calls sys.exit on success, which we need to catch
|
||||
if e.code != 0:
|
||||
raise
|
||||
assert main() == 0
|
||||
assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n"
|
||||
assert source_status(source) == ""
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply the project Bluepad32 patch exactly once."""
|
||||
"""Create a patched build-local copy of the pinned Bluepad32 source."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -12,69 +13,125 @@ class PatchError(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path]:
|
||||
def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path, Path]:
|
||||
root = Path.cwd() if repo_root is None else Path(repo_root)
|
||||
return (
|
||||
root / "external" / "bluepad32",
|
||||
root / "patches" / "bluepad32-sdl3-imu.patch",
|
||||
root / "build" / "_deps" / "bluepad32-src",
|
||||
)
|
||||
|
||||
|
||||
def check_paths(bluepad32_path: Path, patch_path: Path) -> None:
|
||||
if not bluepad32_path.is_dir():
|
||||
raise PatchError(f"bluepad32 directory does not exist: {bluepad32_path}")
|
||||
if not (bluepad32_path / ".git").exists():
|
||||
raise PatchError(f"Bluepad32 is not a git repository: {bluepad32_path}")
|
||||
if not patch_path.is_file():
|
||||
raise PatchError(f"patch file does not exist: {patch_path}")
|
||||
|
||||
|
||||
def git_apply(bluepad32_path: Path, patch_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(bluepad32_path), "apply", *args, str(patch_path)],
|
||||
command,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def is_patch_applied(bluepad32_path: Path, patch_path: Path) -> bool:
|
||||
return git_apply(bluepad32_path, patch_path, "--reverse", "--check").returncode == 0
|
||||
def check_paths(source_path: Path, patch_path: Path, output_path: Path) -> None:
|
||||
if not source_path.is_dir():
|
||||
raise PatchError(f"bluepad32 directory does not exist: {source_path}")
|
||||
if not patch_path.is_file():
|
||||
raise PatchError(f"patch file does not exist: {patch_path}")
|
||||
|
||||
repository_check = _run(
|
||||
["git", "-C", str(source_path), "rev-parse", "--is-inside-work-tree"]
|
||||
)
|
||||
if repository_check.returncode != 0:
|
||||
raise PatchError(f"Bluepad32 is not a git repository: {source_path}")
|
||||
|
||||
source = source_path.resolve()
|
||||
output = output_path.resolve()
|
||||
if output == source or source in output.parents:
|
||||
raise PatchError("patched output must be outside the Bluepad32 source tree")
|
||||
|
||||
|
||||
def apply_patch(bluepad32_path: Path, patch_path: Path) -> None:
|
||||
if is_patch_applied(bluepad32_path, patch_path):
|
||||
return
|
||||
def validate_pristine_source(source_path: Path, patch_path: Path) -> None:
|
||||
status = _run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(source_path),
|
||||
"status",
|
||||
"--porcelain",
|
||||
"--untracked-files=all",
|
||||
]
|
||||
)
|
||||
if status.returncode != 0:
|
||||
detail = status.stderr.strip() or "git status failed"
|
||||
raise PatchError(f"Could not inspect Bluepad32 source: {detail}")
|
||||
if status.stdout.strip():
|
||||
raise PatchError(
|
||||
"Bluepad32 source must be pristine; patches are applied only to the build-local copy"
|
||||
)
|
||||
|
||||
check = git_apply(bluepad32_path, patch_path, "--check")
|
||||
check = _run(
|
||||
["git", "-C", str(source_path), "apply", "--check", str(patch_path)]
|
||||
)
|
||||
if check.returncode != 0:
|
||||
detail = check.stderr.strip() or "patch does not apply"
|
||||
raise PatchError(f"Patch validation failed (repository may be diverged):\n{detail}")
|
||||
raise PatchError(f"Patch validation failed (source revision may have diverged):\n{detail}")
|
||||
|
||||
result = git_apply(bluepad32_path, patch_path)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "git apply failed"
|
||||
raise PatchError(f"Could not patch Bluepad32: {detail}")
|
||||
|
||||
def _copy_and_patch(source_path: Path, patch_path: Path, output_path: Path) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging_path = output_path.with_name(f".{output_path.name}.tmp")
|
||||
|
||||
if staging_path.exists():
|
||||
shutil.rmtree(staging_path)
|
||||
|
||||
try:
|
||||
shutil.copytree(
|
||||
source_path,
|
||||
staging_path,
|
||||
symlinks=True,
|
||||
ignore=shutil.ignore_patterns(".git"),
|
||||
)
|
||||
result = _run(
|
||||
["git", "apply", "--no-index", str(patch_path.resolve())],
|
||||
cwd=staging_path,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "git apply failed"
|
||||
raise PatchError(f"Could not patch build-local Bluepad32 copy: {detail}")
|
||||
|
||||
if output_path.exists():
|
||||
shutil.rmtree(output_path)
|
||||
staging_path.replace(output_path)
|
||||
except Exception:
|
||||
if staging_path.exists():
|
||||
shutil.rmtree(staging_path)
|
||||
raise
|
||||
|
||||
|
||||
def prepare_bluepad32(
|
||||
bluepad32_path: Path | None = None,
|
||||
source_path: Path | None = None,
|
||||
patch_path: Path | None = None,
|
||||
) -> None:
|
||||
default_bluepad32, default_patch = resolve_paths()
|
||||
dependency = Path(bluepad32_path or default_bluepad32)
|
||||
output_path: Path | None = None,
|
||||
) -> Path:
|
||||
default_source, default_patch, default_output = resolve_paths()
|
||||
source = Path(source_path or default_source)
|
||||
patch = Path(patch_path or default_patch)
|
||||
check_paths(dependency, patch)
|
||||
apply_patch(dependency, patch)
|
||||
output = Path(output_path or default_output)
|
||||
|
||||
check_paths(source, patch, output)
|
||||
validate_pristine_source(source, patch)
|
||||
_copy_and_patch(source, patch, output)
|
||||
return output
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--bluepad32", type=Path)
|
||||
parser.add_argument("--patch", type=Path)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
prepare_bluepad32(args.bluepad32, args.patch)
|
||||
prepare_bluepad32(args.bluepad32, args.patch, args.output)
|
||||
except PatchError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue