Repository navigation
shape-doctor.py: a .gitmodules section name keeps git's own bytes and is handed back to git as them, so configured_url and kept_gitdir find a member named caf\xe9, its detached commit gets its keep line under any locale, and a non-ASCII name no longer ends --root in a UnicodeEncodeError traceback under LC_ALL=C PYTHONUTF8=0 (#732) - #774
Conversation
…nd is handed back to git as them, so `configured_url` and `kept_gitdir` find a member named `caf\xe9`, its detached commit gets its keep line under any locale, and a non-ASCII name no longer ends `--root` in a UnicodeEncodeError traceback under `LC_ALL=C PYTHONUTF8=0` (#732) - `gitmodules_listing` decodes `git config -z --list` with `surrogateescape`; `submodule_keys` keeps each section NAME as git's bytes and gives each VALUE with the replacement, as before (#432, #385). - `git_bytes` hands such a name back to git as bytes on POSIX, and as text on Windows. `configured_url` and `kept_gitdir` ask git with it, and `kept_gitdir` reads git's answer as a path (`os.fsdecode`). - Tests: a holder whose member's submodule is NAMED in Latin-1 or in UTF-8, under a UTF-8 locale, `LC_ALL=C` and `LC_ALL=C PYTHONUTF8=0`: the emptied member's detached commit gets its keep line, and a section that lost its url finds the one the holder's config keeps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuidePreserve Git’s original section-name bytes throughout Sequence diagram for byte-preserving submodule lookupsequenceDiagram
participant Doctor as shape-doctor.py
participant Git as Git
participant Config as Repository config
Doctor->>Git: git config -z --file .gitmodules --list
Git-->>Doctor: Listing decoded with surrogateescape
Doctor->>Doctor: submodule_keys preserves NAME bytes
Doctor->>Git: git config --get submodule.NAME.url via git_bytes
Git->>Config: Read configured URL
Config-->>Git: URL
Git-->>Doctor: URL text
Doctor->>Git: git rev-parse --git-path modules/NAME via git_bytes
Git-->>Doctor: Kept repository path
Doctor->>Git: branch NAME COMMIT
Git-->>Doctor: Keep line for detached commit
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-3, 2026-10-17T04:14:52Z) Independent review of #774 by RV-c (opus), on head
SHOULD 1 (follow-up issue): the keep line names a path that does not exist.
SHOULD 2: say in Mutants:
Tests:
Merges: clean with Lane: openreposhape-3 (openRepoShape-3) |
… is not UTF-8 is still not found, and names the lookup that would find it (#732, review round 2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sts/test_doctor_legs_members_next.py keeps #748's two-line `conftest` import, then `listed_locale` Since #748 (431c8cf) main splits the `conftest` import of tests/test_doctor_legs_members_next.py over two lines to add `submodule_update_through_git`, and #774's commit 311ad7d adds `from test_doctor_gitmodules_not_utf8 import listed_locale` just below it. That was the one conflict, in that one hunk. Resolved by keeping main's two-line `conftest` import and then the `listed_locale` line. No other file conflicted. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#774 (l3-732-latin1-section) merged main bad05d2 at 012a772, resolving the one hunk in the import block of tests/test_doctor_legs_members_next.py (#748): main's two-line `conftest` import, then `listed_locale`. This branch changed none of those lines, so the merge is clean. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-3, 2026-10-17T19:23:37Z) RV775r4's check of the main merge into #774 (head
This lands after the v1.1.0 tag, under Brett Heap's word "merge lane 3's v1.1.1 drafts after the v1.1.0 tag". Lane: openreposhape-3 (openRepoShape-3) |
|
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="shape-doctor.py" line_range="5253" />
<code_context>
+ `modules/` and the name's bytes.
+ """
+ if os.name == "nt":
+ return text
+ return text.encode("utf-8", "surrogateescape")
+
</code_context>
<issue_to_address>
**Configured URLs or keep lines go missing**
When the doctor runs on Windows and a `.gitmodules` section name contains bytes that are not valid UTF-8, `git_bytes` returns surrogate-containing text, which Git for Windows converts to different bytes. `configured_url` and `kept_gitdir` then fail to find the member, so its URL or keep line is missing.
Have `git_bytes` preserve the original invalid UTF-8 bytes on Windows instead of returning surrogate-containing text.
</issue_to_address>Sourcery assessment
Approval pending. 1 finding to address first.
Blocking findings: shape-doctor.py:5253
| `modules/` and the name's bytes. | ||
| """ | ||
| if os.name == "nt": | ||
| return text |
There was a problem hiding this comment.
🟡 Medium · Configured URLs or keep lines go missing
When the doctor runs on Windows and a .gitmodules section name contains bytes that are not valid UTF-8, git_bytes returns surrogate-containing text, which Git for Windows converts to different bytes. configured_url and kept_gitdir then fail to find the member, so its URL or keep line is missing.
Have git_bytes preserve the original invalid UTF-8 bytes on Windows instead of returning surrogate-containing text.
Prompt for AI agents
In `shape-doctor.py` at line 5253:
**Configured URLs or keep lines go missing**
When the doctor runs on Windows and a `.gitmodules` section name contains bytes that are not valid UTF-8, `git_bytes` returns surrogate-containing text, which Git for Windows converts to different bytes. `configured_url` and `kept_gitdir` then fail to find the member, so its URL or keep line is missing.
Have `git_bytes` preserve the original invalid UTF-8 bytes on Windows instead of returning surrogate-containing text.Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…appended_to_command_scope`, the scan names a count worked out by hand, and `paste()` gives a pasted `submodule update` the `insteadOf` rewrite (#835) (#848) Stacked on #818 (`fix/758-git-command-scope`, `4a553e17`); retarget to main once it lands. The fix is one commit, `b9ad1187`, written on #818's `eaa72d9e`. The head `fafba9ec` merges #818's refresh `4a553e17` (main `fc867207`) into it, cleanly. Tests only. Nothing a user runs changes, and no pinned file changes. No README, handbook, `AGENTS.md` or `docs/cli.md` sentence is touched: none mentions these helpers (`render-cli-reference.py --check` passes). Both reviews say READY with no MUST: rv848 (issuecomment-6240157020) and lane 2's second review of #848 (issuecomment-6240228302, "rv818" below). One commit on `fafba9ec`, by explicit pathspec, fast-forward pushed. Tests only; no pinned file changes; main and #818 are not merged in. The rv818 numbers are next to rv848's. ### What each finding got | rv848 | rv818 | taken | what changed | |---|---|---|---| | 1 SHOULD: #811's and #838's counts | | the text | the merge section below says who converts it | | 2 NIT: stale merge checks | | yes | re-run at the head, lines corrected below | | 3 NIT: the walk counts arguments | 6 NIT: it does not read their values | yes, one change | `paste(row, cwd, None)` now fails the walk | | 4 NIT: `on_disk` is asked for `"true"` only | | yes | `ADDERS` holds `on_disk-false` | | 5 NIT: the `upload-pack` docstring | | yes | `paste_env` says what the rewrite adds: `pack-objects` and `index-pack` | | | 1 SHOULD: two command scopes built at collection | yes | #818's second review's SHOULD (#758's line 1), taken here so that #818 needs no third round | | | 2 NIT: an expected dict is flagged | yes | the scan leaves a dict that is an operand of a comparison | | | 3 NIT: what the scan misses | the text | "Known limits" below, no code | | | 4 NIT: the same lines in two files | yes | three helpers in `conftest.py` | | | 5 NIT: two surviving mutants | in part | the `count_of(env)` case is added; the `remotes_of` one is left, below | **rv818 SHOULD 1.** `test_adopt_reencoded_files.py`'s `attr.tree` param and `test_family_init_asks_every_value.py`'s `user.name` param were built at collection, before the session fixture put `gc.auto=0` and `maintenance.auto=false` in the environment. Laid over the environment at the test, each dropped the two from git's command scope. Each is a `lambda` its test calls, as `test_adopt_nested_attributes.py` already did. New `tests/test_command_scopes_built_at_the_test.py` (2 tests) reads each param off its test's own marks, builds it, and asks git for the command scope (`git config --show-origin --list`, read and never written). It asks for the session's two settings beside the param's entry. **It fails on `fafba9ec`** (the two test modules as they were, with the new `conftest.py`): `2 failed`, git read `['user.name=', 'user.email=', 'attr.tree=4b825dc6...']` and `['user.name=', 'user.email=', 'user.name=Ada Lovelace']`, with no `gc.auto=0`. It passes on the head. **rv848 3 and rv818 6.** `pastes_in` now returns, for each `paste` call, the fixture it hands the remotes of: `remotes_of(<name>)` as the third argument or as `remotes=`, where the name is `holder` or `scaffolded_here` and a parameter of the nearest function the call is in. `None`, another call, an expression on the call, a name the test does not take, or no third argument gives no fixture. The list of calls with none is `pastes_without_remotes`, a function of its own, so that a test of it can fail. 15 new cases hold the walk: twelve for what a call may hand and three for the function it is in. **rv818 2.** `counts_set_to_a_number` skips a `Dict` that is an operand of a comparison (`assert added == {"GIT_CONFIG_COUNT": str(held + 1), ...}`, `!=`, a dict on the left). I took the narrower reading of "inside an assert or a comparison": a dict in a call inside an assert (`assert run(env={"GIT_CONFIG_COUNT": "1"}).returncode == 0`), or one assigned before the comparison, still sets a count and is still read. Four cases are still found, three are left, and the `count_of(env)` case of rv818 5 is left. **rv818 4.** `JOB_ENTRIES`, `put_job_and_session_entries(monkeypatch)`, `command_line_entries(listing)` and `command_scope_at(environ, cwd)` in `conftest.py` replace the `delenv`, `held = ...` and loop lines of `test_no_auto_maintenance.py` and `test_doctor_paste_through_git.py`, and the four-line read of the `command line:` entries in three files. `OWN_COUNTS` follows: `conftest.py` holds two (the sum, and the job's entries counted from nothing), `test_no_auto_maintenance.py` one, and `test_doctor_paste_through_git.py` none. A new test checks that both readers put `GIT_CONFIG_PARAMETERS` aside (rv818's own round-2 NIT 8). **rv818 5, left.** `remotes_of` returning `fixture["base"]` instead of `fixture["base"] / "remotes"` still survives, and I name it equivalent in practice: the prefix `<base>/` covers every remote under `<base>/remotes`, so all the pasted clones are rewritten either way. ### Merge checks (replace the body's section) Re-run at the head `79b2eac4` (`git merge-tree --write-tree`), against 152 heads: every `fix/*` and `l3-*` branch, and the pull heads from #700. Main `fc867207` is an ancestor of the head (it came in with #818's refresh `4a553e17`). - **Main.** `fc867207`: clean. Main is `190133d5` now (two commits on `family.py`, the README and the handbook): clean too. - **#818's head `4a553e17`:** clean. The body's "one conflict, `tests/test_non_utf8_git_reads.py`" was true only for `b9ad1187` and `eaa72d9e`, before the refresh. - **The open heads that edit the files here merge clean:** #811 (`050abea6`), #820, #822, #828 (`ce0f2a5f`), #774 (`012a7726`) and #775 (`8c5ad0e8`). #761, #762 and #763 are the same heads as #828, #820 and #822. The body said #774 and #775 conflict; they do not, since the refresh. - **#838's pushed head `9166f84f`:** clean. - **Conflicts the other heads show** are the ones #818's head shows too (20 heads, in files this PR does not touch: `tests/test_adopt_git_reads.py`, `tests/test_non_utf8_git_reads.py`, `tests/test_dry_run_parity.py`, `shape-doctor.py`, `scripts/shape_materialize.py` and others). #806 (`76ae6419`) and #845 (`b18b243e`, branch `fix/834-oserror-skips`) conflict in `tests/test_adopt_git_reads.py`, which this PR does not touch. - **This commit adds two, both stale PR heads whose squash is in main:** #767's `bd6d2afe` (landed as `98f14976`, in `tests/test_family_add_spellings_remedies.py`) and #809's `98561405` (landed as `42aed788`, in `tests/test_family_init_asks_every_value.py`). - **Whichever of #848, #811 and #838 lands later converts that count to `appended_to_command_scope`.** #811 (`fix/777-typed-name-edges`, `050abea6`) adds `"GIT_CONFIG_COUNT": str(held + 1),` at `tests/test_setup_project_name_edges.py:190`: the scan finds `[190]` where `OWN_COUNTS` allows none, so `test_no_test_sets_a_git_config_count_of_its_own` fails once #848 and #811 are both on main. rv848 measured the same for #838's local `3845fd54` at `tests/test_github_name_limits.py:503`; the head #838 has pushed, `9166f84f`, holds no `GIT_CONFIG_COUNT` in that file. It is the goal of #835, so it takes no `OWN_COUNTS` entry. No file of theirs is changed here. ### Known limits (add to the body) Shapes the scan does not read, so a test that sets a count this way passes it: - a count key name imported from another module, held as a class attribute, bound by tuple unpacking or by a walrus (only a plain or annotated assignment of the name in the same module is read); - a value built by `format()`, `repr()` or `os.fsdecode()` (`str(...)`, an f-string, a `.format(...)` method and arithmetic are read); - a conditional value, `"2" if job else "1"`; - `setenv(name=..., value=...)`, with keywords; - an annotated subscript, `env["GIT_CONFIG_COUNT"]: str = ...`; - a value held in a variable and set later (`new = str(n + 1)`, then `{"GIT_CONFIG_COUNT": new}`); - an indexed `GIT_CONFIG_KEY_{n}` or `GIT_CONFIG_VALUE_{n}` name, which a hand-worked append writes beside its count; - a dict that is an operand of a comparison, by design (it is the value a test expects). This replaces a separate issue for them. ### Tests, through `run9.sh` - At the commit `79b2eac4`: **222 passed** (`test_command_scopes_built_at_the_test.py` 2, `test_family_config_appends.py` 8, `test_doctor_paste_through_git.py` 21, `test_no_auto_maintenance.py` 60, `test_adopt_reencoded_files.py` 53, `test_family_init_asks_every_value.py` 78). - The same content, run before the commit: **368 passed** (those and `test_doctor_legs_members_next.py` 127, `test_clone_just_pushed.py` 19) and **253 passed** (`test_doctor_next_is_a_command.py`, `test_doctor_validator_rows.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`). - New tests: 24 (2 + 11 + 2 + 9). Before the round: `test_doctor_paste_through_git.py` 10, `test_family_config_appends.py` 6, `test_no_auto_maintenance.py` 51. - Fail on `fafba9ec`: the command-scope test above, `2 failed`. The walk, `on_disk-false` and the scan cases pass on the code of `fafba9ec`, which is right; what they add shows in the mutants, where four survive the files of `fafba9ec` and none survives these. ### Mutants, 29 in a throwaway clone at the head, each caught - **rv848 4:** `on_disk` ignoring its value. Caught by `on_disk-false` (2 failures); survives the `test_family_config_appends.py` of `fafba9ec`. - **rv848 3, rv818 6:** one caller handing `None` (M01), `remotes_of(tmp_path)` or `remotes_of(project)`: each caught by the walk of the real file, and each survives the walk of `fafba9ec`. Eight mutants of the walk itself (the `remotes=` keyword, the parameter check, the remote-fixture check, the argument count, the `remotes_of` name, the nearest-function rule, the call check, and `pastes_without_remotes` always empty): each caught by its own case. - **rv818 1:** each of the two params built at collection again, and each building nothing: 4 mutants, each caught by `test_command_scopes_built_at_the_test.py`. - **rv818 2:** no skip of expected dicts (4 failures), every dict in an assert skipped (4), only the left operand skipped (3), only the comparators skipped (1). - **rv818 5:** any call by a bare name taken as worked out: caught by the `count_of(env)` case. - **rv818 4:** the helper putting only the job's entries (2), the reading taking another line (10), either helper keeping `GIT_CONFIG_PARAMETERS` (1 each), the job's second entry another value (6). - **`OWN_COUNTS`:** `conftest.py` allowed one (2 failures), this file allowed two (1), no allowance for `test_family_config_appends.py` (1). 1. **Line 1.** `on_disk`, `with_config` and `typed_with_local_remotes` each worked out their `GIT_CONFIG_COUNT` from the environment's own count. Each is now one call of `appended_to_command_scope(env, [(key, value)])`; `typed_with_local_remotes` sets each key the helper returns, inside its `monkeypatch.context()`. The scan found a fourth: `test_clone_just_pushed.py`'s `env` for `clone.defaultRemoteName` summed its own count too, and goes through the helper. 2. **Line 2.** `counts_set_to_a_number` finds a count worked out in the same four places it finds one written out (a dict, a keyword, a `setenv`-style call, a subscript): a `str(...)` call, an f-string, a `.format(...)` call or arithmetic. `OWN_COUNTS` lets `conftest.py` hold the one sum that remains, in `appended_to_command_scope`. 3. **Line 3.** `paste(row, cwd, remotes=None)` and `paste_env(remotes=None)`. Given the fixture's remotes, they append `insteadof_entries(remotes)` after `protocol.file.allow`, in the same count. Every `paste` in `test_doctor_legs_members_next.py` hands `remotes_of(<fixture>)`, and a walk of that file holds each call to it. The issue says each site is right today, and it is: the held-two case below passes on the base. What the helper adds is an empty `GIT_CONFIG_COUNT`, which git reads as no entries and `appended_to_command_scope` counts as none (`or 0`). The sums written out by hand died on `int("")`. `tests/test_family_config_appends.py` (6 tests) starts each helper under `GIT_CONFIG_COUNT=2`, the Windows job's two entries, and asks `git config --show-origin --list`: both held entries and the new key, in order. It starts each under an empty count and checks the entry is the first. `typed_with_local_remotes` is asked from inside `typed_as_printed`, and the environment is checked to be back as it was. **Yes.** Measured by running the extended scan over every `tests/*.py` at the base: it finds exactly the three sites, `test_clone_just_pushed.py:99`, and `conftest.py:290`, the helper itself. No other file, so no false positive. - The scan reads the SHAPE of the value and never evaluates it. `str(...)`, an f-string, `.format(...)` and arithmetic are found. A value held in a name, read by subscript (`added["GIT_CONFIG_COUNT"]`) or returned by any other call (`env.get(...)`) is still not read, and so is `int(env.get("GIT_CONFIG_COUNT", "0"))`, which sets nothing. `is_a_written_count` still reads constants only; the new `is_a_worked_out_count` is the one that reads shapes, and both docstrings say so. - `test_the_scan_leaves_a_count_that_is_worked_out` moved its `worked-out` and `a-call` (`str(held)`) cases to the found list, and is now `test_the_scan_leaves_what_it_does_not_read`, with the cases that are still not read. - `OWN_COUNTS` gains `conftest.py: 1` and the two new files (`test_family_config_appends.py: 2`, `test_doctor_paste_through_git.py: 1`), each a test that builds an environment on purpose. A second sum in `conftest.py`, or none, is named, as it is for every other file. - `test_the_scan_names_each_helper_that_worked_its_count_out` holds the three helpers' old source, so the scan names the line of each. **Yes.** Measured on a pasted `git -C <root> submodule update --init --recursive --checkout -- code` of an empty mount, with `GIT_TRACE`: | | no rewrite (base) | with the rewrite | |---|---|---| | clone's store | 8 loose objects, 0 packs | 0 loose objects, 1 pack | | link count of the loose objects | 2 (linked to the remote's own files) | none | | `upload-pack` in the trace | yes (a local clone starts it to list the refs) | yes | | `pack-objects` and `index-pack` in the trace | no | yes | | `origin`, and the `submodule.code.url` the root records | the plain path | the plain path | Across the whole of `test_doctor_legs_members_next.py` (trace2 events of all 56 `sh -c` lines it runs), no pasted line ran `pack-objects`: every pasted clone was a local clone. A pasted fetch needs no rewrite (it asks `upload-pack` whatever the URL is), so the rewrite matters where a mount is cloned: 14 of the 31 tests that paste. **The issue's Verify names `upload-pack` and `pack-objects` for the `GIT_TRACE` run. `upload-pack` is in both columns,** so the tests ask for `pack-objects` and `index-pack`, and for the store `assert_store_read_through_git` asks of every other clone. #645 keeps maintenance from starting, so nothing breaks today. #662 put every other read of these remotes through git as a second guard, and `paste` was the one left. The rewrite is for the directory resolved, which is how `.gitmodules` records each url; no pasted line names a remote itself, so `typed=True` is not needed. `tests/test_doctor_paste_through_git.py` (10 tests): - a pasted update of an empty mount of a project, and of a holder's member with its legs: `pack-objects` and `index-pack` run, `assert_read_through_git`, `origin` equal to the url `.gitmodules` records, no `insteadOf` stored in the root or the clone; - the control, handed no remotes: a local clone, loose objects, no `pack-objects`; - the entries in order: the environment's held entries and the session's two settings, then `protocol.file.allow`, then each `insteadOf` entry; - the walk: every `paste` of the doctor file is given three arguments, and the walk's own five cases. Base `eaa72d9e`, before: the three family files 117 passed; `test_no_auto_maintenance.py`, `test_clone_just_pushed.py` and `test_repo_hygiene.py` 132 passed; `test_doctor_legs_members_next.py` 127 passed. On the head `b9ad1187`, through `run9.sh` (gh stub first, `GH_TOKEN=bogus`, empty `GH_CONFIG_DIR`): - **340 passed:** `test_family_head_lost_gitmodules.py`, `test_family_bootstrap_gate_order.py`, `test_family_add_spellings_remedies.py`, `test_family_config_appends.py`, `test_no_auto_maintenance.py`, `test_clone_just_pushed.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`. - **261 passed:** `test_doctor_paste_through_git.py`, `test_doctor_legs_members_next.py`, `test_validate_family_remedies_typed.py`, `test_doctor_next_is_a_command.py`, `test_doctor_validator_rows.py`. New or changed tests: 31 new (6 + 10 + 15; `test_no_auto_maintenance.py` goes from 36 to 51 tests). **Fail on the base**, in a tree detached at `eaa72d9e` with the new test files copied in: - the helpers' tests and the scan: 4 failed, 53 passed. The empty-count case fails for `on_disk`, `with_config` and the typing helper (`ValueError: invalid literal for int()`), and `test_no_test_sets_a_git_config_count_of_its_own` names `test_clone_just_pushed.py [99, 233]`, `test_family_add_spellings_remedies.py [138]`, `test_family_bootstrap_gate_order.py [136]` and `test_family_head_lost_gitmodules.py [98]`; - the same file with the scan as it was (written counts only): 14 failed; - `test_doctor_paste_through_git.py`: ImportError (`remotes_of`), 10 not run; with `paste` ignoring the remotes (mutant 3a), the three pasted-clone tests fail. **Mutants**, 22 in a throwaway clone at the head, each caught: - 1a-1c each helper back to a hand-worked sum: `test_no_test_sets_a_git_config_count_of_its_own`, and the empty-count case; 1d the clone's `env`: the same scan test; 1e `on_disk` keeping a count of 1 held in a name, which the scan does not read: the held-two case; - 2a-2d `str`, `.format`, arithmetic and f-string dropped from `is_a_worked_out_count`; 2e-2h each of the four readers back to written counts; 2i-2k an `OWN_COUNTS` entry removed; 2z the scan as it was on the base; - 3a `paste` not handing `remotes` on; 3b `paste_env` dropping the rewrite; 3c the rewrite put before `protocol.file.allow`; 3d one caller not handing the remotes (the walk); 3e `remotes_of` naming the wrong directory. - origin/main `fc867207`: one conflict, `tests/test_non_utf8_git_reads.py`, the one #818 has with main at its old head. It is the same with the base `eaa72d9e`, and none of the files here conflicts. - #818's head now, `4a553e17` (main merged in): clean. - The 141 other heads (every `fix/*` and `l3-*` branch, and the pull heads from #700): the conflicts they show are the same with the base, in files this commit does not touch (`tests/test_non_utf8_git_reads.py`, `tests/test_adopt_git_reads.py`, `tests/test_dry_run_parity.py` and others). This commit adds one: #767's stale PR head `bd6d2afe`, which landed as `98f14976`. - The open heads that edit the files here merge clean with it: #820, #822, #828, #748, #747, #761, #762 and #763. #774 and #775 conflict only in `tests/test_non_utf8_git_reads.py`, as they do with the base. - Left out: `GIT_CONFIG_KEY_{n}` and `GIT_CONFIG_VALUE_{n}` names built from an index (an f-string) are not read, nor is a count held in a name and set later (`new = str(n + 1)`, then `{"GIT_CONFIG_COUNT": new}`). A test that did that would pass the scan. Reading the indexed key names in the same four places would close it. - `typed=True` is not used for `paste`; the entries are for the resolved directory. Review: rv848, lane 5's independent review, READY (issuecomment-6240157020); lane 2's independent review, READY (issuecomment-6240228302); rv848's re-verification of the fix round 79b2eac, READY (issuecomment-6241026517). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 73b6d2b (the restack of the branch onto main after #818 landed) passed tests, tests-git-bash and SonarCloud; the head 5b27685 is a clean update-branch merge of main ca47b71, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4dbb4a9 over main ca47b71: the adopt_reencoded_files, clone_just_pushed, command_scopes_built_at_the_test, doctor_legs_members_next, doctor_paste_through_git, family_add_spellings_remedies, family_bootstrap_gate_order, family_config_appends, family_head_lost_gitmodules, family_init_asks_every_value, no_auto_maintenance, repo_hygiene, agents_md_codes, cli_reference sets, 645 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green". Closes #835 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…trap_after_bump.py` imports both `appended_to_command_scope` and `needs_a_byte_name` (#715) Main moved from `0dee22b3` to `ca47b71d` while round 3 was paused (#846, #779, #772, #738, #842, #814, #790, #820, #774, #830 and #841). `git merge-tree` of `8b4dbcf6` with it stopped in one path: - `tests/test_bootstrap_after_bump.py`, its imports. Main's `aec3b38f` (tests append what they tell git to the command scope) added `appended_to_command_scope` to the `from conftest import (...)` list. This branch added `from test_bootstrap_spelling import needs_a_byte_name` below it (#715, round 1). Both are kept. `scaffold-project.py`, `scripts/bump-leg.py` and `scripts/shape_materialize.py` merged without a conflict. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(`[submodule ""]`, a `..` component), a `path` or `url` beginning with `-`, a section with no `path`, a file git refuses for a bare `path`, `url`, `ignore`, `update` or `branch` key or for a value it dies on (`update = !cmd`), and an unmerged `.gitmodules` name no mount url, NEXT line or clone, where git says `No url found` or refuses the file and exits 128 (#733) (#775) Closes #733. `shape-doctor.py` and `siblings.py` read `.gitmodules` more loosely than git does, so they named a mount url, a NEXT line or a clone where `git submodule init` says `No url found` or refuses the file and exits 128. Now both read only what git reads: - a section whose name is empty or holds a `..` component is skipped whole; - a `path` or `url` value beginning with `-` is skipped, that key alone; - a section with no `path` names no mount, and a url is keyed by its section's `path` alone; - a key with no value at all (a bare `path`, `url`, `ignore`, `update` or `branch`) makes git refuse the working tree's file whole, so no url is read from it; an index or HEAD copy is read up to that line, and the sections before it keep their urls; - a value git dies on (an `update` other than `none`, `checkout`, `rebase` or `merge`, a `shallow` that is no boolean, a `fetchRecurseSubmodules` that is neither a boolean nor `on-demand`) refuses the file in any copy; - an unmerged `.gitmodules` names no url. The rules are in `submodule_keys`, `section_url`, `gitmodules_entry` and `mounted_from` of `shape-doctor.py`, and in `submodule_urls` of `templates/family-root/scripts/siblings.py`, where they sit in the `git config -z --list` loop #805 gave it. Section names keep git's bytes, so names that differ only in bytes that are not UTF-8 stay apart, as in git. `tests/test_doctor_legs_members_next.py` and `tests/test_siblings_inputs.py` check the doctor and `make siblings` against `git submodule init` over each case and its controls. git 2.43 crashes on a bare `branch` (exit 139), and the rows that ask it skip there; git 2.55 stops at it as at any other key. Lane 3's independent reviewer RV-c (opus) found it READY in rounds 2 and 3, and RV775r4 (opus) in rounds 4 and 5 and in its check of the last merge of main (#775 issuecomment-6229804341, -6229900472, -6237014956, -6237376002 and -6239488425). After #774 landed, it was restacked onto #774's squash by three merges, and its changed lines equal the reviewed delta (#775 issuecomment-6244253158). It lands on Brett Heap's word "merge lane 3's v1.1.1 drafts after the v1.1.0 tag" (2026-10-17T13:00:57Z). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-3 (openRepoShape-3)
Claim: #732 (comment)
Closes #732.
Lands after the v1.1.0 tag. #700 (#668), which the issue says it lands after, is on main as
55c6ba7d.What was wrong (re-measured on main
e3137010, git 2.43.0)I built a superproject with a real submodule NAMED
members/caf\xe9(Latin-1) atmembers/IRRS. I made a commit on its detached HEAD, then rangit submodule deinit -f.LC_ALL=C.UTF-8and underLC_ALL=C,checkout_reachgives('checkout', None), so the update line is handed over with no keep line. The ASCII control (members/cafe) gives the keep tuple, andkept_branch_lineprintsgit --git-dir=…/.git/modules/members/cafe branch NAME <commit>.configured_urlmisses the same name. A section that kept itspathand lost itsurl, with the url in the root's config, givessubmodule_url→('members/caf\ufffd', None). The mount is called url-less.LC_ALL=C PYTHONUTF8=0. Python's filesystem encoding is ASCII there, and it is the encodingsubprocessuses for astrargument.UnicodeEncodeErrorout ofsubprocessinconfigured_urlorkept_gitdir. That includes valid UTF-8 (caf\xc3\xa9).shape-doctor.py --root <holder> --jsonthen exits 1 with an empty stdout and a traceback, measured on holders this PR's tests build.What changed, in
shape-doctor.pygitmodules_listing(:5019-5047) decodesgit config -z --listwithsurrogateescaperather than the replacement, so nothing git listed is lost.submodule_keys(:4986-5016) keeps each section NAME as git's own bytes, thesurrogateescape. It still gives each VALUE with the replacement (shape-doctor.pysubmodule_configreads a family holder's.gitmodulesthroughgit_out, which decodes strictly, insideexcept (Refusal, OSError), so a member url that is not UTF-8 raises a UnicodeDecodeError thatrun_checksdoes not catch andshape-doctor.py --rootends in a traceback, exit 1 (also the code of a finding), with no verdict, wherevalidate-family.pyandsiblings.pyread the same file as bytes or witherrors="replace"#432, scripts/shape_advisory.pyrepository_namesand shape-doctor.pyorigin_urlandorigin_namereadgit remote get-url originthroughgit_outand catchRefusalandOSErroronly, so an origin URL that is not UTF-8 makesreadanswernot-answered(a single repository loses the Triad advisory, a leg clone its pointer to the assembly root) and endsshape-doctor.py --rootin a UnicodeDecodeError traceback, exit 1, where an ASCII origin gives NOT A SHAPE ROOT, exit 2 #385): a url is compared withorigin_url's reading and printed, somounted_fromand themembersrow read urls exactly as before. The name is never printed; it is only handed back to git.git_bytes(:5080-5094, new) turns such a name back into the argument that hands git the same bytes. On POSIX that is bytes, so neither a byte that is not UTF-8 nor an ASCII filesystem encoding changes it. On Windows, whose command line is text, it is the text itself.configured_url(:5050-5077) asksgit config --getforsubmodule.<name>.urlthroughgit_bytes.kept_gitdir(:5116-5148) asksgit rev-parse --git-path modules/<name>throughgit_bytes, and reads git's answer as a path (os.fsdecode).kept_headand the keep line hand that path back to git unchanged.git_out, whose strict decode failed on that path.ascii_text, as every row does, socaf\xe9shows ascaf?(quoted).submodule_config's docstring (:7791-7843) now says where each part is decoded.Tests
tests/test_doctor_legs_members_next.py:1807-1878.member_namedgives the holder fixture's member a submodule NAMED with bytes: its.gitmodulesheader (committed), its section of the holder's config, the repository git keeps and the mount's.gitfile. The names aremembers/caf\xe9(Latin-1) andmembers/caf\xc3\xa9(UTF-8). Each runs under three locales: a UTF-8 localelocale -alists,LC_ALL=C, andLC_ALL=C PYTHONUTF8=0. The tests skip on Windows.test_732_a_member_named_in_bytes_gets_its_keep_line: an emptied member's detached commit getsbranch NAME <commit>in themembersrow. The ASCII case istest_413a_an_emptied_members_kept_repository_names_its_detached_commit.test_732_a_member_named_in_bytes_whose_url_the_config_keeps_has_one: a member whose section lost its url is not called url-less. The ASCII case istest_414_3_a_member_url_the_holders_config_keeps_still_gets_the_line.shape-doctor.py, 8 of these 12 fail.LC_ALL=C PYTHONUTF8=0, where the doctor printed no JSON.LC_ALL=C, which main already handled.tests/test_doctor_gitmodules_not_utf8.py:312(shape-doctor.py:submodule_configandgitmodules_entryread a holder's.gitmodulesthrough onegit config -z --list, so a section name holding a space or a byte that is not UTF-8, or a url holding NEL or U+2028, gives the doctor the mount url git andmake siblingsuse, under any locale (#668) #700's test).assert_read_as_git_readsnow expects the section name fromgitmodules_entrydecoded withsurrogateescape.Commands on head
311ad7d9, each run with a stubgh(exits 97) first on PATH,GH_TOKEN=bogusand an emptyGH_CONFIG_DIR, fromtests/:The skips and the xfail are in
test_shape_doctor.py, which this PR does not change.Pinned shape copies
None.
shape-doctor.pyis not a pinned copy.Neighbours
#733's PR is stacked on this one: it changes
submodule_keysagain. This diff stays in the.gitmodulesreaders that #700 added or changed, plusconfigured_urlandkept_gitdir. It does not touchpin_restore(#736), the partial-clone row (#754) or the lockstep NEXT (#750).Round 2 (head
93b9bac3)RV-c's review (issuecomment-6229515744) is READY. This round takes SHOULD 2 alone, as a docstring:
git_bytes(shape-doctor.py:5080-5102) now says that on Windows a section name that is not UTF-8 is still not found. Git for Windows'xwcstoutfturns the lone surrogate into ED B3 A9, not E9. A UTF-8 name is found there.git config -z --listand match the key's bytes in Python, then build the kept repository's path fromgit rev-parse --git-common-dir,modules/and the name's bytes. That is not built here.SHOULD 1 (the doctor prints a non-ASCII path as
?) is lane 3's own issue.On
93b9bac3:Round 3: main
bad05d21merged (#748's import block)Main moved to
bad05d21. Its merge with93b9bac3conflicted in one hunk of one file, the import block oftests/test_doctor_legs_members_next.py, since #748 (431c8cf8): main splits theconftestimport over two lines to addsubmodule_update_through_git, and this PR's311ad7d9addsfrom test_doctor_gitmodules_not_utf8 import listed_localejust below it. No other file conflicted. Nothing else changes: this PR is still a draft and still lands after the v1.1.0 tag.conftestimport, then thelisted_localeline. The merge commit is012a7726(parents93b9bac3andbad05d21), the head. Its combined diff against both parents:On
012a7726, under a stubgh(exits 97),GH_TOKEN=bogusand an emptyGH_CONFIG_DIR:#775, stacked on this PR, takes this head and main in its own round.
🤖 Generated with Claude Code
Summary by Sourcery
Preserve Git’s original bytes for
.gitmodulessection names so submodule URL and repository lookups remain reliable across locales and non-UTF-8 names.Bug Fixes:
.gitmodulessection names when resolving configured URLs and retained submodule repositories, preventing missing keep lines and locale-dependent UnicodeEncodeError failures.Enhancements:
Tests: