Repository navigation
adopt-project.py and five other tools escape a name their stdout cannot encode, so plan on 日本.md under a cp1252 stdout (strict, or Windows' default surrogateescape) no longer exits 1 after writing the plan (#214) - #246
Conversation
…py, family.py and bump-leg.py escape a name their stdout cannot encode rather than raise on it, so `plan` on a source holding `日本.md` under a cp1252 stdout no longer exits 1 after writing the plan, before `plan written to` and its questions (#214) WHAT WAS WRONG. `cmd_plan` writes the plan file and then prints its report with plain `print`s. On a stdout whose encoding cannot hold a name -- on Windows, any stdout that is not the console itself: a file, a pipe to `tee`, CI, an agent running the tool, which Python gives the ANSI code page, cp1252 on a Western-European install -- the table raised `UnicodeEncodeError` two rows in, and `plan` exited 1 with a traceback after the plan was on disk and before `plan written to` and the questions it exists to ask. Emulated with `PYTHONIOENCODING=cp1252:strict` on origin/main 20e13f0, the same raise is at the first plain `print` of a path or a person's text in six tools; each run exits 0 (the doctor's NOT A SHAPE ROOT, 2) under `utf-8:strict`: adopt-project.py:1588 `plan`'s table (a source file `日本.md`); also its `source` line (a `--source` path) and `plan written to` (an `--out` path) adopt-project.py:2141 `check`'s `WHAT WILL HAPPEN, against` (the source) shape-doctor.py:3690 `root` (a `--root` path; its rows are ASCII) update-shape.py:1245 `check`'s `root` (a `--root` path) scaffold-project.py:602 the plan (`--name`, `--elected-by`; :620 a `--work-dir` or `--local-remote-dir` path) scripts/family.py:563 `init`'s plan (`--name`, `--created-by`; :567 an `--into` or `--local-remote-dir` path) scripts/bump-leg.py:706 `project` (the project's display name) THE FIX. `shape_materialize.escape_what_stdout_cannot_encode()`, the first statement of each tool's `main()`: a stdout whose error handler is `strict` is reconfigured to `backslashreplace`, the handler Python already gives stderr. `日本.md` prints as `日本.md` on a cp1252 stdout, and `plan` exits 0 with its whole report. WHY THIS, AND NOT FLATTENING EACH NAME WHERE IT IS PRINTED (the issue's other candidate): - Nothing that printed prints differently. `backslashreplace` writes what `strict` writes and differs only where `strict` raises. Checked by running main's tools and this head's on the same inputs under `utf-8:strict` and under this container's own locale: identical exit, stdout and stderr in every pair once each checkout's own path is normalised. The advisory's `printable` (ASCII, `?`) would print `??.md` on a UTF-8 terminal that shows `日本.md` today, and lose which file it was. - Smallest: one helper and one call per tool, against nine raising sites in six files, and the next `print` somebody adds. - Composes with #199, whose `_shown()` rewrites the two `print`s in `_print_entries` and `plan`'s question list -- the lines a per-site fix would rewrite too. This touches neither. ONLY `strict` IS CHANGED, and the encoding never is. UTF-8 mode and the POSIX locale give stdout `surrogateescape`, which writes a name that is not UTF-8 back as its own bytes (this container's stdout is `utf-8 surrogateescape`); switching it would turn that byte into `\udcff` in a run that works today. So any handler but `strict` is left as it was. stderr is not touched. The helper is in `shape_materialize.py`, not `repo_shape.py`: that one is a shape copy every project digests, and a helper no project calls would put an `upstream-changed` row in front of each of them. TESTS. tests/test_stdout_encoding.py: `plan` on a source holding `日本.md` (answered, in the table) and `東京.ipynb` (asked, in the question list), under cp1252:strict and utf-8:strict, read as bytes and decoded strictly in the encoding the run was given; `check`, the doctor, `update-shape.py check`, `bump-leg.py --dry-run` and the scaffold's and `family.py init`'s dry runs, each under both, exiting alike with the name shown; and the helper's three promises in process (escapes only what it cannot encode, leaves `surrogateescape` alone, fails nothing without `reconfigure`). On origin/main 20e13f0, 10 of the 11 fail -- the utf-8 `plan` control passes -- each cp1252 run at the line listed above. LEFT OUT. `scripts/validate-repository-naming.py`, the project validators and `bootstrap.py` are shape copies and were not changed; `setup-project.py` already sets its own streams. A stdout set by hand to `<code page>:surrogateescape` still raises on a name outside the code page: an operator's explicit handler is theirs. A `plan` follow-up naming a code file with a non-ASCII name prints git's quoted octal (`code/"src/\346\227\245\346\234\254.py"`, `git grep -l` under core.quotepath), a separate defect. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very Windows stdout by default, now escapes a name it cannot encode as well and still writes a byte that is not UTF-8 back as that byte, so a redirected cp1252 `plan` on `日本.md` exits 0 on real Windows too; the root `bootstrap` shim calls the same helper WHAT WAS WRONG WITH 3006303. Its guard changed a stdout whose handler is `strict` and returned for every other one. On Windows a redirected stdout is never `strict`. CPython gives EVERY Windows stdout the `surrogateescape` handler by default: `config_get_stdio_errors()` in `Python/initconfig.c` ends `/* On Windows, always use surrogateescape by default */ return L"surrogateescape";` (3.9 and 3.12/3.13 alike), and `Doc/c-api/init_config.rst` says "Default error handler: On Windows: use "surrogateescape"". A stdout that is not the console gets the locale encoding, the ANSI code page. So the issue's own case, a file, a pipe to `tee`, CI or an agent on a Western-European Windows, is `cp1252:surrogateescape`. That handler covers only U+DC80..U+DCFF, so `日本.md` still raised, and the helper did nothing. The tests stood in for Windows with `cp1252:strict`, which Windows never gives by default (`PYTHONIOENCODING=cp1252` with no handler does, and still works). Reproduced by the review under `PYTHONIOENCODING=cp1252:surrogateescape`: `plan` exited 1 in `_print_entries`, unchanged from main. THE FIX. `escape_what_stdout_cannot_encode()` now changes two handlers and no others, and never the encoding: - `strict` becomes `backslashreplace`, as before. - `surrogateescape` becomes `openreposhape.surrogateescape-then- backslashreplace` (`SURROGATEESCAPE_THEN_BACKSLASHREPLACE`), registered with `codecs.register_error` by the first call that needs it and by no other. It writes a surrogate-escaped byte back as that byte, which is surrogateescape's own rule, and anything else the encoding cannot hold in `codecs.backslashreplace_errors`' spelling. It is the review's prototype, with the run of unencodable characters split into escaped-byte and other stretches, so `caf\udcff` followed by `日本` writes `caf` 0xff then `\u65e5\u672c`. - Every other handler (`replace`, `backslashreplace`, `ignore`, `xmlcharrefreplace`, `namereplace`, `surrogatepass`, one an operator set) is left as it was. Each replacement writes what the handler it replaces would have written and differs only where that one raised. In particular the inherited `utf-8:surrogateescape` of UTF-8 mode and the C locale still writes a name that is not UTF-8 as its own bytes. `日本.md` prints as `\u65e5\u672c.md` on a cp1252 stdout under either handler. The same applies to the `ascii:surrogateescape` of `LC_ALL=C PYTHONUTF8=0` (checked in process). The root `bootstrap` shim (not a shape copy: no copy list or `shape-pin.yaml` names it; projects hold `templates/*/scripts/bootstrap.py`) now imports and calls the helper before it hands over to the canonical bootstrap with `runpy`. Run in a directory named `日本` under cp1252 it exited 1 at `print(f"bootstrap: {name} ({root})")`. TESTS. tests/test_stdout_encoding.py, 29 tests: - Every tool run is now made under `cp1252:surrogateescape` (the Windows default), `cp1252:strict` and `utf-8:strict`. The tool runs are `plan` and `check`, the doctor, `update-shape.py check`, `bump-leg.py --dry-run`, the scaffold's and `family.py init`'s dry runs, and now the `bootstrap` shim. - `plan --out` into a directory whose name holds byte 0xff, under `utf-8:surrogateescape` and `cp1252:surrogateescape`: the raw byte comes out raw, and in the cp1252 run `日本.md` is escaped in the same report. POSIX filesystems only; skipped where a name that is not UTF-8 cannot be made. - In process: a surrogateescape stream in utf-8 and in cp1252 writes the raw byte and escapes the rest, with the encoding kept; a second call changes nothing; the handler is registered once, under a name that starts `openreposhape.`; six other handlers are left alone. - `reconfigure` raising a plain `ValueError`, a plain `OSError`, a closed stream, a stream with no `reconfigure`, and no stdout fail nothing. On 3006303 the same file gives 12 failed, 17 passed. The failures are every cp1252:surrogateescape run, each a `UnicodeEncodeError` (7 tool runs, `plan`, the raw-byte cp1252 case), and the three in-process tests that need the new handler. THE CALL'S PLACE. Moving the doctor's call after `parse_args` changes nothing anyone can observe, so no test tells the two apart. Before the first report line, only argparse writes to stdout, and only for `--help`. Every tool's and subcommand's `--help` is pure ASCII (17 outputs, 7 tools and 10 subcommands, under `ascii:strict`: all exit 0, no byte above 0x7f). The doctor's `prog` is fixed and its help interpolates nothing from the environment. A usage error goes to stderr, which is already `backslashreplace`. CORRECTIONS TO 3006303's MESSAGE (pushed, so not amended): - "`日本.md` prints as `日本.md`" lost its escapes; it is `\u65e5\u672c.md`. - "identical exit, stdout and stderr in every pair" holds for every pair that completes on main. Where main raised, its partial stdout is a byte prefix of the head's. - "ONLY `strict` IS CHANGED", and the Left-out line that called a `<code page>:surrogateescape` stdout an operator's explicit choice, are both superseded above. On Windows that handler is the default. LEFT OUT. A real `bump-leg.py` run, and `update-shape.py apply`, still fail under a code page that cannot hold the project's display name. Their child `validate-manifest.py` prints `manifest ok: <name>` with the inherited stdout and its own default handler. The fix belongs in those two tools' `run_validator`. `bump-leg.py`'s is the call #230 (fix/decode-git-output-utf8) rewrites, with the opposite decoding choice (the locale codec with `errors="replace"`), so it is left to a follow-up that composes with #230. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 1 day and 3 hours by commenting |
|
(lane openRepoShape-5, 2026-10-11T20:54Z) @codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Reviewer's GuideThis PR prevents affected commands from failing after producing output when stdout cannot encode paths or user-provided names by safely adapting only strict and surrogateescape error handling, while preserving the stream encoding and adding comprehensive cross-platform regression tests. Sequence diagram for safe stdout encoding in CLI toolssequenceDiagram
participant User
participant ToolMain
participant StdoutHelper as shape_materialize.escape_what_stdout_cannot_encode
participant Stdout
participant Report
User->>ToolMain: main(argv)
ToolMain->>StdoutHelper: escape_what_stdout_cannot_encode()
StdoutHelper->>Stdout: inspect errors
alt errors is strict
StdoutHelper->>Stdout: reconfigure(errors=backslashreplace)
else errors is surrogateescape
StdoutHelper->>Stdout: reconfigure(errors=openreposhape.surrogateescape-then-backslashreplace)
else other handler or unavailable stream
StdoutHelper-->>ToolMain: leave stdout unchanged
end
ToolMain->>Report: print path or user-provided name
Report->>Stdout: encode using original stream encoding
Stdout-->>User: encoded name or escaped name, command continues
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…ange boundaries are pinned by 17 in-process cases, and `plan` on `日本.md` is run on a real default stdout, with no `PYTHONIOENCODING` at all (POSIX `ascii:surrogateescape` here, Windows' own in CI); the helper's docstring now names `C.UTF-8` as a `surrogateescape` locale R1, TESTS. The re-review found three mutants of the new error handler that all 29 tests let through: the stretch it backslash-escapes running to the end of the codec's span, so a raw byte AFTER an unencodable character in the same run is written \udcff and not as the byte; U+DC80 left out of the range of bytes surrogateescape makes; and U+DC00..U+DC7F let into it, which surrogateescape never makes and stdlib raises on. HANDLER_CASES in tests/test_stdout_encoding.py is 17 cases, in cp1252 and in utf-8, each run through a TextIOWrapper after the helper and through `str.encode` by the handler's name: - a raw byte before and after `日`, the lowest and highest raw bytes, the characters just under (U+DC7F), at the bottom of (U+DC00, and U+DC41, which a wrong range writes as `A`) and just over (U+DD00) the range, a high surrogate, and one run holding every kind; - in utf-8, where a run is made of surrogates alone, the same boundaries with a raw byte beside U+D800 and beside U+DC7F. Each mutant was applied in a throwaway worktree and run against the file: the stretch-to-span-end one fails 4 of the 47 tests that run, the U+DC80 one 5, the U+DC00 one 8, all in the new cases, and the other 39 to 43 still pass. On 3006303 the 17 fail too, for want of the handler; the mutants are what shows they tell a wrong boundary from a right one. R2, TESTS. No test ran a tool without a `PYTHONIOENCODING`, so none proved the premise of the first round: what CPython itself gives a redirected stdout. Now two do, through plan_on_the_default_stdout(), which gives the child NO `PYTHONIOENCODING` under any spelling and asks Python: - test_plan_on_the_posix_default_stdout: `LC_ALL=C PYTHONUTF8=0`, which gives `ascii:surrogateescape` (`PYTHONUTF8=0` because the C locale turns UTF-8 mode on, and 3.15 does everywhere). It runs on Linux, and on macOS when its C locale gives the same. - test_plan_on_the_windows_default_stdout: Windows only, `PYTHONUTF8=0` (CI's job sets `PYTHONUTF8=1`, and 3.15 turns UTF-8 mode on by default). It has not been run on Windows: only CI runs it. Before either runs `plan`, a child reports its own stdout's codec and handler. The test is SKIPPED, with the reason, when the handler is not surrogateescape or the code page holds `日本` (UTF-8 mode, cp932): never failed on a platform it does not test. Then `plan` runs on a source holding `日本.md` and `東京.ipynb`, and its stdout is decoded strictly in the probed codec, with the name escaped in the table and in the question list. On 3006303 the POSIX one exits 1 with `UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1`; on this head it passes. The first test's body is now plan_arguments() and assert_plan_reported_every_name(), shared; run_tool_bytes() takes `encoding=None` and `changes`, through child_environ(). R4, WORDING. The helper's docstring said `strict` is what "a UTF-8 or other non-C POSIX locale" gives stdout. `C.UTF-8`, the usual container locale, is a locale Python coerces C to, and gives `surrogateescape` (`config_get_stdio_errors`, `_Py_IsLocaleCoercionTarget`; observed here: `LC_ALL=C.UTF-8` is `utf-8 surrogateescape`). `strict` is a POSIX locale that is none of C, POSIX or such a target, `en_US.UTF-8` for one, with UTF-8 mode off. The docstring now says so, and lists `C.UTF-8` among what gives `surrogateescape`. No code changes. CORRECTIONS TO 4d4e844's MESSAGE (pushed, so not amended): - Its LEFT OUT paragraph says the child `validate-manifest.py` prints "with the inherited stdout". That holds for `update-shape.py`, whose child inherits stdout. `bump-leg.py` captures the child's output; its child takes the stdout CONFIGURATION from the environment, not the stdout. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-5, 2026-10-11T21:07Z) Review trail for this PR.
This PR conflicts with #247 (#199) in |
|
(lane openRepoShape-5, 2026-10-11T21:07Z) @codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…s skipped on Windows before its body runs, where `os.fsdecode` of that name raised `UnicodeDecodeError` and failed tests-windows twice PR #246's tests-windows job (run 38174834166) failed 2 of 2040 tests, both parametrizations of test_a_name_that_is_not_utf8_still_comes_out_as_its_own_bytes ([utf-8:surrogateescape] and [cp1252:surrogateescape]), with 1801 passed and 237 skipped: `parent = os.path.join(os.fsencode(tmp_path), b"b\xffd")` raised `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 1` at `<frozen ntpath>:112`. Windows' `os.fsencode` and `os.fsdecode` are UTF-8 (PEP 529), which has no byte 0xff, so a bytes path holding it cannot even be joined there. The docstring already said "POSIX only", but the only guard was the runtime skip after `os.mkdir`, which is never reached on Windows. macOS passed because its filesystem refuses the name and the `os.mkdir` skip fires. The test now wears `@pytest.mark.skipif(os.name == "nt", ...)`, the same condition its neighbour test_plan_on_the_posix_default_stdout wears, with a reason naming the cause. conftest.WINDOWS_SKIP is not used: its reason is about a POSIX shell and shebang execution, which is not this cause. The macOS runtime skip and every other line are unchanged. The rest of the file was read for the same exposure: no other test builds a bytes path, or calls os.fsencode or os.fsdecode, outside this one. The tests that hold U+DC80..U+DCFF write them to an in-memory io.BytesIO wrapper or to `str.encode`, and the file-system tests name directories `日本`, which is valid UTF-16 and UTF-8. Run: tests/test_stdout_encoding.py and tests/test_repo_hygiene.py on this head under Linux, 97 passed, 1 skipped (the Windows-default test). Not run on Windows or macOS: CI is the check. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fix/plan-report-stdout-encoding (#214) One conflict, scripts/shape_materialize.py, the single add/add hunk right after write_lf: #199's write_paths_listing (from main) and this branch's escape_what_stdout_cannot_encode with SURROGATEESCAPE_THEN_BACKSLASHREPLACE, _registered_surrogateescape_then_backslashreplace, _surrogateescape_then_backslashreplace and _is_escaped_byte. Resolved by keeping both sides whole, this branch's block first and write_paths_listing after it, two blank lines between. Every other file merged without a conflict and is taken as main has it. No code was written for the merge. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-5, 2026-10-11T21:34Z) @codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…) into fix/plan-report-stdout-encoding (#214) One conflict, resolved by keeping both sides: the `from shape_materialize import (...)` list in update-shape.py. This branch added `escape_what_stdout_cannot_encode` and main added `force_stage` (#202) in the same place. Both names are now in the list, in its existing order (commit_trailers, escape_what_stdout_cannot_encode, force_stage, lane_trailer_argument, ...), wrapped to 79 columns like its neighbours, so the list's tail lines re-flow with it. No other file conflicted, and nothing else was changed. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
(lane openRepoShape-4, 2026-10-11T22:05:55Z) Landing note: our #233 (#202) landed as 150c41d, and main is now 39ac59c. This PR now conflicts with main in one hunk only: Leave the next line, We applied this in scratch on 39ac59c + 164fa16. |
…, #252 via #257, #195 via #226, #210 via #219, #211 via #220, #248 via #265, #212 via #223) into fix/adopt-record-submodule-placement Brings in 06b5204 (#214), 60d08a1 (#217), 2727b08 (#209), 4b2add8 (#252), aaa40eb (#195), d89fffa (#210), eb6df75 (#211), c3f4987 (#248) and 5751460 (#212) on top of 39ac59c. No rebase. 5751460 landed while the merge of c3f4987 was being checked, and it changes adopt-project.py, scripts/shape_materialize.py and tests/test_adopt_submodules.py, as this branch does, so this one merge takes it too. No conflicts. Both sides change adopt-project.py, scripts/repo_shape.py, scripts/shape_materialize.py, shape-doctor.py, tests/test_adopt_submodules.py and tests/test_shape_doctor.py. Every top-level definition only one side changed is that side's text in the merge, byte for byte. The one thing both changed is shape_materialize.py's imports, and both are kept: main's `codecs` and this branch's `path_spelling_fault` and `PATH_*` codes. Checked: - adopt-project.py: main calls `escape_what_stdout_cannot_encode()` first in `main` (#214). It makes a `.gitmodules` git's submodule reader cannot read a finding where the tip holds no submodule and the history does, or where `ignore`, `update` or `branch` has no value (#195). And every path a finding names is now `_spelled` (#212). This branch's `submodule_record`, `_resolution_text` and the record's preamble are unchanged by the merge, and so are `_answered_legs`, `_kept_in_a_leg`, `_commit_the_split`, `_refuse_what_check_finds`, `cmd_execute` and `Plan`. The record and #195's finding read the kept submodules the same way, `_kept_in_a_leg(_answered_legs(...))` over the tip's gitlinks. `execute` refuses #195's finding in `_refuse_what_check_finds`, before any leg exists and before `_commit_the_split` writes the record. So a source #195 refuses gets no record, and so does a source whose tip holds no submodule. - scripts/repo_shape.py: main refuses an explicit key in a flow mapping and reads an empty flow-mapping value as null (#252). A block scalar's record is now a (key, body) pair, and `_BLOCK_SENTINEL` is gone (#248); nothing on this branch names it. The record is a block sequence of double-quoted scalars, and it reads back through the merged reader with 0 mismatches (`submodule_record`, then `parse_yaml`, path and resolution): - 4,525 names, plus 35 more; - 240 aimed at #252 and #248 (`? a`, `{a: }`, `|`, `a: |`, a NUL, the old sentinel). - scripts/shape_materialize.py: main adds `escape_what_stdout_cannot_encode`, `run()` reading UTF-8 (#217), and `first_and_more(paths, spell)` (#212). This branch's `LEG_SEGMENT_REASONS` and `_spelling_reason` are untouched. - shape-doctor.py: main adds the `manifest kinds` finding for a `family.yaml` beside `project.yaml` (#211), and the stdout escaping. This branch's placement row and `recorded_submodules` are untouched. - tests: #212 respells the finding text that the earlier tests in test_adopt_submodules.py assert. None of this branch's tests quotes a finding. - No top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py, shape-doctor.py, validate-manifest.py or update-shape.py. Each merged module's names are the base's, plus what either side added, minus what either side removed. No side added a name the other side also added, in the tests either. - AGENTS.md is untouched by main: 472 lines, under the cap of 472. - The branch's diff against main is still #189 alone: 12 files, +1670 -156, file by file the same as against 39ac59c. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, #209, #252, #195, #210, #211, #248, #212, #251 and #206 merge with one hunk, the materializer import list, kept on both sides, and `_entry_findings` still hands `_leg_findings` the tree and ends with the root-symlink findings origin/main moved 39ac59c -> 60588d5: 06b5204 (#214 via #246), 60d08a1 (#217 via #230), 2727b08 (#209 via #218), 4b2add8 (#252 via #257), aaa40eb (#195 via #226), d89fffa (#210 via #219), eb6df75 (#211 via #220), c3f4987 (#248 via #265), 5751460 (#212 via #223), 23e16ae (#251 via #271) and 60588d5 (#206; its subject names no pull request). Conflict, resolved by keeping both sides: - adopt-project.py, the `shape_materialize` import list. This branch adds BOOTSTRAP, SYMLINK_HOPS and template_files; #214 adds escape_what_stdout_cannot_encode. The resolution is the union. Auto-merged: scripts/shape_materialize.py, where #214's escaping, #217's UTF-8 `run()` and #212's `first_and_more(paths, spell=None)` sit apart from this branch's #183 section, and adopt-project.py's other hunks. #195 adds `import os` at the line where this branch added it, and the merge keeps it once. Checked on the merged tree: - `_entry_findings` still calls `_leg_findings(plan, tree)` and ends with `_root_symlink_findings(plan, source, tree)`. `main` still calls `escape_what_stdout_cannot_encode()` first. - No top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or scripts/shape_materialize.py, and none is loaded but bound nowhere (AST). `first_and_more` gives its new `spell` a default, so this branch's call is unchanged. - Both sides are intact by +/- line sets: each side's change against the base reappears against the other side, apart from the import hunk and the shared `import os`. - #212 says this branch's `plan-root-symlink` names its paths raw, and that whichever pull request lands second should spell them. That was 04792d2. Since d11cf9f every path the finding names goes through `_spelled`. One clash the textual merge does not show is fixed in the next commit: #217's tests/test_git_output_decoding.py refuses a shipped text-mode `subprocess` call that names no codec, and this branch's `_checks_out_symlinks` probe names none. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-notice It merged with no conflict. Main brings nine commits since 39ac59c: - 06b5204, six tools escape a name their stdout cannot encode (#214 via #246), with tests/test_stdout_encoding.py; - 60d08a1, `shape_materialize.run()` and 26 other git and gh calls read the child's output as UTF-8 (#217 via #230), with tests/test_git_output_decoding.py; - 2727b08, update-shape.py's `gh pr create --base` (#209 via #218), with tests/test_update_shape_pr_base.py; - 4b2add8, scripts/repo_shape.py's flow mappings (#252 via #257), with tests/test_yaml_flow_mappings.py; - aaa40eb, adopt-project.py refuses a `.gitmodules` the history's gitlinks make unreadable (#195 via #226), with tests/test_adopt_history_gitlinks.py; - d89fffa, family.py `bump` to the current pin (#210 via #219); - eb6df75, shape-doctor.py's family.yaml beside a project.yaml (#211 via #220); - c3f4987, scripts/repo_shape.py's block-scalar records (#248 via #265), with tests/test_yaml_block_scalar_keys.py; - 5751460, adopt-project.py spells every path a FINDING or a refusal names (#212 via #223), with tests/test_adopt_finding_spelling.py. Three files changed on both sides: adopt-project.py, tests/test_adopt_ignored_shape_files.py and tests/test_adopt_verify.py. In each, `git diff -U0` of this merge against 5a8b649 equals main's diff against 39ac59c, and this merge against 5751460 equals the branch's diff against 39ac59c, apart from the hunk headers. Neither side's lines are changed. How main's changes meet this branch's failure handling: - Every definition the late failures go through is byte-identical to 5a8b649's: LATE_FAILURES, the notice (`_nothing_rolled_back`), `_failed_loudly`, `_report_late`, the three builders' handlers, `_verify` and `cmd_execute` among them. Three definitions differ, each by main's hunk alone. - `main()` gains `escape_what_stdout_cannot_encode()` as its first statement (06b5204). The branch writes every REFUSED block and notice to stderr, which Python opens with `backslashreplace` whatever the locale. - `run()` names its codec (60d08a1). Output that is not UTF-8 now reaches the branch's handlers as `CommandFailed`, not as `UnicodeDecodeError`. LATE_FAILURES keeps `UnicodeError`, because `git_out` in scripts/repo_shape.py still decodes strictly. - `Source.registered_submodules` reads the history (aaa40eb). Its one caller in `execute` is `_refuse_what_check_finds`, before `_create_leg_remotes`, so the new refusal comes before any leg repository exists and needs no notice. - 5751460 spells the paths of the verification's findings. The branch's footer and notice follow those findings unchanged. No top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or scripts/shape_materialize.py, and a `symtable` check finds no name they read that nothing binds. On the merged tree, ten test files give 439 passed and 1 skipped (Windows' own stdout default): the two shared ones, main's four new ones that read adopt-project.py (finding_spelling, history_gitlinks, stdout_encoding, git_output_decoding), and test_adopt_late_failure, test_adopt_filter_repo_failure, test_adopt_execute_refusals and test_adopt_plan_escapes. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-5 (openRepoShape-5)
Claim: #214 (comment)
Closes #214.
adopt-project.py planwrote the plan, then raisedUnicodeEncodeErrorin its terminal report for a name its stdout could not encode, and exited 1 beforeplan written toand the questions it exists to ask. On Windows that happens on any stdout that is not the console itself: a file, a pipe totee, CI, an agent running the tool. Python gives such a stdout the ANSI code page (cp1252 on a Western-European install), which has no日本. It also gives it thesurrogateescapeerror handler, which CPython uses for EVERY Windows stdout by default.config_get_stdio_errors()inPython/initconfig.cends/* On Windows, always use surrogateescape by default */ return L"surrogateescape";in 3.9, 3.12 and 3.13, andDoc/c-api/init_config.rstsays the same ("Default error handler: On Windows: use "surrogateescape""). That handler covers only U+DC80..U+DCFF, so it raises on日本just asstrictdoes.What was wrong
Reproduced on origin/main 20e13f0 with the issue's method,
PYTHONIOENCODING=cp1252:strict. Underutf-8:strictevery run exits 0, except the doctor's NOT A SHAPE ROOT, which exits 2 as designed. Under cp1252 the raise comes at the first plainprintof a path or a person's text, in six tools and the rootbootstrapshim:adopt-project.py plan日本.md; the--sourcepath; the--outpathplan written toadopt-project.py checkshape-doctor.py--rootpathupdate-shape.py check--rootpathscaffold-project.py--name,--elected-by; a--work-diror--local-remote-dirpathscripts/family.py init--name,--created-by; an--intoor--local-remote-dirpathscripts/bump-leg.py(--dry-run)bootstrap(root shim)templates/assembly-root/scripts/bootstrap.py:400Under
cp1252:surrogateescape, the Windows default, the same prints raise. This was measured on this branch's first commit 3006303, whose helper changed onlystrictand so left that case exactly as on main.The fix
shape_materialize.escape_what_stdout_cannot_encode()is the first statement of each of the six tools'main(), and thebootstrapshim calls it before it hands over to the canonical bootstrap withrunpy. It changes two error handlers and no others, and it never changes the encoding:strictbecomesbackslashreplace, the handler Python already gives stderr.strictis what a POSIX locale gives stdout when it is not C or POSIX and not one Python coerces C to (en_US.UTF-8, but notC.UTF-8, the usual container locale, which givessurrogateescape), and whatPYTHONIOENCODING=<codec>gives when it names no handler.surrogateescapebecomesopenreposhape.surrogateescape-then-backslashreplace, a handler registered withcodecs.register_errorby the first call that needs it and by no other. It writes a surrogate-escaped byte back as that byte, which is surrogateescape's own rule, so a name that is not UTF-8 still comes out as its own bytes. Anything else the encoding cannot hold it writes incodecs.backslashreplace_errors' own spelling.surrogateescapeis the Windows default, and UTF-8 mode and the C, POSIX andC.UTF-8locales give it too.replace,backslashreplace,ignore,xmlcharrefreplace,namereplace,surrogatepass, or one an operator set) is left as it was.Each replacement writes exactly the bytes the handler it replaces wrote, and differs only where that handler raised. So nothing that printed before prints differently. On a cp1252 stdout
日本.mdprints as\u65e5\u672c.mdunder either handler, andplanexits 0 with its whole report. stderr is untouched. The helper lives inshape_materialize.py, notrepo_shape.py, becauserepo_shape.pyis a digested shape copy, and a helper no project calls would put anupstream-changedrow in front of every project. Thebootstrapshim at the repository root is not a shape copy: no copy list and noshape-pin.yamlnames it, and projects holdtemplates/*/scripts/bootstrap.pyinstead.Why this and not flattening each name where it is printed (the issue's other candidate):
printable(ASCII,?) would print??.mdon a UTF-8 terminal that shows日本.mdtoday.printsomebody adds.Icon\rfile), socheckrefuses the whole plan asyaml-unparsablewith no entry to edit #199, whose_shown()rewrites theprints in_print_entriesand the question list.Evidence
tests/test_stdout_encoding.py, 48 tests.cp1252:surrogateescape(the Windows default),cp1252:strictandutf-8:strict. Output is read as bytes and decoded strictly in the encoding the run was given.planruns on a source holding日本.md(answered, in the table) and東京.ipynb(asked, in the question list).check, the doctor,update-shape.py check,bump-leg.py --dry-run, the scaffold's andfamily.py init's dry runs, and thebootstrapshim each exit alike under all three, with the name shown.plan --outwrites into a directory whose name holds byte 0xff, underutf-8:surrogateescapeandcp1252:surrogateescape. The raw byte comes out raw, and in the cp1252 run日本.mdis escaped in the same report. This one runs on POSIX filesystems only, and skips where a name that is not UTF-8 cannot be made.openreposhape.. Six other handlers are left alone. Areconfigurethat raises a plainValueErroror a plainOSError, a closed stream, a stream with noreconfigure, and no stdout at all each fail nothing.TextIOWrapperand throughstr.encode: a raw byte before and after日in one run, the lowest and highest raw bytes, U+DC7F, U+DC00 and U+DC41 under the range, U+DD00 over it, a high surrogate, and one run holding every kind.PYTHONIOENCODINGat all. Two tests give the child none, and take what Python itself chooses for a redirected stdout. A first child reports that stdout's codec and handler. The test skips, with the reason, when the handler is notsurrogateescapeor the code page holds日本, so it never fails on a platform it does not test. Thenplanruns on日本.mdand東京.ipynb, its stdout decoded strictly in the probed codec, and exits 0 with the name escaped.LC_ALL=C PYTHONUTF8=0, which givesascii:surrogateescape(PYTHONUTF8=0because the C locale turns UTF-8 mode on, and 3.15 does everywhere). It runs on Linux, and on macOS when its C locale gives the same.PYTHONUTF8=0(the job setsPYTHONUTF8=1), which gives the ANSI code page withsurrogateescape. It has not been run on Windows by the author: only this PR's CI runs it. A Windows whose code page holds日本(cp932 does) skips it.日本.mdis a legal NTFS name, andPYTHONIOENCODINGoverrides the job'sPYTHONUTF8=1.They fail where the bug is. On 3006303 the 29 tests of round 1 give 12 failed, 17 passed. The failures are every
cp1252:surrogateescaperun (7 tool runs,plan, and the raw-byte cp1252 case), each withUnicodeEncodeError, plus the three in-process tests that need the new handler. The file as it stands gives 30 failed, 17 passed, 1 skipped there: those 12, the POSIX default test (exit 1,UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1), and the 17 handler cases, which fail there for want of the handler and so say nothing about their boundaries. The mutants below do. The first test file, 11 tests, failed 10 of 11 on 20e13f0; the utf-8plancontrol passed.Mutations. 14 mutants were run against the test file, and 13 were killed:
AttributeError, droppingOSError, and droppingValueError;surrogateescapeto plainbackslashreplace;surrogateescapealone;\udcff;\udcffand not as the byte; U+DC80 left out of the range; and U+DC00..U+DC7F let into it. The first 29 tests let all three through, and the new cases kill them (4, 5 and 8 failures, the other 39 to 43 tests still passing).The survivor moves the doctor's call after
parse_args, and it is not observable. Before the first report line, only argparse writes to stdout, and only for--help. All 17--helpoutputs (7 tools, 10 subcommands) are pure ASCII: all exit 0 underascii:strict, with no byte above 0x7f. The doctor'sprogis fixed, and its help interpolates nothing from the environment. A usage error goes to stderr, which is alreadybackslashreplace.Same bytes where it worked. 17 commands, each run through a main checkout (20e13f0) and through 20e13f0 with this branch's diff applied. Both checkouts were equally dirty, and each checkout's own path was normalised. The commands:
planon four sources,checkon three,planwith an--outpath holding byte 0xff,adopt-project.py --help, the doctor on a non-shape root, on a project and with--json,update-shape.py check, the scaffold's andfamily.py init's dry runs,bump-leg.py --dry-runand thebootstrapshim. Each ran under five environments:utf-8:strict, the container's inherited locale (utf-8:surrogateescape),PYTHONUTF8=1,cp1252:strictandcp1252:surrogateescape, for 85 pairs.UnicodeEncodeErrorevery time), main's partial stdout is a byte prefix of the head's. Those 16 are 8 undercp1252:strict, 7 undercp1252:surrogateescape, and the 0xff--outpath underutf-8:strict.caf\xff.mdmakes both sides raise inwrite_lfwriting the plan file (see Left out). Stdout and the plan are identical, and stderr is identical once traceback line numbers are masked.Targeted runs. At round 1's head 4d4e844:
tests/test_shape_doctor.py181 passed. In the second stream, 392 passed and 1 skipped:test_stdout_encoding29,test_adopt_plan56 (+1 skip, which needs a MedxEHR clone),test_shape_advisory40,test_update_shape61,test_family76,test_bump_leg23,test_scaffold_e2e44,test_repo_hygiene50 andtest_cli_reference13 (no--helptext changed). In all, 573 passed, 1 skipped, 0 failed. At round 2's head 2d60c3e, which changes only the test file and the helper's docstring,test_stdout_encoding(48 tests),test_repo_hygieneandtest_cli_referencegive 110 passed, 1 skipped (the Windows-only test); the other files were not re-run.git diff --checkis clean, and no added line holds a host-absolute path or a non-stdlib import.At the merge head 164fa16 (main 4835fb4 merged in), git-filter-repo 2.47.0 on PATH, the quick check over
test_stdout_encoding(48),test_adopt_plan_escapes(59),test_yaml_flow_quotes(17),test_adopt_plan(57),test_repo_hygiene(50) andtest_cli_reference(13) gives 242 passed, 2 skipped (the Windows-only test intest_stdout_encoding, and the MedxEHR-clone test intest_adopt_plan). Thentest_shape_doctorgives 181 passed, andtest_bump_leg(23),test_update_shape(77),test_family(76) andtest_scaffold_e2e(44) give 220 passed. In all 643 passed, 2 skipped, 0 failed.git diff --check HEAD^1 HEADis clean.At the merge head 657abb1 (main 39ac59c merged in), git-filter-repo 2.47.0 on PATH, the quick check over
test_stdout_encoding(48),test_update_shape(77),test_update_shape_add(17),test_scaffold_e2e(44),test_repo_hygiene(50),test_cli_reference(13) andtest_adopt_plan_escapes(59) gives 307 passed, 1 skipped (the Windows-only test intest_stdout_encoding). Thentest_shape_doctorgives 181 passed, andtest_bump_leg(23),test_family(76),test_scaffold_leg_path_collisions(104) andtest_scaffold_mount_ignored(42) give 245 passed. In all 733 passed, 1 skipped, 0 failed over those 12 files.test_update_shape_add_ignored(update-shape.pyapply --addstages the new file with a plaingit add, so a path the operator's excludes ignore fails after the copies, the re-pin and the branch are written, nothing is rolled back, andcheckthen exits 0 over a re-pin nobody committed #202's new file, 26 tests) andtest_scaffold_ignored_shape_files(15 tests) also pass on 657abb1, 41 passed.git diff --check HEAD^1 HEADis clean.Left out
bump-leg.pyrun, andupdate-shape.py apply, still fail under a code page that cannot hold the project's display name. Their childvalidate-manifest.pyprintsmanifest ok: <name>with its own default handler. It is a shape copy and does not call the helper.update-shape.py's child inherits the tool's stdout, andbump-leg.pycaptures the child's output. Either way the child takes the stdout's configuration from the environment and not the parent'sreconfigure. The fix belongs in the two tools'run_validator, the wayshape-doctor.py:823already setsPYTHONIOENCODING=utf-8for the same validators. But shape_materialize.run() and 26 other git and gh calls read what the child wrote as UTF-8 whatever the locale, so anadopt-project.py executewhose leg push is rejected with a message naming a non-ASCII path endsREFUSED pushing the spec legand the ruleset hint underLC_ALL=C PYTHONUTF8=0, not a UnicodeDecodeError traceback (#217) #230 (fix/decode-git-output-utf8) rewritesbump-leg.py'srun_validatorcall, with the opposite decoding choice: the locale codec witherrors="replace". A trialgit merge-treeof the review's proposed change against shape_materialize.run() and 26 other git and gh calls read what the child wrote as UTF-8 whatever the locale, so anadopt-project.py executewhose leg push is rejected with a message naming a non-ASCII path endsREFUSED pushing the spec legand the ruleset hint underLC_ALL=C PYTHONUTF8=0, not a UnicodeDecodeError traceback (#217) #230's head conflicts inscripts/bump-leg.py. So it is a follow-up that lands with or after shape_materialize.run() and 26 other git and gh calls read what the child wrote as UTF-8 whatever the locale, so anadopt-project.py executewhose leg push is rejected with a message naming a non-ASCII path endsREFUSED pushing the spec legand the ruleset hint underLC_ALL=C PYTHONUTF8=0, not a UnicodeDecodeError traceback (#217) #230. Filed as bump-leg.py (a real bump) and update-shape.pyapplyrun the project's own validators as children that cannot print its display name on a cp1252 stdout, so a project named日本is refused and rolled back asbump-leg-validators-redorupdate-validators-redunderPYTHONIOENCODING=cp1252:surrogateescape, and the refusal blames the validators #254.scripts/validate-repository-naming.py, the project validators andtemplates/*/scripts/bootstrap.pyare shape copies and were not changed.setup-project.pyalready sets its own streams, and its scaffold child now calls the helper itself (by reading, not run on Windows).\u65e5\u672c.mdpushes the columns after it out of line. Cosmetic.backslashreplacedoes not escape a backslash. A name that literally holds the six characters\u65e5therefore prints the same as a name holding日. Unlikely, and it comes with any backslash escaping.planfollow-up naming a code file with a non-ASCII name prints git's quoted octal (code/"src/\346\227\245\346\234\254.py", fromgit grep -lundercore.quotepath). That is a separate defect. It is covered by adopt-project.pyplan'scross_leg_referencespasses a spec directory's name togit grep -Fwith no-eand the code entries as glob pathspecs, so a spec directory named-notesor a code entry named:cachesilently drops the follow-up, a code entry named*names files that are not in the code leg, a name outside ASCII is printed in git's quoted spelling, and a spec directory named-O<cmd>is run as git grep's pager when a tracked file holds the planned commit's hash #216 and PR adopt-project.py:plan's follow-upgit greptakes a spec directory as its pattern and each code entry as a literal path, so a spec directory named-notes/or a code entry named:cache/no longer loses its follow-up, a code entry named*/no longer names a spec-leg file, a name outside ASCII is spelled instead of C-quoted, and a spec directory named-O<cmd>/no longer runs a command (#216) #228, which readsgit grep -z.planraise inwrite_lfwhile writing the plan file, the same on main and on this head. adopt-project.py plan writes a plan its own loader cannot parse when a source name holds a line break (the macOSIcon\rfile), socheckrefuses the whole plan asyaml-unparsablewith no entry to edit #199 fixes that. On the trial merge with adopt-project.py plan writes a plan its own loader cannot parse when a source name holds a line break (the macOSIcon\rfile), socheckrefuses the whole plan asyaml-unparsablewith no entry to edit #199's a02240a,planovercaf\xff.mdexits 0 underutf-8:strict,cp1252:strictandcp1252:surrogateescape. The plan holdspath: "caf\udcff.md", and the table shows`caf\xff.md`.Composition
--spec-path contracts,--code-path project.yaml), creates all three repositories and pushes both legs, then dies: #172's shape half on the scaffold side #197 via scaffold-project.py: a leg path that is, holds or lies inside a path the shape writes into the assembly root, a--pin'scontracts/<product>-pin.yamlincluded, is refused before anything is created, under--dry-runalike, through the one definition adopt-project.py asks, so--spec-path contractsno longer creates three repositories and pushes both legs to die atgit submodule add(#197) #234, update-shape.pyapply --addstages the new file with a plaingit add, so a path the operator's excludes ignore fails after the copies, the re-pin and the branch are written, nothing is rolled back, andcheckthen exits 0 over a re-pin nobody committed #202 via update-shape.py:apply --addforce-stages its additions past the operator's own excludes, and a branch, stage or commit that fails before the commit lands now puts back the copies, the pin, the README line, the index and the branch and names the step, socheckno longer exits 0 over an uncommitted re-pin (#202) #233, scaffold-project.py_build_assembly_rootrunsgit submodule addwithout--force, so a leg path an ignore rule matches (--code-path __pycache__or.DS_Storeunder the shape's own.gitignore, or the defaultspecunder an operator's global excludes) passes--dry-run, then dies at the mount AFTER all three repositories exist and both legs are pushed #229 via scaffold-project.py: the leg mount passes--forcetogit submodule add, so a leg path an ignore rule matches (__pycache__,.DS_Store, or the defaultspecunder an operator's own excludes) scaffolds instead of dying at the mount after all three repositories exist and both legs are pushed (#229) #235; before them adopt-project.py plan writes a plan its own loader cannot parse when a source name holds a line break (the macOSIcon\rfile), socheckrefuses the whole plan asyaml-unparsablewith no entry to edit #199 via adopt-project.py: the plan writer escapes every character that is not printable from the one tablerepo_shape's reader resolves, so a source holding the macOSIcon\rno longer writes a plancheckrefuses, and a non-UTF-8 name no longer empties the plan (#199) #247, scripts/repo_shape.py_split_flowignores a backslash inside a double-quoted flow item, sopins: ["x\",y"]reads back as two itemsx\andy"where the same text as a block sequence item reads as the one itemx",y#213 via repo_shape.py:_split_flowand_split_key_flowtake the character after a backslash inside a double-quoted flow item, sopins: ["x\",y"]reads as the one itemx",yits block twin reads, not asx\andy"(#213) #244, update-shape.pyapplyrewrites the manifest'sshape:block with fixed two- and four-space indents, so aproject.yamlindented four spaces, which the project's own reader and both validators accept, is rewritten into YAML that reader rejects, and the rolled-back refusal relays the validators' lockstep remedy #208). This branch carries main 39ac59c, as merge commit 657abb1 (parents 164fa16 and 39ac59c), so the PR no longer conflicts. 164fa16 had carried main 4835fb4 the same way. Each merge had exactly one conflict and both sides are kept whole. At 164fa16 it was the add/add hunk inscripts/shape_materialize.pyright afterwrite_lf: adopt-project.py plan writes a plan its own loader cannot parse when a source name holds a line break (the macOSIcon\rfile), socheckrefuses the whole plan asyaml-unparsablewith no entry to edit #199'swrite_paths_listingand this PR'sescape_what_stdout_cannot_encode, with its handler, sit side by side. At 657abb1 it is thefrom shape_materialize import (...)list inupdate-shape.py: this PR addsescape_what_stdout_cannot_encodeand update-shape.pyapply --addstages the new file with a plaingit add, so a path the operator's excludes ignore fails after the copies, the re-pin and the branch are written, nothing is rolled back, andcheckthen exits 0 over a re-pin nobody committed #202 addedforce_stage, so the list holds both, in its existing order, re-wrapped to 79 columns like its neighbours. No other file conflicted at either merge, and no code was written for them. adopt-project.py plan writes a plan its own loader cannot parse when a source name holds a line break (the macOSIcon\rfile), socheckrefuses the whole plan asyaml-unparsablewith no entry to edit #199's own tests,tests/test_adopt_plan_escapes.py, and main'stests/test_update_shape.py,tests/test_update_shape_add.py,tests/test_scaffold_leg_path_collisions.pyandtests/test_scaffold_mount_ignored.pypass together with this PR'stests/test_stdout_encoding.pyon the merge (see Targeted runs).adopt-project.py executewhose leg push is rejected with a message naming a non-ASCII path endsREFUSED pushing the spec legand the ruleset hint underLC_ALL=C PYTHONUTF8=0, not a UnicodeDecodeError traceback (#217) #230 (fbdf8db).git merge-tree --write-treeis clean at 2d60c3e, tree7718902ebc43. On the merged tree at 4d4e844 (tree823450fb125a), shape_materialize.run() and 26 other git and gh calls read what the child wrote as UTF-8 whatever the locale, so anadopt-project.py executewhose leg push is rejected with a message naming a non-ASCII path endsREFUSED pushing the spec legand the ruleset hint underLC_ALL=C PYTHONUTF8=0, not a UnicodeDecodeError traceback (#217) #230'stests/test_git_output_decoding.py(its guard against text-mode calls with no codec) andtests/test_stdout_encoding.pygave 32 passed, not re-run at 2d60c3e. This change adds nosubprocesscall.🤖 Generated with Claude Code