Skip to content

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) - #230

Merged
brettheap merged 5 commits into
mainfrom
fix/decode-git-output-utf8
Oct 11, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-3 (openRepoShape-3)
Claim: #217 (comment)

What

subprocess.run(..., text=True) with no encoding= decodes what the child wrote with the LOCALE'S codec. That is ASCII under a POSIX C locale with UTF-8 mode off, and the ANSI code page on a Windows without PYTHONUTF8=1. Git writes a path as UTF-8. When a byte cannot be decoded, subprocess.run raises UnicodeDecodeError before CommandFailed is built, so execute printed a traceback and exited 1. It should have printed REFUSED pushing the spec leg and the ruleset hint. By then both leg remotes already exist. On main, 30 text-mode calls in the shipped tools name no codec. This branch fixes 28 of them:

Why

The issue's repro, measured on this machine against current main a5b09c3 and against this branch's tree ce41ef3 (head b6f7cfd):

  • The source has specs/001-feature/{spec,plan}.md, src/main.py and README.md.
  • Every new bare remote gets a pre-receive hook through GIT_TEMPLATE_DIR. The hook rejects the push with error: push rule: the file 'specs/001-feature/Übersicht.md' is blocked, as a hosted push rule would.
  • plan and check run in this shell. execute --yes --local-remote-dir runs under env -u LANG LC_ALL=C PYTHONUTF8=0, where locale.getpreferredencoding() is ANSI_X3.4-1968.
main a5b09c3 this branch b6f7cfd
plan, check exit 0, exit 0 exit 0, exit 0
execute under LC_ALL=C PYTHONUTF8=0 exit 1 exit 2
what it prints traceback through _extract_leg → shape_materialize.run → subprocess.py _translate_newlines, ending UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 54: ordinal not in range(128) REFUSED pushing the spec leg. THE EXACT COMMAND WAS: … remote: error: push rule: the file 'specs/001-feature/\xdcbersicht.md' is blocked (stderr's backslashreplace) … [remote rejected] HEAD -> main (pre-receive hook declined)
ruleset hint (If the organisation applies a ruleset …) not printed printed
leg remotes left Northwind-code.git, Northwind-spec.git Northwind-code.git, Northwind-spec.git (unchanged: NOTHING has been rolled back)
same run, source also holding specs/001-feature/Übersicht.md exit 1, same traceback exit 2, execute output identical to the row above
execute in a UTF-8 shell exit 2, REFUSED pushing the spec leg + hint exit 2. plan output, check output, execute output, adoption-plan.yaml and exit codes are byte-identical to main's, diffed after masking the run directory

Tests

On tree ce41ef3812965cb08d19fa9ad222d2abd0cd4078, which is head b6f7cfdcfbd6fb0cb03299de99c69889899edb52, based on a5b09c3, with git-filter-repo 2.47.0 first on PATH. The runs were made at 328c099, which has the same tree; b6f7cfd differs from it only in commit metadata:

  • tests/test_git_output_decoding.py tests/test_adopt_e2e.py tests/test_adopt_execute_refusals.py tests/test_adopt_ignored_shape_files.py tests/test_adopt_leg_path_collisions.py tests/test_adopt_leg_paths.py tests/test_adopt_mount_ignored.py tests/test_adopt_plan.py tests/test_adopt_spec_only.py tests/test_adopt_submodules.py tests/test_update_shape.py tests/test_update_shape_add.py tests/test_family.py tests/test_bump_leg.py tests/test_scaffold_e2e.py tests/test_scaffold_ignored_shape_files.py tests/test_scaffold_pin_and_reuse.py tests/test_setup_project_py.py tests/test_shape_doctor.py tests/test_repo_hygiene.py tests/test_cli_reference.py: 1344 passed, 1 skipped in 793.93s. The one skip is tests/test_adopt_plan.py:393, the MedxEHR-backed plan test, which needs SHAPE_MEDXEHR_CLONE.
  • tests/test_scaffold_leg_paths.py tests/test_free_plan_hint.py tests/test_reuse_visibility.py tests/test_commit_trailers.py tests/test_park_resume_targets.py tests/test_windows_paths.py: 250 passed in 55.48s.

Platform skips:

  • Windows skips test_run_names_the_path_git_complained_of_under_a_c_locale (os.name == "nt"). A C locale is a POSIX idea, and the Windows job sets PYTHONUTF8: '1' (.github/workflows/tests.yml), so CI does not run the cp1252 case.
  • Any platform where LC_ALL=C PYTHONUTF8=0 still decodes as UTF-8 skips that test too, saying so.
  • The guard test runs on all three OSes.

Composes with

Trial merges, git merge-tree --write-tree <sibling> <this branch> (tree ce41ef3) with main at a5b09c3, run 2026-10-11T18:51Z. On every clean merge, the guard's own rule was run over the merged tree.

sibling sha issue vs this branch vs main alone guard on the merge
origin/fix/adopt-check-unsafe-path 30badde #196 CONFLICT adopt-project.py CONFLICT –
fix/adopt-check-unsafe-path (local, newer) 5a8df76 #196 clean clean ok
origin/fix/adopt-history-gitlinks / local bf65ae4 / 28437f3 #195 clean / clean clean ok
origin/fix/adopt-late-failure-notice 8d4fef4 #188 clean clean ok
origin/fix/adopt-plan-linebreak-names / local d566e12 / d5fdc22 #199 CONFLICT adopt-project.py CONFLICT –
origin/fix/adopt-plan-quote-yaml-keywords / local 76a6153 / 2e225fe #181 clean / clean clean ok
origin/fix/adopt-record-submodule-placement e167d8b #189 clean clean ok
origin/fix/adopt-relative-submodule-url b0c959e #190 CONFLICT adopt-project.py CONFLICT –
origin/fix/adopt-report-filter-repo-failure f416e13 #171 clean clean ok
origin/fix/adopt-root-kept-symlinks 7b5af45 #183 CONFLICT adopt-project.py, shape_materialize.py CONFLICT –
fix/adopt-root-kept-symlinks (local, merge of a5b09c3) 04792d2 #183 clean clean FAILS, see below
origin/fix/adopt-spell-finding-paths / local ad7f380 / 35cb5f8 #212 clean / clean clean ok
origin/fix/adopt-verify-mounted-gitlinks 28fee0f #165 clean clean ok
origin/fix/bump-leg-tracked-workflows-only / local ba93b1c / 52f4bff #207 clean / clean clean ok
origin/fix/doctor-reads-family-yaml / local f776dda / 7fe2c56 #211 clean / clean clean ok
origin/fix/doctor-unparsable-manifest 50aa967 #205 clean clean ok
origin/fix/family-add-check-before-mount 11c1168 #203 clean clean ok
origin/fix/family-add-member-case / local 333453d / 6c917d6 #206 clean / clean clean ok
origin/fix/family-bump-nothing-to-do / local 8756f55 / 66ace9b #210 clean / clean clean ok
origin/fix/plan-literal-follow-up-grep / local 53beefb / 115f012 #216 clean / clean clean ok
fix/plan-report-stdout-encoding (local) 3006303 #214 clean clean ok
origin/fix/scaffold-shape-path-collisions aabfae0 #197 clean clean ok
origin/fix/update-shape-add-rollback c022b64 #202 clean clean ok
origin/fix/update-shape-manifest-indent / local 2ccad27 / bb79295 #208 clean / clean clean ok
origin/fix/update-shape-pr-base-branch / local da2035f / 30240df #209 clean / clean clean ok
origin/fix/update-shape-pr-create-failure ffc5284 #204 clean clean ok
fix/yaml-flow-escaped-quote (local) 8c656f4 #213 clean clean ok

Left out

Closes #217

🤖 Generated with Claude Code

Summary by Sourcery

Make command-output handling locale-independent so failed operations with non-ASCII paths remain actionable across environments.

Bug Fixes:

  • Ensure Git and GitHub command output is decoded consistently as UTF-8 so non-ASCII paths produce normal refusal messages instead of UnicodeDecodeError tracebacks under non-UTF-8 locales.

Enhancements:

  • Apply explicit subprocess decoding policies across shipped tooling, preserving strict UTF-8 handling for digest inputs and tolerant decoding for user-facing command output.

Tests:

  • Add locale-sensitive regression coverage and a static guard that prevents new text-mode subprocess calls from omitting decoding behavior.

…hild 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 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

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 5 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-11T19:05Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR prevents locale-dependent subprocess decoding from turning Git refusal paths into UnicodeDecodeError tracebacks by applying explicit UTF-8/replacement policies throughout the shipped tools, retaining strict decoding where bytes affect digests, and adding both runtime regression tests and an AST-based guard for future calls.

Sequence diagram for locale-independent Git refusal handling

sequenceDiagram
    actor User
    participant Execute as adopt-project.py execute
    participant Materialize as shape_materialize.run
    participant Git

    User->>Execute: execute --yes
    Execute->>Materialize: run(git push)
    Materialize->>Git: git push
    Git-->>Materialize: UTF-8 stderr with non-ASCII path
    Materialize-->>Execute: CommandFailed with decoded output
    Execute-->>User: REFUSED pushing the spec leg
    Execute-->>User: ruleset hint
Loading

File-Level Changes

Change Details Files
Make text-mode subprocess output decoding explicit and resilient across shipped Git/GitHub CLI integrations.
  • Use UTF-8 decoding with replacement for Git/GH output that is displayed, parsed, compared, or propagated into refusal messages.
  • Keep strict UTF-8 decoding for GitHub tree JSON used to compute digests.
  • Preserve locale-based decoding for the Python validator child while replacing undecodable output to avoid traceback failures.
  • Apply the same policy to shared materialization, commit helpers, project tooling, and family templates.
adopt-project.py
scaffold-project.py
scripts/bump-leg.py
scripts/family.py
scripts/repo_shape.py
scripts/shape_materialize.py
templates/family-root/scripts/bootstrap.py
templates/family-root/scripts/siblings.py
update-shape.py
Add regression coverage and a repository-wide guard against locale-dependent text decoding.
  • Run a child interpreter under LC_ALL=C and PYTHONUTF8=0 to verify non-ASCII and invalid byte output becomes a refusal rather than UnicodeDecodeError.
  • Statically inspect shipped Python tools for text-mode subprocess calls missing required encoding/error arguments.
  • Document and exempt the two calls intentionally handled by sibling pull requests, plus the validator and digest-specific policies.
tests/test_git_output_decoding.py

Assessment against linked issues

Issue Objective Addressed Explanation
#217 Ensure shape_materialize.run() decodes Git and gh output as UTF-8 with replacement errors so failures involving non-ASCII paths produce CommandFailed/REFUSED output and the ruleset hint instead of a UnicodeDecodeError traceback. ✅
#217 Update the remaining shipped Git and gh subprocess calls that use text mode so locale-dependent decoding cannot cause similar UnicodeDecodeError failures. ❌ The PR updates most of the calls and adds a guard test, but explicitly leaves shape_materialize.env_commit and adopt-project.py's cross_leg_references to sibling PRs via exemptions. Therefore it does not independently cover every affected call described by the issue.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-11T19:24Z) independent review (codex is out of quota): ready; PR body updated with three composition and follow-up facts, no code change.

This was referenced Oct 11, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-11T19:57:58Z)

Lane openRepoShape-2 reviewed PR #230 at b6f7cfd against main a5b09c3, read-only in its own detached worktrees, with nothing authored and no claim.

MERGEABLE WITH NITS. Sequencing: whichever of #230 and fix/adopt-root-kept-symlinks (04792d2) lands second must add encoding="utf-8", errors="replace" at adopt-project.py:2216 (_checks_out_symlinks). Otherwise the new guard fails on the merged tree. The branch has since moved to fbdf8db, whose tree equals git merge-tree --write-tree b6f7cfd 54aa69a; not reviewed separately.

Findings (no P1 to P3):

Tests:

  • Issue repro under LC_ALL=C PYTHONUTF8=0: main exits 1 with UnicodeDecodeError from shape_materialize.py:481 and no hint. Head exits 2 with REFUSED pushing the spec leg and the ruleset hint.
  • A hook message holding byte 0xE9: main crashes even in a UTF-8 locale, and head refuses cleanly.
  • tests/test_git_output_decoding.py: main (file copied in) has 3 failures for the right reason, and head 3 passed. The same holds with pytest itself run under C and POSIX locales. 5 of 7 mutants were killed.
  • Head targeted runs: 573 passed (adopt, scaffold, doctor), 447 passed (family, bump-leg, update-shape, hygiene, cli), and 574 passed with 1 skipped (MedxEHR). render-cli-reference.py --check passes.
  • The two family-root templates and repo_shape.py are pinned per project at materialize time, so no static pin or test had to move.

Merges: 27 trial merges, against 54aa69a, the 24 open heads that touch these files, and the newer heads of #218 and #227. 25 are clean, and the guard passes on every clean merge except 04792d2. d5fdc22 and b0c959e conflict in adopt-project.py exactly as they do with main alone, and none of the conflicts is in this PR's lines.

Full report: handoffs/xFactory/attachments/lane-openRepoShape-2/review230-b6f7cfd/final-report.md in opensoft/brett-wip.

brettheap added a commit that referenced this pull request Oct 11, 2026
…rns HEAD to a non-ASCII start branch on Windows too; the branch HEAD is still on is never deleted after a switch back that failed; and the NEXT hint quotes every path and adds them literally, so a name holding `[` stages itself and no ignored neighbour (#202)

Fix round 2 for PR #233, answering the four nits of the re-review at 396b946.

NIT 1 (Windows only, a regression of fix round 1): `_symbolic_head` now
reads HEAD through `_git_answer`, which decoded git's output with the
locale's codec. Under the ANSI code page (Windows without PYTHONUTF8=1) a
start branch `feature/café` came back as `feature/café`, the switch back
named a branch that does not exist, and a name holding a byte that code page
leaves undefined (`Ýmir`) raised out of the very answer that may not raise.
The two-line change that fixes it, `encoding="utf-8", errors="replace"` on
`_git_answer`'s `subprocess.run`, is PR #230's own (#217,
fix/decode-git-output-utf8 @ fbdf8db), copied byte for byte and with none of
its other hunks, so whichever of the two lands second merges it as a no-op:
`git merge-tree --write-tree origin/fix/decode-git-output-utf8` over this
head is clean. Pinned twice, neither depending on the platform's code page:
the rollback's own undo run with every text-mode call that names no codec
forced to cp1252 (both names, on every OS), and `_symbolic_head` in a child
started under `LC_ALL=C PYTHONUTF8=0` (POSIX).

NIT 2 (wording): `_by_hand`'s docstring said an ordinary root prints what it
always printed. That holds on POSIX only: every Windows root holds a
backslash, which `shlex` quotes. It now says so, and why that is left alone.
The two smaller slips: `_index_by_hand` said `git reset` "would replace with
HEAD's" an entry that may BE HEAD's, and `force_stage`'s first line left out
adopt's seeded leg, which reaches it through `git_init_commit`.

NIT 3: the hint `add -f -- {added}` was unquoted, and `-f` is what made that
dangerous: pasted into a shell, an added `docs/guide[1].md` is a pattern the
shell expands to an ignored `docs/guide1.md`, which `-f` then forces in.
Quoting alone is not enough, because git reads a pathspec as a glob too:
`git add -f -- 'docs/guide[1].md'` still stages the neighbour, as the first
round's `force_stage` already found. So each added path is quoted with
`shlex.join`, the helper `_by_hand` uses, and the add is
`--literal-pathspecs`. The commit's own path list in the same line is quoted
the same way, so the whole line pastes whole. An ordinary name prints exactly
as it did. Tested by taking the line apart as a shell would on every OS, and
by pasting it into `sh` from the root beside an ignored neighbour.

NIT 4: when the switch back failed, `_undo_branch` still ran `update-ref -d`
on the branch HEAD was attached to, which takes no index lock. HEAD then
named a ref with no commit and `git status` said "No commits yet" over a
repository with a history. The deletion is skipped while HEAD is still on the
branch, and the partial-rollback report lists the branch as left, with the
switch and then the deletion (`git branch -D` refuses the checked-out
branch). Tested end to end through `apply`: a `post-checkout` hook takes
`.git/index.lock` once the branch exists, so the staging fails and the switch
back with it; once the lock is cleared the printed line makes the tree whole.

Each new test was shown to fail against its regression, by mutants in a
scratch worktree: `_git_answer` without its codec (4 failed), the hint with
its added paths unquoted (2), without `--literal-pathspecs` (3), with the
commit list unquoted (2), and the deletion not guarded (1).

Not touched, and said so: an intent-to-add entry at an `--add` path (NIT 5 of
the review), `git commit -- <paths>` reading each name as a glob in
`commit_on_branch`, and the unquoted root in the NEXT lines, all of which
predate this pull request.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 11, 2026
…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>
brettheap added a commit that referenced this pull request Oct 11, 2026
…erator'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, so `check` no longer exits 0 over an uncommitted re-pin (#202) (#233)

update-shape.py `apply --add` stages its additions through `shape_materialize.force_stage` (`git --literal-pathspecs add -f --`, on the named paths only), so an addition the operator's own excludes ignore is committed. Any failure up to and including the commit puts back every byte it wrote (the copies, the pin, the README line, the index records, HEAD and the branch) and names the step that failed.

- The `Rollback` ledger records the branch `apply` creates and the index entries it forces. A failure after the commit lands rolls nothing back, and `--branch` naming the current branch keeps it.
- A partial rollback says what was left and prints a pasteable fix per path (`update-index`, never `git reset`). A failed switch back keeps the branch HEAD is still on.
- The NEXT hint stages with `--literal-pathspecs` and quoted paths.
- `_git_answer` decodes UTF-8, #230's own one-line change, so a non-ASCII branch survives the rollback on Windows.
- `tests/test_update_shape_add_ignored.py` (new, 26 tests).

Reviewed adversarially twice (MERGEABLE WITH NITS), with two fix rounds. CI is green on Linux, macOS and Windows.

Closes #202

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 60d08a1 into main Oct 11, 2026
5 checks passed
@brettheap
brettheap deleted the fix/decode-git-output-utf8 branch October 11, 2026 22:23
brettheap pushed a commit that referenced this pull request Oct 11, 2026
…m, not from a codec setting, so they keep meaning the same thing once `main` escapes what a strict stdout cannot encode (#253)

The first cut of the echo test ran `bump-leg.py` with `PYTHONIOENCODING=cp1252`.
#214's `escape_what_stdout_cannot_encode` reconfigures exactly that stream to
`backslashreplace` at the top of `main`, so that test would have started to
see a bump that succeeds the day #214 landed. The echo test is now in-process
with a stdout whose `write` raises `UnicodeEncodeError` for what cp1252
cannot hold, beside a `UnicodeDecodeError` raised from the validator read, so
both ways a validator's output can fail the statement after the staging are
covered whichever of #214 and #230 is in.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 11, 2026
brettheap added a commit that referenced this pull request Oct 11, 2026
…s body as a pair instead of NUL-joined text, so `"a\x00b": |` no longer reads as the key `a` with the body `b<NUL>text`, and a line that begins with a raw NUL and `BLOCK` no longer reads as a block scalar nobody wrote (#248) (#265)

The YAML reader kept a block scalar (`key: |`, `key: >`) as one `_Rec` whose text was `"\x00BLOCK\x00" + key + "\x00" + body`. `_parse_mapping` took it apart with `split("\x00", 3)`, and both `_parse_mapping` and `_parse_sequence` recognised it by that text prefix. Now:

- `_Rec` has a `block` slot holding `(key, body)`. It is set only on the record a block scalar's header makes, and that record's `text` is empty.
- `_tokenise` builds the record with the pair. `_parse_mapping` and `_parse_sequence` test `rec.block`, not a text prefix.
- `_BLOCK_SENTINEL` is gone. Its comment, which named the collision as a known limit, becomes a `_Rec` docstring saying why nothing can collide now.

No other function in the file changes, and no writer changes.

Since #199 the reader resolves `\x00`, `\u0000` and `\U00000000` in a double-quoted key, which is valid YAML. A key holding a NUL was cut short at the NUL, and the rest of it read into the body. A raw NUL did the same before #199. The marker was also only a text prefix, so an ordinary line beginning with a raw NUL + `BLOCK` + NUL was taken for a block record:

| document | main (150c41d) | this branch |
|---|---|---|
| `"a\x00b": \|` | `{'a': 'b\x00text\n'}` | `{'a\x00b': 'text\n'}` (PyYAML agrees) |
| `"a\x00b": text` (control) | `{'a\x00b': 'text'}` | same |
| `<NUL>BLOCK<NUL>k<NUL>v: 1` | `{'k': 'v: 1'}` | `{'\x00BLOCK\x00k\x00v': 1}` |
| `- <NUL>BLOCK<NUL>x` | bare `ValueError` out of `load_yaml` | `['\x00BLOCK\x00x']` |
| `k:\n  <NUL>BLOCK<NUL>x<NUL>y` | `{'k': {'x': 'y'}}` | `YamlError`, as `k:\n  xyz` is |

A structure that is not a string has no character to collide with, so this removes the class and not just `\x00`. A different separator only moves the collision, and refusing a NUL in a block key leaves the prefix misreads in place (a `\x01` separator and a NUL refusal were each tried as mutants, and both are caught by the new tests).

**A NUL in a key is read, not refused.** YAML allows it escaped. PyYAML 6.0.3 reads every escaped row the way this branch does. The reader already read the same key over a plain value, and `double_quoted` writes a NUL as `\x00`. A raw NUL, which YAML forbids, is read the way the reader reads a raw NUL in any other key (`a<NUL>b: text` reads as `{'a\x00b': 'text'}` on main). Refusing raw control characters is a reader-wide decision and is left out (see below).

Issue row 2 (`"a\^@b": >`) does not reproduce, not even on a02240a: `\^@` is no escape the reader (or YAML) resolves, so the key keeps its backslash on main and here. It is pinned as a control.

- `tests/test_yaml_block_scalar_keys.py` (new, 51 tests). On 150c41d: 41 fail and 10 pass, and the 10 that pass are controls. On this branch: 51 pass.
- Targeted runs on 9be6287:
  - the new file plus `test_yaml_flow_quotes.py`, `test_load_yaml_refusals.py`, `test_adopt_plan_escapes.py`, `test_manifest_validator.py`, `test_pin_validator.py`, `test_neutral_pin_validator.py`, `test_repo_hygiene.py`, `test_cli_reference.py` and `test_adopt_plan.py`: 326 passed, 1 skipped (`SHAPE_MEDXEHR_CLONE` unset);
  - `test_shape_doctor.py`: 181 passed.
- Mutation: each fix hunk reverted on its own (`_tokenise`, `_parse_sequence`, `_parse_mapping`, `_Rec`) fails 46, 4, 48 and 51 tests. Of the narrow alternatives, refusing a NUL in a block key fails 41, including every marker-prefixed-line test. A `\x01` separator fails the every-character round-trip test. Dropping the sequence check fails the placement test, and taking the pair reversed fails 46.
- Every code point in a written block key, `double_quoted("a" + chr(c) + "b")` under `|` and `>`: all 2,228,224 documents read back, and PyYAML agrees on every one. Main misreads exactly 2 of them, both NUL.
- A base-against-head fuzz of 200,000 documents built around block scalars finds 0 differences outside the class. All 23,298 documents with no NUL in any spelling read the same. Over the same corpus, main lets 4,250 bare `ValueError`s out of `parse_yaml`, and this branch none.
- Reach: all 14 tracked YAML files read the same on main and here, rendered templates too. Block scalars in `tests.yml`, `path-classification.yaml` and both `validate.yml` templates read unchanged. **No writer in this repository emits the construct:**
  - no shipped Python writes a block-scalar header;
  - `adopt-project.py`'s keys are literals, and its one variable key comes from `summary_of`'s fixed names;
  - `y` never writes `|`, `>` or a raw NUL plain;
  - the doctor's placement plan uses `y`/`emit`, and `family.py`, `update-shape.py` and `bump-leg.py` write literal keys.

  So the defect shows only on a file a person edits or another tool writes. Every YAML file every tool reads goes through this reader.

`git merge-tree --write-tree` from 9be6287:

- origin/main 60d08a1 (with #214 and #230): clean.
- `fix/yaml-quote-boundaries` (#249/#250) at 21fc213: clean; the review also passed 363 tests on this branch merged with it and #252 together.
- `fix/yaml-flow-mapping-keys` (#252) at 2438190: clean. The merged tree passes this file, `test_yaml_flow_quotes.py` and `test_yaml_flow_mappings.py` (114 passed).
- `fix/scaffold-referent-chain-links` (#251) at 114bec9: clean.

- **Refusing the raw control characters YAML forbids** (PyYAML: `unacceptable character #x0000`). The reader takes them everywhere today, and `double_quoted`'s docstring says so. A refusal belongs to the whole reader, not to one path. The plain-twin tests here say those rows become refusals together if that changes. A follow-up issue is proposed.
- Honouring `+` chomping and the indentation digit is documented as accepted, not honoured, and is unrelated.

Reviewed adversarially (APPROVE WITH NITS, 0 blockers; a 2,228,224-code-point oracle and 840,000 fuzzed documents show no misread). CI is green on Linux, macOS and Windows.

Closes #248

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #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>
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #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>
brettheap added a commit that referenced this pull request Oct 12, 2026
…explicit PYTHONIOENCODING, so a real bump or `apply` on a project named `日本` is no longer refused as `bump-leg-validators-red` or `update-validators-red` under a cp1252 stdout (#254) (#281)

A real `bump-leg.py` (not `--dry-run`) and `update-shape.py apply` run the
PROJECT'S own `scripts/validate-pins.py` and `scripts/validate-manifest.py` as
child processes after they rewrite the pin. A child takes its stdout
configuration from its environment (`PYTHONIOENCODING`, then the locale), not
from the `reconfigure` its parent did in #214, and the validators are
digest-pinned shape copies that print the display name with a plain `print`.
So on a stdout whose code page cannot hold the name (`PYTHONIOENCODING=cp1252`,
or the cp1252 with `surrogateescape` that Windows gives any stdout that is not
the console), `validate-manifest.py` raised `UnicodeEncodeError` at
`manifest ok: <name>`, exited 1, and the tool refused and rolled back a bump
the pins had no quarrel with. The refusal blamed the validators.

- `scripts/shape_materialize.py`: two helpers build the child's environment,
  and each tool passes the one it needs as `env=`.
  - `captured_child_environment()` sets `PYTHONIOENCODING=utf-8:backslashreplace`.
  - `inherited_child_environment(stream=None)` sets the stream's OWN canonical
    encoding with its own handler where that handler never raises
    (`replace`, `ignore`, `backslashreplace`, `xmlcharrefreplace`,
    `namereplace`), and `backslashreplace` for every other one.
- `scripts/bump-leg.py` `run_validator` CAPTURES the output. It passes
  `env=captured_child_environment()` and reads the pipe with
  `encoding="utf-8", errors="replace"`, where #230 had `errors="replace"` and
  no `encoding=`.
- `update-shape.py` `run_validator` lets the child INHERIT stdout. It passes
  `env=inherited_child_environment()`.
- `tests/test_git_output_decoding.py`: the `bump-leg.py run_validator` row
  leaves `EXEMPT`, because that call now names both `encoding` and `errors`.
- `tests/test_validator_child_encoding.py`, 51 new tests.

**Captured case: the child writes UTF-8, and the tool reads UTF-8.** #230 gave
`run_validator` `errors="replace"` and no `encoding=`, "so that codec is the
one that reads it back". That was right only while the child wrote in the
locale's codec. Once the child is told UTF-8, the locale's codec misreads it:
`'日本'.encode('utf-8')` read as ascii is six U+FFFD, and read as cp1252 is
`'\xe6\u2014\xa5\xe6\u0153\xac'`. So `encoding="utf-8"` is
explicit. Dropping it fails
`test_a_real_bump_reads_the_validators_as_utf_8_whatever_the_locale_is`
(mutant `bump-decode-with-locale`). The words then reach the person through
the tool's own stdout, which #214 made safe: `manifest ok: \u65e5\u672c` on
a cp1252 stdout, `manifest ok: 日本` on UTF-8 and cp932.

**An operator's `PYTHONIOENCODING` is replaced for the captured pipe and
respected for the inherited one.** The operator's value describes the stream
their own words land in. The captured pipe is the tool's, so respecting
`cp1252:strict` there would put the failure back one pipe away from the
console. The inherited fd IS the operator's stream, so the child is told what
that stream is. The tests run under `cp1252:strict` and `cp1252:surrogateescape`
(the brief's two), plus `cp1252:replace` (an operator's own lenient handler:
`??` before and after), `cp932:strict` (a Japanese Windows) and `utf-8:strict`.

**Inherited case: the stdout's own encoding, never UTF-8, and built-in handler
names only.** UTF-8 bytes on a cp1252 pipe would be a different mistake. A
child cannot use a handler only its parent registered:
`PYTHONIOENCODING=cp1252:openreposhape.surrogateescape-then-backslashreplace`
still STARTS the child and fails with `LookupError` at the first character it
cannot encode. `test_the_handler_a_child_would_not_know_is_never_passed_on`
runs that child and asserts both halves. At the point the child is built,
`main()` has already turned a `strict` stdout into `backslashreplace` and a
`surrogateescape` one into the registered handler, so the helper maps
`strict`, `surrogateescape`, `surrogatepass` and the registered name to
`backslashreplace`, and keeps a handler that never raises.

One difference, and it is small: a byte of a file name that was not UTF-8,
read in as U+DC80..U+DCFF, is written by a validator as the text `\udcff`
where the tool writes the byte back. A validator prints one only for a path it
lists.

Fails on 60d08a1: with the new test files copied into a throwaway worktree at
60d08a1, **42 failed, 12 passed** (the 12 are controls that pass either way:
the five `--dry-run` bumps, `cp1252:replace` and `utf-8:strict` for both tools,
`cp932:strict` for `apply`, and #230's own two C-locale tests). The decisive
lines:
- bump, `cp1252:strict` and `cp1252:surrogateescape`: exit 2, `REFUSED
  bump-leg-validators-red: scripts/validate-manifest.py exited 1 after the
  rewrite`, `UnicodeEncodeError: 'charmap' codec can't encode characters in
  position 13-14`.
- apply, the same two: exit 2, `REFUSED update-validators-red: after the
  rewrite, scripts/validate-manifest.py refused.`
- the guard in `test_git_output_decoding.py` flags `run_validator` once its
  EXEMPT row is gone.

Passes on the head (5757e29):
- the new file (51), `test_stdout_encoding.py` (48), `test_bump_leg.py` (23),
  `test_update_shape.py` (77), `test_update_shape_add.py` (17),
  `test_git_output_decoding.py` (3), `test_repo_hygiene.py` (50) and
  `test_cli_reference.py` (13): **281 passed, 1 skipped** (the existing
  Windows-only default-stdout test);
- `test_shape_doctor.py`: **181 passed**;
- `test_family.py` and `test_update_shape_add_ignored.py`: **102 passed**;
- the head merged with main d89fffa, on the files above plus
  `test_update_shape_pr_base.py`: **392 passed, 1 skipped**.

**One extra finding, fixed by the same two changes.** Under `LC_ALL=C
PYTHONUTF8=0` (an ASCII stdout; UTF-8 mode has to be switched off by hand, as
it is on by default in a C locale) `validate-pins.py` raises on the `…`
(U+2026) it prints, so on 60d08a1 a real bump of a project named `Atlas` was
refused as well: exit 2, `REFUSED bump-leg-validators-red:
scripts/validate-pins.py exited 1`, `UnicodeEncodeError: 'ascii' codec can't
encode character '\u2026'`. On the head the same run exits 0. This is why the
C-locale bump test fails on 60d08a1 at the PINS validator.

**Emulation caveat.** The `cp932:strict` BUMP also fails on 60d08a1 when the
test runs on Linux, but only as an artefact: the child wrote cp932 and a UTF-8
locale cannot read it, where a Japanese Windows' own locale could. It is not
the defect and is not cited as one. The Windows-specific runs here are
emulations (`PYTHONIOENCODING=cp1252:surrogateescape`); **I did not run anything
on Windows**, and these tests first run on Windows in this PR's `tests-windows`
job. The two C-locale tests are POSIX-only (`skipif os.name == "nt"`) and skip
where the platform reads UTF-8 under `LC_ALL=C PYTHONUTF8=0`.

**Mutation probes.** Eleven hunks reverted one at a time in a throwaway
worktree, the whole new file run each time: all eleven killed.

| hunk reverted | failed | first killers |
|---|---|---|
| `captured_child_environment` sets nothing | 6 | both `captured_child` unit tests, bump `cp1252:strict`, `cp1252:surrogateescape`, `cp932:strict`, the C-locale bump |
| captured value `utf-8` with no handler | 2 | the two `captured_child` unit tests |
| `bump-leg.py` drops `env=` | 5 | the three bump cp1252/cp932 runs, the C-locale bump, the AST guard |
| `bump-leg.py` drops `encoding="utf-8"` | 1 | the C-locale bump |
| `update-shape.py` drops `env=` | 4 | apply `cp1252:strict`, `cp1252:surrogateescape`, the C-locale apply, the AST guard |
| inherited handler passed verbatim | 14 | `strict`/`surrogateescape`/`surrogatepass` unit and child cases, apply `cp1252:surrogateescape`, the C-locale apply |
| inherited always `backslashreplace` | 11 | `ignore`/`namereplace`/`replace`/`xmlcharrefreplace` cases, apply `cp1252:replace` |
| `replace` dropped from the never-raises set | 5 | the `replace` cases, apply `cp1252:replace` |
| inherited encoding forced to `utf-8` | 27 | every non-UTF-8 case |
| inherited encoding not canonicalised | 3 | `ANSI_X3.4-1968`, `latin-1`, the no-stdout case |
| inherited drops the no-stdout guard | 1 | `test_a_stdout_that_is_not_there_leaves_the_environment_alone` |

**Byte-identical where nothing was wrong.** A real bump and an `apply`, from
60d08a1 and from the head, on the same project, under `utf-8:strict`,
`utf-8:surrogateescape` and no `PYTHONIOENCODING`, named `Atlas` and named
`日本`: twelve pairs, stdout, stderr and exit code identical once the run
directory and the shas are masked.

**Sibling merge-trees** (`git merge-tree --write-tree HEAD <branch>`, local
tips of lane 5's branches and every open branch that touches `bump-leg.py`,
`update-shape.py` or `shape_materialize.py`): clean against
`fix/yaml-quote-boundaries`, `fix/yaml-block-key-nul`,
`fix/yaml-flow-mapping-keys`, `fix/scaffold-referent-chain-links`,
`fix/adopt-check-unsafe-path`, `fix/adopt-plan-quote-yaml-keywords`,
`fix/adopt-record-submodule-placement`, `fix/adopt-template-values-utf8`,
`fix/bump-leg-rollback-every-failure`, `fix/bump-leg-tracked-workflows-only`,
`fix/leg-path-yaml-scalars`, `fix/update-shape-add-dangling-symlink`,
`fix/update-shape-pr-base-branch` and `fix/update-shape-pr-create-failure`.
One conflicts: `fix/adopt-root-kept-symlinks` (`adopt-project.py` and
`scripts/shape_materialize.py`), and it conflicts the same way with origin/main
itself, so it is not this change.

- The shape copies a project runs itself. `scripts/bootstrap.py`,
  `scripts/validate-manifest.py` and the family root's `siblings.py` are
  digest-pinned and print the display name raw, so `make bootstrap` and `make
  validate` on a project named `日本` fail under a cp1252 stdout at their own
  `print`, before any child is run (checked: `bootstrap.py` raises at
  `bootstrap: <name> (<root>)`). That is a shape change, and it reaches
  projects through `update-shape.py`.
- `shape-doctor.py`'s `run_validator` already sets `PYTHONIOENCODING=utf-8`
  for the same validators and reads them as UTF-8, and it is unchanged. A
  display name holding a lone surrogate (`name: "a\ud83db"`) reaches it
  today. The project's own `validate-manifest.py` accepts that name, then
  raises printing it, so `make validate` and the doctor fail on a traceback
  (`INVALID (manifest)`). After this change, a real bump and `apply` print
  `manifest ok: a\ud83db` and pass. That is pre-existing, and the fix belongs
  in the validator or the doctor, not in these two callers: #280.

An adversarial review of 5757e29 found no blocker. Its one should-fix was a
false sentence in this body's "Left out", corrected above and filed as #280.
- It ran the inherited case on Python 3.10, 3.12, 3.13, 3.14 and 3.15rc2,
  including UTF-8 mode and the C locale. In each 28-run cell the head had 0
  failures; main had 8, or 12 under `LC_ALL=C PYTHONUTF8=0`.
- It compared 30 real runs between main and the head under UTF-8: stdout and
  stderr were identical.
- The head merged with c3f4987 passed: 264 passed, 1 skipped.

Three nits are kept as they are:
- **The inherited child's stderr.** It follows the parent's STDOUT encoding,
  because `PYTHONIOENCODING` sets both streams. With a console stdout and a
  redirected stderr, the child writes UTF-8 to a cp1252 stderr.
- **`errors="replace"` in `bump-leg.py`'s `run_validator`.** It is defensive.
  The child now always writes UTF-8, so a `strict` mutant survives every test.
- **The `\udcff` text.** This is the difference stated above.

The first head, 5757e29, failed `tests-windows`: 5 failed and 2477 passed, and macOS and Linux passed. All five failures were `test_an_apply_on_a_project_named_in_japanese_lands_under_any_stdout`. Its `assert_the_update_landed` compared the applied copy with the upstream clone's WORKING-TREE file. On Windows that file is CRLF, because the shared test helper `commit_upstream_changes` rewrites it with text-mode `write_text`. Git stores the blob as LF, which is what `apply` writes. The test now compares with the committed blob (`git show <b>:<path>`), as `test_update_shape.py` already does, and no product code changed.
- **Emulation on Linux.** The helper was made to write CRLF: the old assertion failed 6 `apply` cases, and the new one passed them.
- **Targeted runs.** The seven targeted files gave 274 passed and 1 skipped on the head merged with main b30595c (97c4280).
- **A candidate follow-up.** `commit_upstream_changes` leaves the upstream clone's working-tree copy CRLF on Windows for the rest of its module. Writing the appended bytes in binary would remove the trap at its source, but the helper is shared and outside this change.

Reviewed adversarially (APPROVE WITH NITS, 0 blockers); its one should-fix was a sentence in this body, corrected and filed as #280. Fix round 1 compares the Windows test with the committed blob. CI is green on Linux, macOS and Windows.

Closes #254

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…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>
brettheap added a commit that referenced this pull request Oct 12, 2026
…pelled `from subprocess import run`, `import subprocess as sp`, with only `encoding=` or `errors=`, through `getoutput`, with `**options` or passed uncalled, so none of them walks past #230's check any more (#321) (#335)

#230's guard (`TextModeCalls`) matched one spelling, `subprocess.<spawner>(...,
text=True)`, and checked that the call names `encoding` and `errors`. Every
other way to ask a child for text decodes with the locale's codec exactly as
#217 describes, and was invisible to it. Nothing but `tests/test_git_output_decoding.py`
changes: the shipped code already passes the hardened guard (see below).

- A call is a spawner call however `subprocess` is reached: `from subprocess
  import run` (also `as`, also `*`), `import subprocess as sp`,
  `sp = subprocess` and `sp: object = subprocess` (a plain and an annotated
  assignment alike), `other_module.subprocess.run`. A walrus, an unpacking or
  a `for` target that holds the module is not followed.
- `encoding=` or `errors=` alone asks for text, because `Popen` puts the pipes
  in text mode on either. `encoding=None` and `errors=None` do not ask.
- A literal `encoding=` that is not UTF-8 (`None`, `"cp1252"`) and a literal
  `errors=None` name nothing, so `text=True, encoding=None, errors="replace"`
  is refused. A variable is taken at its word: a tree cannot say what it holds,
  and the shipped code has none.
- `subprocess.getoutput` and `getstatusoutput` are refused unless EXEMPT lists
  the function: they are always text, and Python 3.9 (this standard's floor)
  takes no codec for them.
- A spawner call that expands `**options` without asking for text in a way the
  guard can see is refused unless the new `OPAQUE` table lists its function
  with a reason (a blank reason is refused too). A call that names `encoding=`
  and `errors=` itself needs no row: Python rejects a keyword given twice.
- A spawner referred to without being called (`functools.partial(subprocess.run,
  ...)`, `runner = subprocess.run`, `run: Callable = subprocess.run`,
  `class Child(subprocess.Popen)`, `map(run, argvs)`) is refused the same
  way. A type annotation is not a reference, but the value of an annotated
  assignment is.
- EXEMPT drops its `env_commit` and `cross_leg_references` rows, which were
  kept until #196 and #216 landed and exempt nothing now: `env_commit`'s call
  names both codec keywords (since #231), and #216 made `cross_leg_references`'
  `git grep` read bytes. EXEMPT keeps the two `gh api` digest rows.

Tests (`tests/test_git_output_decoding.py`, 3 -> 67): `SYNTHETIC` holds one
small module per form above, 24 of them. Each gets two tests, one that the
guard flags the module and names why, one that the same module fixed passes.
Eight allowed and four refused one-liners pin what the old guard already did,
and four more tests cover the annotation rule, a function listed in EXEMPT, a
function listed in OPAQUE (with a reason, with a blank one, and for another
function) and the reasons in the real table.

Fails on main: `TextModeCalls` as main has it, run over the same 24 modules,
finds nothing wrong in any of them (the guard lives in the test file, so the
head's tests cannot be pointed at main's guard directly). Each detection was
also removed in a scratch copy (19 mutants: from-import, star-import, `as`,
`sp = subprocess`, `sp: object = subprocess`, an annotated assignment's value
read as an annotation, `other_module.subprocess`, encoding/errors do not ask,
any encoding is UTF-8, `None` counts as a policy, expansion ignored, every
expansion refused, getoutput ignored, references ignored, annotations read as
references, blank reason accepted, EXEMPT ignored for getoutput, `text=False`
asks, the old call shape restored), and each failed the test named for it.

Unchanged: for everything the old guard saw, main's and the head's `found`
lists are identical over the shipped Python at 71b58a1 and at c7c02fa (rows in
place): 51 spawner calls, 33 of them text-mode, 0 offenders in either. At
346079d, with the two rows gone, the guard sees 51 spawner calls, 32 of them
text-mode (#216 made `cross_leg_references` read bytes), and flags 0. So no
shipped file changes and OPAQUE starts empty.

Left out: `interpreter_runs` in `tests/test_validator_child_encoding.py` reads
only `subprocess.run` too, and `os.popen` (text, no codec) is not read; none is
used in the shipped code. A check that each EXEMPT row still matches a text-mode
call would have caught the two rows this change drops.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
brettheap added a commit that referenced this pull request Oct 13, 2026
…-ASCII value into the commit as typed under any locale and on a git without `commit --trailer`, and refuses one whose bytes are not UTF-8 by name before anything is staged, so `Co-Authored-By: José` under `LC_ALL=C PYTHONUTF8=0` is no longer refused as "not a trailer" and a direct caller's is no longer committed as `Jos??` (#322) (#376)

Mechanism. Under a POSIX `C` locale with UTF-8 mode off `sys.argv` is decoded as ASCII with `surrogateescape`, so the two bytes of `é` arrive as the lone surrogates `\udcc3\udca9`. Measured on c7c02fa, two things happened to such a trailer. At the command line `trailer_line` refused it as "not a `<Key>: <value>` trailer" (exit 2, any git), because `one_printable_line` rejects category `Cs`. Handed straight to a commit helper, with `git_takes_trailer_option` forced False (git 2.43 here has the option, so an older git was emulated), `commit_trailers` appended it to the stdin message and the helper's `errors="replace"` (#230) wrote each surrogate as `?`: `Co-Authored-By: Jos??`. The issue named the second; the first is what the three tools actually do.

- `argv_text` puts the bytes back: `os.fsencode` gives them, and if they decode as UTF-8 the value is the text (`José`), the same string a UTF-8 locale hands over. A value with no escaped byte is returned as it is.
- `trailer_line` recovers first and judges the text after, so a trailer is accepted or refused as the characters it is: bytes that spell U+0085, U+2028 or a zero-width joiner are still refused.
- A byte that is not UTF-8 (a Latin-1 `\xe9`, a cut multi-byte sequence) is refused at the command line, exit 2, naming the trailer by its bytes (`b'Co-Authored-By: Jos\xe9'`) and saying why, instead of "not a trailer".
- `commit_trailers` recovers too and raises `Refusal("trailer-not-utf8")` for a trailer that still holds such a byte, on both paths, so a caller that skips the command line's check cannot commit a `?`. `bump-leg.py`'s `commit_once` and `family.py`'s `_commit_one` call it ahead of their stage, so the refusal leaves nothing staged; `update-shape.py apply` raises it inside its ledger, which rolls the branch and the copies back.
- The `--trailer` argument given to a git that takes it is `argv_spelling`: the string `os.fsencode` turns back into the UTF-8 bytes under any locale (the text under a UTF-8 one, nothing under Windows, which passes UTF-16). Without it the recovered `é` is not encodable under a `C` locale and `subprocess` raises.

Unchanged: an ASCII trailer takes exactly the paths it took, byte for byte (`argv_text` and `argv_spelling` return it as it is, and `test_no_trailers_leaves_the_commit_argv_byte_identical` and the order and equality tests pass untouched); the commit helpers' `errors="replace"` on the message BODY stays as #230 wrote it, and none of the three call forms changed, so `tests/test_git_output_decoding.py` is untouched.

Left out: the body of a message still writes a lone surrogate as `?` (a path a plan names, #230); no `--help` text changed, so `docs/cli.md` is not regenerated.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant