From b6f7cfdcfbd6fb0cb03299de99c69889899edb52 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:04:29 +0000 Subject: [PATCH] shape_materialize.run() and 26 other git and gh calls read what the child wrote as UTF-8 whatever the locale, so an `adopt-project.py execute` whose leg push is rejected with a message naming a non-ASCII path ends `REFUSED pushing the spec leg` and the ruleset hint under `LC_ALL=C PYTHONUTF8=0`, not a UnicodeDecodeError traceback (#217) `subprocess.run(..., text=True)` with no `encoding=` decodes a child's output with the LOCALE'S codec: ASCII under a POSIX `C` locale with UTF-8 mode off, the ANSI code page on Windows without PYTHONUTF8=1. Git writes a path as UTF-8, so a byte that codec cannot read raised `UnicodeDecodeError` inside `subprocess.run`, before `CommandFailed` existed. `execute` died with a traceback and exit 1 where `REFUSED pushing the spec leg` and the ruleset hint belong, with both leg remotes already created. Thirty text-mode calls in the shipped tools named no codec; this names one on twenty-eight of them. - `encoding="utf-8", errors="replace"` on 25 calls whose output is shown, parsed or compared: `shape_materialize.run()`, which carries most of the git calls `execute` makes; the commit helpers of bump-leg.py, family.py and update-shape.py; the `gh repo view` and `gh api` probes of scaffold-project.py, family.py and repo_shape.py; and the git calls of adopt-project.py (`_require_filter_repo`), bump-leg.py, scaffold-project.py, update-shape.py and the family root's bootstrap.py and siblings.py. A byte that is not UTF-8 is read as U+FFFD rather than raised. - UTF-8 and STRICT, no `errors=`, on the two `gh api` tree listings that become a digest (scaffold-project.py `pin_digest_from_gh`, repo_shape.py `tree_digest_from_gh`): a replaced byte would be a wrong digest stated as fact. - `errors="replace"` and no `encoding=` on bump-leg.py `run_validator`: the child is a Python process writing in the locale's own codec, so that codec reads it, and `replace` keeps two interpreters that disagree from turning a refusal into a traceback. - tests/test_git_output_decoding.py drives `run()` in a child interpreter started under `LC_ALL=C PYTHONUTF8=0` with a UTF-8 name and a non-UTF-8 one, and reads every shipped tool's `subprocess` calls so that a text-mode call naming no codec fails the suite. Two calls are left to sibling pull requests and listed in the guard's EXEMPT table, so no pair of branches carries a conflicting hunk: `shape_materialize.env_commit` (#196's branch gives it both arguments) and adopt-project.py `cross_leg_references` (#216's branch reads its `git grep` as bytes). Unchanged: in a UTF-8 locale the repro's `plan`, `check` and `execute` output is byte-identical to main's once the run directory is masked, and `execute` still exits 2. The Windows job already sets PYTHONUTF8=1 and skips the C-locale test. Left out: on a git without `commit --trailer`, a non-ASCII `--trailer` travels in the stdin message, which under `LC_ALL=C PYTHONUTF8=0` is now encoded with `replace` (`Jos??`) where main raised UnicodeEncodeError; carrying it as typed needs the commit helpers to encode stdin with `surrogateescape` apart from decoding their output. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 3 +- scaffold-project.py | 17 +- scripts/bump-leg.py | 23 ++- scripts/family.py | 13 +- scripts/repo_shape.py | 8 +- scripts/shape_materialize.py | 8 +- templates/family-root/scripts/bootstrap.py | 2 +- templates/family-root/scripts/siblings.py | 11 +- tests/test_git_output_decoding.py | 188 +++++++++++++++++++++ update-shape.py | 12 +- 10 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 tests/test_git_output_decoding.py diff --git a/adopt-project.py b/adopt-project.py index c96d9a07..3bb4f50c 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -2011,7 +2011,8 @@ def cmd_check(args) -> int: def _require_filter_repo() -> None: if shutil.which("git-filter-repo") is None: probe = subprocess.run(["git", "filter-repo", "--version"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) if probe.returncode != 0: raise Refusal( "filter-repo-missing", diff --git a/scaffold-project.py b/scaffold-project.py index aa46b941..ea74ce6d 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -129,7 +129,10 @@ def pin_digest_from_gh(repository: str, commit: str) -> str: """ proc = subprocess.run( ["gh", "api", GH_TREE_API.format(repo=repository, commit=commit)], - capture_output=True, text=True, check=False) + # UTF-8 and STRICT, not `errors="replace"`: this JSON becomes a + # digest, and a replaced byte would be a wrong digest with a + # confident tone (#217). + capture_output=True, text=True, encoding="utf-8", check=False) if proc.returncode != 0: raise Refusal( "pin-unreadable", @@ -177,12 +180,14 @@ def remote_is_empty(repository: str) -> bool | None: reliable signal — `size: 0` is also what a repository of small files says. """ probe = subprocess.run(["gh", "repo", "view", repository, "--json", "name"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) if probe.returncode != 0: return None commits = subprocess.run( ["gh", "api", f"repos/{repository}/commits?per_page=1"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, encoding="utf-8", errors="replace", + check=False) if commits.returncode != 0: blob = (commits.stderr + commits.stdout).lower() if "empty" in blob: @@ -207,7 +212,8 @@ def remote_visibility(repository: str) -> str | None: """ proc = subprocess.run( ["gh", "repo", "view", repository, "--json", "visibility"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, encoding="utf-8", errors="replace", + check=False) if proc.returncode != 0: return None try: @@ -222,7 +228,8 @@ def local_remote_is_empty(bare: Path) -> bool | None: if not bare.exists(): return None out = subprocess.run(["git", "-C", str(bare), "rev-list", "--count", "--all"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) if out.returncode != 0: raise Refusal("remote-unreadable", f"{bare} exists but is not a git repository") diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 1e9aac2e..0d4a49b8 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -282,7 +282,7 @@ def leg_placement(submodule: Path) -> str | None: """ proc = subprocess.run(["git", "symbolic-ref", "--quiet", "HEAD"], cwd=str(submodule), capture_output=True, text=True, - check=False) + encoding="utf-8", errors="replace", check=False) return proc.stdout.strip() or None @@ -298,7 +298,8 @@ def restore_leg(submodule: Path, placement: str | None, commit: str) -> None: def object_present(submodule: Path, commit: str) -> bool: proc = subprocess.run( ["git", "rev-parse", "--verify", "--quiet", f"{commit}^{{commit}}"], - cwd=str(submodule), capture_output=True, text=True, check=False) + cwd=str(submodule), capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) return proc.returncode == 0 @@ -307,7 +308,8 @@ def on_a_remote_branch(submodule: Path, commit: str) -> bool: proc = subprocess.run( ["git", "for-each-ref", "--contains", commit, "--format=%(refname)", "refs/remotes/origin/"], - cwd=str(submodule), capture_output=True, text=True, check=False) + cwd=str(submodule), capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) return proc.returncode == 0 and bool(proc.stdout.strip()) @@ -387,8 +389,15 @@ def run_validator(root: Path, script: str) -> tuple[int, str]: """The ROOT's own copy of a validator, its output captured for a refusal.""" if not (root / script).is_file(): return 0, f" {script} is absent; SKIPPED\n" + # `errors="replace"` and no `encoding=`: the child is a Python process, + # which writes its pipe in the locale's codec, so that codec is the one + # that reads it back (UTF-8 here would misread a Windows ANSI child). + # `replace` is for two interpreters that disagree (`-X utf8` on this one + # only): a refusal must not become a traceback on its way to printing the + # validator's words (#217). proc = subprocess.run([sys.executable, script], cwd=str(root), - capture_output=True, text=True, check=False) + capture_output=True, text=True, errors="replace", + check=False) return proc.returncode, (proc.stdout or "") + (proc.stderr or "") @@ -430,7 +439,9 @@ def commit_once(root: Path, message: str, paths: list[str], if not env.get(key): env[key] = fallback proc = subprocess.run(args, cwd=str(root), input=message, - capture_output=True, text=True, check=False, env=env) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False, + env=env) if proc.returncode != 0: raise CommandFailed(args, root, proc.returncode, proc.stderr + proc.stdout) @@ -440,7 +451,7 @@ def commit_once(root: Path, message: str, paths: list[str], def current_branch(root: Path) -> str | None: proc = subprocess.run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], cwd=str(root), capture_output=True, text=True, - check=False) + encoding="utf-8", errors="replace", check=False) return proc.stdout.strip() or None diff --git a/scripts/family.py b/scripts/family.py index 560e2844..1cc36710 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -338,7 +338,9 @@ def _commit_one(root: Path, message: str, paths: list[str], if not env.get(key): env[key] = fallback proc = subprocess.run(args, cwd=str(root), input=message, - capture_output=True, text=True, check=False, env=env) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False, + env=env) if proc.returncode != 0: raise CommandFailed(args, root, proc.returncode, proc.stderr + proc.stdout) @@ -354,12 +356,14 @@ def _remote_is_empty(repository: str) -> bool | None: reliable signal — `size: 0` is also what a repository of small files says. """ probe = subprocess.run(["gh", "repo", "view", repository, "--json", "name"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) if probe.returncode != 0: return None commits = subprocess.run( ["gh", "api", f"repos/{repository}/commits?per_page=1"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, encoding="utf-8", errors="replace", + check=False) if commits.returncode != 0: if "empty" in (commits.stderr + commits.stdout).lower(): return True @@ -378,7 +382,8 @@ def _local_remote_is_empty(bare: Path) -> bool | None: if not bare.exists(): return None out = subprocess.run(["git", "-C", str(bare), "rev-list", "--count", - "--all"], capture_output=True, text=True, check=False) + "--all"], capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) if out.returncode != 0: raise Refusal("remote-unreadable", f"{bare} exists but is not a git repository") diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 1b07e105..efbc9b68 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -720,7 +720,10 @@ def tree_digest_from_gh(repository: str, commit: str) -> str: try: proc = subprocess.run( ["gh", "api", GH_TREE_API.format(repo=repository, commit=commit)], - capture_output=True, text=True, check=False) + # UTF-8 and STRICT, not `errors="replace"`: this JSON becomes a + # digest, and a replaced byte would be a wrong digest with a + # confident tone (#217). + capture_output=True, text=True, encoding="utf-8", check=False) except FileNotFoundError as exc: raise Refusal("gh-not-found", f"the `gh` CLI is not on PATH: {exc}") from exc @@ -788,7 +791,8 @@ def free_plan_secret_hint(org: str, repo: str, what: str) -> str | None: try: proc = subprocess.run( ["gh", "api", f"orgs/{org}", "--jq", ".plan.name"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, encoding="utf-8", + errors="replace", check=False) except OSError: # FileNotFoundError (no `gh` on PATH) is already an OSError return None if proc.returncode != 0: diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index b75f842b..60672145 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -478,8 +478,14 @@ def check_program(args: list[str]) -> None: def run(args: list[str], cwd: Path | None = None, capture: bool = True) -> str: check_program(args) + # UTF-8, whatever the locale: git writes a path as UTF-8, and the locale's + # codec (ASCII under a POSIX `C` locale, the ANSI code page on Windows) + # raises `UnicodeDecodeError` out of `subprocess.run` on one it cannot + # read -- before `CommandFailed` exists, so the person gets a traceback + # where `REFUSED ` and the ruleset hint belong (#217). proc = subprocess.run(args, cwd=str(cwd) if cwd else None, - capture_output=capture, text=True, check=False) + capture_output=capture, text=True, + encoding="utf-8", errors="replace", check=False) if proc.returncode != 0: raise CommandFailed(args, cwd, proc.returncode, (proc.stderr or "") + (proc.stdout or "")) diff --git a/templates/family-root/scripts/bootstrap.py b/templates/family-root/scripts/bootstrap.py index 314376d0..6668790c 100755 --- a/templates/family-root/scripts/bootstrap.py +++ b/templates/family-root/scripts/bootstrap.py @@ -89,7 +89,7 @@ def fetch_members(root: Path) -> bool: print(f" credential source: {source}") sys.stdout.flush() proc = subprocess.run(args, cwd=str(root), capture_output=True, text=True, - check=False) + encoding="utf-8", errors="replace", check=False) if proc.returncode == 0: print(" every member and leg fetched") return True diff --git a/templates/family-root/scripts/siblings.py b/templates/family-root/scripts/siblings.py index 597f66fc..86398cf9 100755 --- a/templates/family-root/scripts/siblings.py +++ b/templates/family-root/scripts/siblings.py @@ -171,7 +171,8 @@ def submodule_urls(root: Path) -> dict[str, str]: if not path.is_file(): return {} proc = subprocess.run(["git", "config", "-f", str(path), "--list"], - capture_output=True, text=True, check=False) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) if proc.returncode != 0: return {} paths: dict[str, str] = {} @@ -299,7 +300,8 @@ def scrub(text: str, token: str) -> str: def git_text(args: list[str], cwd: Path) -> str | None: proc = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, - text=True, check=False) + text=True, encoding="utf-8", errors="replace", + check=False) return proc.stdout.strip() if proc.returncode == 0 else None @@ -451,7 +453,8 @@ def place(root: Path, row: dict, urls: dict[str, str], prefix: list[str], proc = subprocess.run( ["git", *prefix, "clone", "-q", "--recurse-submodules", "--", url, str(target)], - capture_output=True, text=True, check=False) + capture_output=True, text=True, encoding="utf-8", + errors="replace", check=False) if proc.returncode != 0: sibling.state = "CLONE FAILED" sibling.finding = ( @@ -475,7 +478,7 @@ def place(root: Path, row: dict, urls: dict[str, str], prefix: list[str], print(f" [{project}] present at {target}; fetching only") fetched = subprocess.run(["git", *prefix, "fetch", "--quiet", "origin"], cwd=str(target), capture_output=True, text=True, - check=False) + encoding="utf-8", errors="replace", check=False) # NO CHECKOUT, NO RESET, NO PULL: somebody is working in here. sibling.state = "present, fetched" if fetched.returncode == 0 \ else "present, fetch failed" diff --git a/tests/test_git_output_decoding.py b/tests/test_git_output_decoding.py new file mode 100644 index 00000000..def5070a --- /dev/null +++ b/tests/test_git_output_decoding.py @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A child's words are read as UTF-8, whatever the locale (#217). + +`shape_materialize.run` carries most of the `git` and `gh` calls `execute` +makes, and it used to decode what they wrote with the LOCALE'S codec: ASCII +under a POSIX `C` locale with UTF-8 mode off, the ANSI code page on Windows +without `PYTHONUTF8=1`. Git writes a path as UTF-8, so a message that named +`Übersicht.md` raised `UnicodeDecodeError` out of `subprocess.run` before +`CommandFailed` was built, and the person got a traceback and exit 1 where +`REFUSED pushing the spec leg` and the ruleset hint belong, with both leg +remotes already created. + +TWO TESTS, because the cause is one line repeated in thirty places: + + * `test_run_names_the_path_git_complained_of_under_a_c_locale` drives the + fixed `run` in a CHILD interpreter started with `LC_ALL=C PYTHONUTF8=0`. + A codec is fixed when the interpreter starts, so this process (UTF-8 mode + on, or a UTF-8 locale) cannot reproduce it by setting an environment + variable afterwards. + * `test_no_shipped_text_mode_call_decodes_with_the_locale_codec` reads + every shipped tool's `subprocess` calls and fails on one that asks for + text without naming the codec, so a thirty-first cannot arrive. +""" + +from __future__ import annotations + +import ast +import codecs +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import REPO, git + +#: What the child does, as a program rather than a function here, because it +#: has to run in an interpreter this one is not. Every argument it reads is +#: ASCII (the name travels as hex), and it prints ONE line of ASCII JSON, so a +#: stdout that cannot encode `Ü` is not what the test is measuring. +#: `os.fsdecode` turns the name's bytes into the `str` that `subprocess` turns +#: back into exactly those bytes on the command line, whatever the codec. +CHILD = r""" +import json, locale, os, sys +sys.path.insert(0, sys.argv[1]) +import shape_materialize +repo, name = sys.argv[2], os.fsdecode(bytes.fromhex(sys.argv[3])) +answer = {"codec": locale.getpreferredencoding(False)} +try: + shape_materialize.run(["git", "-C", repo, "add", "--", name]) +except shape_materialize.CommandFailed as exc: + answer["refused"] = exc.loudly("staging the file") +except Exception as exc: + answer["raised"] = type(exc).__name__ + ": " + str(exc) +print(json.dumps(answer)) +""" + +#: The name as git wrote it (UTF-8 bytes) and as `run` must hand it back. The +#: second is NOT valid UTF-8: git writes the bytes it was given, `errors= +#: "replace"` turns the one it cannot read into U+FFFD, and a strict UTF-8 +#: decode would have been the same traceback again. +NAMES = [ + pytest.param("Übersicht.md".encode("utf-8"), "Übersicht.md", id="utf-8"), + pytest.param(b"caf\xe9.md", "caf�.md", id="not-utf-8"), +] + + +@pytest.mark.skipif( + os.name == "nt", + reason="a C locale is a POSIX idea: Windows decodes with the ANSI code " + "page, and its job sets PYTHONUTF8=1 (.github/workflows/tests.yml)") +@pytest.mark.parametrize("written, expected", NAMES) +def test_run_names_the_path_git_complained_of_under_a_c_locale( + tmp_path, written, expected): + repo = tmp_path / "repo" + repo.mkdir() + git("init", "-q", ".", cwd=repo) + env = {key: value for key, value in os.environ.items() + if not key.startswith(("LC_", "LANG", "PYTHONUTF8", + "PYTHONIOENCODING", "PYTHONCOERCE"))} + env.update({"LC_ALL": "C", "PYTHONUTF8": "0"}) + proc = subprocess.run( + [sys.executable, "-c", CHILD, str(REPO / "scripts"), str(repo), + written.hex()], + capture_output=True, env=env, stdin=subprocess.DEVNULL, check=False) + stderr = proc.stderr.decode("utf-8", "replace") + assert proc.returncode == 0, f"the child died:\n{stderr}" + answer = json.loads(proc.stdout.decode("ascii")) + if codecs.lookup(answer["codec"]).name == "utf-8": + pytest.skip(f"this platform reads a child's output as {answer['codec']} " + "under LC_ALL=C PYTHONUTF8=0, so there is no non-UTF-8 " + "locale to reproduce the failure in") + assert "raised" not in answer, ( + f"run() raised in place of a refusal under {answer['codec']}: " + f"{answer['raised']}") + assert answer["refused"].startswith("REFUSED staging the file."), answer + assert f"pathspec '{expected}' did not match any files" in answer["refused"] + assert "UnicodeDecodeError" not in answer["refused"] + stderr + + +#: The calls `subprocess` exposes that take `text=` and `encoding=`. +SPAWNERS = {"run", "Popen", "check_output", "check_call", "call"} + +#: A call that asks for text must name BOTH, unless it is listed here. Keyed +#: by (file, the function the call is in); the value is what it must still +#: name, and the reason it differs. +EXEMPT = { + # #196's branch gives this call both arguments; until it lands, the two + # tools' fixes stay in separate pull requests rather than one conflicting + # hunk. + ("scripts/shape_materialize.py", "env_commit"): frozenset(), + # #216's branch reads this `git grep` as BYTES (a path is bytes, and it + # spells each one itself), which leaves nothing here to decode; the same + # two-pull-requests reason as above. + ("adopt-project.py", "cross_leg_references"): frozenset(), + # The child is a PYTHON process, which writes its pipe in the locale's own + # codec, so naming UTF-8 here would misread it where the locale is not. + ("scripts/bump-leg.py", "run_validator"): frozenset({"errors"}), + # The `gh api` tree listing becomes a digest: UTF-8 yes, `replace` no, for + # a replaced byte would be a wrong digest with a confident tone. + ("scaffold-project.py", "pin_digest_from_gh"): frozenset({"encoding"}), + ("scripts/repo_shape.py", "tree_digest_from_gh"): frozenset({"encoding"}), +} + + +def shipped_python() -> list[Path]: + """Every Python file this standard ships, tests excluded.""" + found = [*REPO.glob("*.py"), *REPO.glob("scripts/*.py"), + *REPO.glob("templates/**/*.py"), REPO / "bootstrap"] + return sorted(found) + + +class TextModeCalls(ast.NodeVisitor): + """Each `subprocess.(..., text=True)` and the keywords it names.""" + + def __init__(self) -> None: + self.found: list[tuple[int, str, set[str]]] = [] + self.scope: list[str] = [] + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self.scope.append(node.name) + self.generic_visit(node) + self.scope.pop() + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_Call(self, node: ast.Call) -> None: + func = node.func + if (isinstance(func, ast.Attribute) and func.attr in SPAWNERS + and isinstance(func.value, ast.Name) + and func.value.id == "subprocess"): + named = {keyword.arg for keyword in node.keywords if keyword.arg} + asks = [keyword for keyword in node.keywords + if keyword.arg in ("text", "universal_newlines")] + # `text=False` is bytes; anything else (True, a variable) is text. + if any(not (isinstance(keyword.value, ast.Constant) + and keyword.value.value is False) for keyword in asks): + self.found.append((node.lineno, + self.scope[-1] if self.scope else "", + named)) + self.generic_visit(node) + + +def test_no_shipped_text_mode_call_decodes_with_the_locale_codec(): + offenders: list[str] = [] + inspected: set[tuple[str, str]] = set() + for path in shipped_python(): + visitor = TextModeCalls() + visitor.visit(ast.parse(path.read_text(encoding="utf-8"))) + relative = path.relative_to(REPO).as_posix() + for line, function, named in visitor.found: + inspected.add((relative, function)) + need = EXEMPT.get((relative, function), + frozenset({"encoding", "errors"})) + if need - named: + offenders.append( + f"{relative}:{line} ({function or 'module level'}) asks " + f"for text and does not name {sorted(need - named)}") + assert ("scripts/shape_materialize.py", "run") in inspected, ( + "the scan no longer finds `run()`'s call, so it is not reading " + "what it claims to") + assert not offenders, ( + "a text-mode `subprocess` call decodes with the LOCALE'S codec, which " + "is ASCII under a C locale and the ANSI code page on Windows, and git " + "writes UTF-8 (#217). Add `encoding=\"utf-8\", errors=\"replace\"`:\n " + + "\n ".join(offenders)) diff --git a/update-shape.py b/update-shape.py index 553c1508..3e16d0d5 100755 --- a/update-shape.py +++ b/update-shape.py @@ -326,7 +326,8 @@ def open_upstream(spec: str) -> Upstream: url = f"https://github.com/{spec}.git" args = ["git", "clone", "--quiet", "--bare", url, str(temporary / "shape.git")] check_program(args) - proc = subprocess.run(args, capture_output=True, text=True, check=False) + proc = subprocess.run(args, capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) if proc.returncode != 0: shutil.rmtree(temporary, ignore_errors=True) raise Refusal( @@ -951,7 +952,8 @@ def _git_answer(root: Path, args: list) -> "str | None": """ try: proc = subprocess.run(["git", *args], cwd=str(root), - capture_output=True, text=True, check=False) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) except OSError: return None return proc.stdout if proc.returncode == 0 else None @@ -1439,7 +1441,7 @@ def current_branch(root: Path) -> str | None: """ proc = subprocess.run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], cwd=str(root), capture_output=True, text=True, - check=False) + encoding="utf-8", errors="replace", check=False) return proc.stdout.strip() or None @@ -1556,7 +1558,9 @@ def commit_on_branch(root: Path, branch: str, paths: list[str], target: str, args = ["git", "commit", "-q", "-F", "-", *trailer_args, "--", *paths] check_program(args) proc = subprocess.run(args, cwd=str(root), input=message, - capture_output=True, text=True, check=False, env=env) + capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False, + env=env) if proc.returncode != 0: raise CommandFailed(args, root, proc.returncode, proc.stderr + proc.stdout)