Skip to content

Commit bbb7bdd

Browse files
committed
fix: round-trip non-UTF-8 text files instead of crashing with UnicodeDecodeError
1 parent a5056e1 commit bbb7bdd

8 files changed

Lines changed: 130 additions & 5 deletions

File tree

pr_split/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def _create_single_branch_and_commit(
204204
if content is not None:
205205
p.parent.mkdir(parents=True, exist_ok=True)
206206
# newline="" keeps CRLF from the reconstructed content intact.
207-
p.write_text(content, encoding="utf-8", newline="")
207+
p.write_text(content, encoding="utf-8", errors="surrogateescape", newline="")
208208
elif p.exists():
209209
p.unlink()
210210

pr_split/diff_ops/parser.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ def extract_diff(dev_branch: str, base_branch: str) -> str:
4747
)
4848
if result.returncode != 0:
4949
raise GitOperationError(result.stderr.decode("utf-8", errors="replace").strip())
50-
return result.stdout.decode("utf-8")
50+
# Git diffs any NUL-free file as text, so legacy latin-1 sources reach us
51+
# too; surrogateescape keeps their bytes intact so they round-trip when
52+
# the worker writes them back with the same error handler.
53+
return result.stdout.decode("utf-8", errors="surrogateescape")
5154

5255

5356
_C_ESCAPES = {

pr_split/diff_ops/reconstructor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def _get_base_file_content(file_path: str, ref: str) -> str:
6868
)
6969
if result.returncode != 0:
7070
raise GitOperationError(result.stderr.decode("utf-8", errors="replace").strip())
71-
return result.stdout.decode("utf-8")
71+
return result.stdout.decode("utf-8", errors="surrogateescape")
7272

7373

7474
NO_NEWLINE_MARKER = "\\"

pr_split/plan_store.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from pathlib import Path
23

34
from loguru import logger
@@ -12,15 +13,19 @@ def save_plan(plan_file: PlanFile) -> None:
1213
path = Path(PLAN_DIR)
1314
path.mkdir(parents=True, exist_ok=True)
1415
plan_path = Path(PLAN_FILE)
15-
plan_path.write_text(plan_file.model_dump_json(indent=2))
16+
# The raw diff may carry surrogate-escaped bytes from non-UTF-8 files;
17+
# json.dumps escapes those as \udcXX and loads them back losslessly,
18+
# which pydantic's own JSON writer refuses to do.
19+
payload = json.dumps(plan_file.model_dump(mode="json"), indent=2, ensure_ascii=True)
20+
plan_path.write_text(payload, encoding="utf-8")
1621
logger.info(logs.SAVING_PLAN.format(path=plan_path))
1722

1823

1924
def load_plan() -> PlanFile:
2025
plan_path = Path(PLAN_FILE)
2126
if not plan_path.exists():
2227
raise PRSplitError(ErrorMsg.NO_PLAN())
23-
plan_file = PlanFile.model_validate_json(plan_path.read_text())
28+
plan_file = PlanFile.model_validate(json.loads(plan_path.read_text(encoding="utf-8")))
2429
logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=plan_path))
2530
return plan_file
2631

pr_split/planner/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,18 @@ def _count_tokens_openai(texts: list[str], *, model: str) -> int:
107107
return sum(len(enc.encode(t)) for t in texts)
108108

109109

110+
def _utf8_safe(text: str) -> str:
111+
"""Replace surrogate-escaped bytes so the text can be sent as JSON.
112+
113+
Diff text keeps undecodable bytes as surrogates so files round-trip on
114+
disk; the HTTP clients encode request bodies as strict UTF-8, so the
115+
prompt copy gets U+FFFD instead.
116+
"""
117+
return text.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace")
118+
119+
110120
def _count_tokens(system: str, user: str, *, settings: Settings) -> int:
121+
system, user = _utf8_safe(system), _utf8_safe(user)
111122
match settings.provider:
112123
case Provider.ANTHROPIC:
113124
return _count_tokens_anthropic(system, user, settings=settings)
@@ -169,6 +180,7 @@ def _call_openai(system: str, user: str, *, settings: Settings) -> RawToolOutput
169180

170181

171182
def _call_llm(system: str, user: str, *, settings: Settings) -> RawToolOutput:
183+
system, user = _utf8_safe(system), _utf8_safe(user)
172184
match settings.provider:
173185
case Provider.ANTHROPIC:
174186
return _call_anthropic(system, user, settings=settings)

tests/test_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,3 +986,24 @@ def test_large_diff_uses_chunking(
986986
result = _plan_split_with_llm(parsed, settings)
987987
assert len(result) == 1
988988
mock_chunked.assert_called_once()
989+
990+
991+
class TestSurrogateSafePrompts:
992+
@patch("pr_split.planner.client._call_anthropic")
993+
def test_call_llm_replaces_surrogates_before_the_request(self, mock_call: MagicMock) -> None:
994+
from pr_split.planner.client import _call_llm
995+
996+
user = b"diff caf\xe9".decode("utf-8", errors="surrogateescape")
997+
_call_llm("sys", user, settings=_make_settings())
998+
sent_user = mock_call.call_args.args[1]
999+
assert "\udce9" not in sent_user
1000+
assert sent_user == "diff caf\ufffd"
1001+
sent_user.encode("utf-8") # must be JSON-serialisable
1002+
1003+
@patch("pr_split.planner.client._count_tokens_anthropic", return_value=3)
1004+
def test_count_tokens_replaces_surrogates(self, mock_count: MagicMock) -> None:
1005+
from pr_split.planner.client import _count_tokens
1006+
1007+
user = b"caf\xe9".decode("utf-8", errors="surrogateescape")
1008+
assert _count_tokens("sys", user, settings=_make_settings()) == 3
1009+
mock_count.call_args.args[1].encode("utf-8")

tests/test_diff_parser_extended.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,58 @@ def git(*args: str) -> str:
355355
[(modified, 1), (deleted, 1), ("plain name.md", 1), ('new "file".txt', 1)]
356356
)
357357
assert parsed.stats["total_files"] == 4
358+
359+
360+
class TestNonUtf8FileContent:
361+
def test_latin1_file_round_trips_byte_for_byte(self, tmp_path: Path) -> None:
362+
from pr_split.constants import AssignmentType
363+
from pr_split.diff_ops.reconstructor import materialize_group_files
364+
from pr_split.git_ops.branches import merge_base
365+
from pr_split.schemas import Group, GroupAssignment
366+
367+
def git(*args: str) -> str:
368+
return subprocess.run(
369+
["git", "-c", "user.name=t", "-c", "user.email=t@x", *args],
370+
cwd=tmp_path,
371+
capture_output=True,
372+
text=True,
373+
check=True,
374+
).stdout
375+
376+
git("init", "-q", "-b", "main")
377+
base_bytes = "caf\xe9 one\nkeep\n".encode("latin-1")
378+
dev_bytes = "caf\xe9 two\nkeep\n".encode("latin-1")
379+
(tmp_path / "legacy.txt").write_bytes(base_bytes)
380+
git("add", "-A")
381+
git("commit", "-qm", "base")
382+
git("checkout", "-qb", "dev")
383+
(tmp_path / "legacy.txt").write_bytes(dev_bytes)
384+
git("add", "-A")
385+
git("commit", "-qm", "dev")
386+
387+
cwd = os.getcwd()
388+
os.chdir(tmp_path)
389+
try:
390+
parsed = parse_diff(extract_diff("dev", "main"))
391+
group = Group(
392+
id="pr-1",
393+
title="t",
394+
description="d",
395+
assignments=[
396+
GroupAssignment(
397+
file_path="legacy.txt",
398+
assignment_type=AssignmentType.WHOLE_FILE,
399+
hunk_indices=[0],
400+
)
401+
],
402+
)
403+
materialized = materialize_group_files(parsed, group, merge_base("main", "dev"))
404+
out = tmp_path / "out.txt"
405+
content = materialized["legacy.txt"]
406+
assert content is not None
407+
out.write_text(content, encoding="utf-8", errors="surrogateescape", newline="")
408+
finally:
409+
os.chdir(cwd)
410+
411+
assert [pf.path for pf in parsed.patch_set] == ["legacy.txt"]
412+
assert out.read_bytes() == dev_bytes

tests/test_plan_store.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,32 @@ def test_saved_file_is_valid_json(
106106
assert raw["plan"]["priority"] == "logical"
107107
assert raw["plan"]["min_loc"] == 25
108108
assert raw["plan"]["strict_loc_bounds"] is True
109+
110+
111+
class TestPlanStoreSurrogates:
112+
def test_raw_diff_with_undecodable_bytes_round_trips(
113+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
114+
) -> None:
115+
from pr_split.constants import Priority
116+
from pr_split.plan_store import load_plan, save_plan
117+
from pr_split.schemas import PlanFile, SplitPlan
118+
119+
monkeypatch.chdir(tmp_path)
120+
raw = b"--- a/x\n+++ b/x\n@@ -1 +1 @@\n-caf\xe9\n+caf\xe9!\n".decode(
121+
"utf-8", errors="surrogateescape"
122+
)
123+
plan = SplitPlan(
124+
dev_branch="dev",
125+
base_branch="main",
126+
max_loc=400,
127+
priority=Priority.ORTHOGONAL,
128+
raw_diff=raw,
129+
)
130+
save_plan(PlanFile(plan=plan))
131+
132+
loaded = load_plan()
133+
134+
assert loaded.plan.raw_diff == raw
135+
assert loaded.plan.raw_diff.encode("utf-8", errors="surrogateescape").endswith(
136+
b"+caf\xe9!\n"
137+
)

0 commit comments

Comments
 (0)