From aaf37ffa801fedf274dd6003a05a69cbd57d8554 Mon Sep 17 00:00:00 2001 From: ppcvote Date: Fri, 7 Aug 2026 02:46:03 +0800 Subject: [PATCH] fix(windows): decode git output as UTF-8, not the process locale Git reports paths as UTF-8 on every platform. `subprocess.run(..., text=True)` decodes with the process locale, which on Windows is the ANSI codepage, so any non-ASCII path is decoded with the wrong codec. Fixes #192, and covers a second call site the report does not reach. The failure has two shapes depending on the codepage, which is why the filed report and this one look different: - Single-byte (cp1251, the reporter's): every byte decodes, so nothing raises and a silently wrong string comes back. `Path.relative_to` then fails and the scan is refused with "Scan target must stay inside its Git working tree." - Multi-byte (cp932/936/950): the sequence is rejected outright. The UnicodeDecodeError is raised on subprocess's reader thread, so `run` returns with returncode 0 and `stdout` set to None, and `git_output` calls `.strip()` on it. That surfaces as `AttributeError: 'NoneType' object has no attribute 'strip'` rather than a diagnosable message. Reproduced on Windows 10, Python 3.11.6, ANSI codepage cp950, git 2.52.0: before CJK repo directory AttributeError ... 'strip' before Cyrillic repo directory AttributeError ... 'strip' before CJK file in ASCII repo AttributeError ... 'split' after all three resolve correctly, ASCII control unchanged The third case is outside #192's scope and is the reason for the second file. `generate_rank_input.run_git_changed_paths` reads names out of `git diff --name-status -z`, so the repository path does not need to contain non-ASCII at all: one tracked file with a non-ASCII name is enough to end the scan on any non-UTF-8 Windows host. Naming the encoding is what PYTHONUTF8=1 achieves, which the reporter confirmed as a workaround, without depending on the environment. `git_bytes` is untouched and still returns undecoded bytes; the text branch is now explicit so the two modes cannot be conflated. --- .../scripts/generate_rank_input.py | 4 ++++ .../scripts/workbench_target.py | 20 ++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index e01bfd0b..2be706c9 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -502,6 +502,10 @@ def run_git_changed_paths(repo: Path, diff_args: list[str]) -> list[tuple[Path, check=True, capture_output=True, text=True, + # Git emits path names as UTF-8; decoding them with the process locale + # leaves stdout None for the split below when one tracked file has a + # non-ASCII name and the host codepage cannot represent it. + encoding="utf-8", ) fields = result.stdout.split("\0") if fields and not fields[-1]: diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index c39c9635..05925cf7 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -59,18 +59,32 @@ def git_command( command.extend(["--git-dir", str(git_dir), "--work-tree", str(work_tree)]) full_command = [*command, *args] try: + if text: + # Git reports paths as UTF-8; text=True alone would decode them with + # the process locale, which on Windows is the ANSI codepage. A + # multi-byte codepage raises in subprocess's reader thread and leaves + # stdout None, which git_output then calls .strip() on. + return subprocess.run( + full_command, + check=False, + capture_output=True, + env=environment, + text=True, + encoding="utf-8", + ) return subprocess.run( full_command, check=False, capture_output=True, env=environment, - text=text, + text=False, ) except FileNotFoundError: # Git is optional for Codebase scans. Treat an unavailable executable like # any other failed Git probe so the target falls back to a directory snapshot. - empty_output = "" if text else b"" - return subprocess.CompletedProcess(full_command, 127, empty_output, empty_output) + if text: + return subprocess.CompletedProcess(full_command, 127, "", "") + return subprocess.CompletedProcess(full_command, 127, b"", b"") def update_digest_field(digest: Any, label: bytes, value: bytes) -> None: