Skip to content

adopt-project.py: _elector keeps a user.name byte that is not UTF-8 for #255's question, _remote_repository reads such an origin URL as no org/repo, and plan asks adopt-no-org before checked_value, so a Latin-1 name is refused as adopt-unwritable-value naming --elected-by, and plan, check and execute go on past such an origin, where both ended in a UnicodeDecodeError traceback (#292) - #353

Merged
brettheap merged 15 commits into
mainfrom
fix/adopt-git-reads-not-utf8
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #292 (comment)

Merged main after #270 landed

#270 landed on main as the squash f795cf7 (its final head was 3cee853), and this PR, stacked on it, now targets main. The branch carried an older copy of #270's commits, so a plain merge of main conflicts in adopt-project.py. It took two merges: #270's final head 3cee853 (81df4e2, no conflict), then origin/main ab623dd (dcbc37b). The second conflicted in adopt-project.py only, and the whole file takes this branch's side: the squash's content of the file is 3cee853's (git diff 3cee853 f795cf7 -- adopt-project.py tests/test_adopt_template_values.py is empty), which the first merge had brought in. At dcbc37b, git diff HEAD^1 HEAD on those two files is empty, git diff --stat origin/main HEAD lists adopt-project.py and tests/test_adopt_git_reads.py only, and git merge-tree --write-tree origin/main HEAD is rc 0. This PR's own change to adopt-project.py is the same lines it was before the merges. Each count below says the head it was run at.

Fix round (review of dcbc37b)

The review of dcbc37b found no P1 or P2, and four nits for this PR. They are fixed in two commits on top of dcbc37b (main is not merged again), head 96ff0c7:

  • N1, 344028e. The adopt-no-org sentence said "the source has no origin to read an organisation from" of a source that has an origin whose URL is not UTF-8, which is the case this PR newly sends there (and of one whose URL ends in no org/repo). It now reads "the source's origin names no organisation this tool can read: it has none, its URL is not UTF-8, or its URL ends in no org/repo". The refusal, exit 2 and pass --org <org> remediation are the same. test_plan_without_org_refuses_a_source_that_names_none takes the new bytes and a third source, no-pair (an origin of repo.git).
  • N3, 344028e. Deleting the moved args.org = checked_value("--org", args.org) survived this file and tests/test_adopt_plan.py: plan --org=-evil exited 0 and wrote org: "-evil", check said plan ok, and only execute refused it. test_plan_refuses_an_unsafe_org_that_is_passed[leading-dash|dash-only|space] pins exit 2, REFUSED unsafe-value: --org is '-evil' and no plan file.
  • N2, 96ff0c7. The four user.name tests skipped in a checkout whose own .git/config sets user.name, because that outranks the GIT_CONFIG_GLOBAL file they wrote, and the _elector fix was then untested. On POSIX the name is now also given in git's command scope (GIT_CONFIG_COUNT, GIT_CONFIG_KEY_<n>, GIT_CONFIG_VALUE_<n>), which outranks every config file, appended to any count the environment already holds: the Windows job sets GIT_CONFIG_COUNT=2, and replacing it would drop core.autocrlf and core.longpaths. The GIT_CONFIG_GLOBAL file stays where an environment variable cannot carry the byte (Windows); CI is the only Windows runner, so CI is what confirms that route is unchanged. Two tests pin the route on any machine: a checkout made in tmp_path with a local user.name still reads the name given, and a count of 2 in the environment survives.
  • N4. This description: the Composes with table, the two Left out items, and the claim about which test files were run are re-measured at 96ff0c7.

What

adopt-project.py asks git two questions whose answers are bytes it did not write, and now reads both without dying on one that is not UTF-8:

  • _elector reads git config user.name (asked when --elected-by is not given) with binary=True and decodes it with surrogateescape, as sys.argv keeps such a byte. The lone surrogate then meets the question adopt-project.py: a value execute writes into a file is asked whether a file can hold it, in plan, in check and in execute before any leg exists, so a byte that is not UTF-8 in --elected-by, --reference or a local --source directory no longer pushes both legs before write_lf raises (#255) #270 asks of every value execute writes, and plan refuses it as adopt-unwritable-value, exit 2, before any plan is written:

    REFUSED adopt-unwritable-value: --elected-by (not given, so `git config user.name`) is 'Jos\udce9', which `execute` writes into the assembly root's `project.yaml` and `README.md`, and it holds a byte that is not UTF-8.
    Remediation: no plan was written. Pass each value named again, retyped; when `--elected-by` is not given, `plan` reads `git config user.name`, so pass the flag or correct that. ...
    
  • _remote_repository catches UnicodeDecodeError beside Refusal and returns None, as it does for a source with no origin: such a URL names no org/repo. plan takes --org and records repository: null; check and execute name the source by its path and adopt it.

  • _checked_plan_inputs asks adopt-no-org before checked_value. Since v0.2 (v0.2: adopt an existing repository in place, reuse an empty root, scaffold a declared descendant #5) the or "-" stand-in for a missing org reached checked_value first, which refused --org is '-', a value nobody passed, so adopt-no-org was never reached. The issue's "What it should do" 1 assumed it was; now it is, for a source with no origin, for one whose origin is not UTF-8, and for one whose URL ends in no org/repo. The refusal says each of the three, because it is asked of a source that has an origin as often as of one that has none (the fix round's N1).

  • _refuse_an_unwritable_clone_url's docstring said a UTF-8 clone of a source whose path is not UTF-8, passed with --source, raised in _remote_repository; it now says that route adopts too.

git_out in scripts/repo_shape.py is untouched: it is a pinned copy, and a change there would move every project's shape pin (the issue's point 3).

Why

git keeps a config value and a remote URL as the bytes it was given, so a repository can hold either without anyone having done anything wrong: a name saved in a legacy code page, a clone of a directory named in one. Both reads decoded strictly through git_out, and UnicodeDecodeError is a ValueError, not a Refusal, so main did not catch it: the run ended in a traceback, exit 1, naming neither the git setting nor the flag (--elected-by, --org) that gets past it. Reproduced on #270's head before it landed (edfa7c6, then 72bb869) and again on main ab623dd (local only):

input run main this PR
global user.name = Jos\xe9, no --elected-by plan exit 1, UnicodeDecodeError ... byte 0xe9 in position 3 from _elector, no plan exit 2, adopt-unwritable-value as above, no plan
ASCII user.name (control) plan exit 0, elected_by: Jose the same
Latin-1 user.name, --elected-by 'T H' (control) plan exit 0 the same
origin URL holding \xe9, --org plan exit 1, the traceback from _remote_repository exit 0, repository: null
the same, no --org plan exit 1, the traceback exit 2, adopt-no-org, "pass --org "
no origin at all, no --org plan exit 2, unsafe-value: --org is '-' exit 2, adopt-no-org
origin URL holding \xe9, a plan written earlier check exit 1, the traceback exit 0, plan ok
the same execute --yes --local-remote-dir exit 1, the traceback, no leg exit 0, adoption verified
a UTF-8 clone of a source under caf\xe9/, execute --source <clone> execute exit 1, the traceback exit 0, adoption verified

The issue said _elector "should not decode with errors="surrogateescape" and carry on", because the surrogate would be recorded and execute would die after pushing both legs. On this branch it does not carry on: #270's unwritable_values refuses it in _checked_plan_inputs, right after _elector returns, before the plan is written, with the setting and the flag named. That is the refusal the issue asked for, and it is one sentence with every other value plan cannot write.

Tests

tests/test_adopt_git_reads.py, new, 17 tests. It reuses test_adopt_template_values.py's runners (strict stdout, bytes, every run read for a traceback). user.name comes from a GIT_CONFIG_GLOBAL file written with write_bytes and, on POSIX, from git's command scope as well (command_scope, the fix round's N2), so the four tests that use it run in a checkout whose own config sets user.name too; the origin URL is written into the source's .git/config as bytes, so it runs on every OS; each skips where git does not give the bytes back as written.

  • test_plan_refuses_a_user_name_that_is_not_utf8: exit 2, the refusal naming git config user.name and --elected-by, no traceback, no plan.
  • test_plan_records_a_user_name_that_is_utf8[ascii|utf8]: controls, recorded as itself.
  • test_elected_by_given_adopts_under_a_user_name_that_is_not_utf8: control, plan, check and execute under the Latin-1 config with the flag, adoption verified.
  • test_plan_reads_an_origin_that_is_not_utf8_as_none[not-utf8|ascii]: repository: null; the ASCII URL's control reads cafe/repo.
  • test_plan_without_org_refuses_a_source_that_names_none[not-utf8|no-origin|no-pair]: adopt-no-org, with one sentence for the three, not unsafe-value.
  • test_plan_refuses_an_unsafe_org_that_is_passed[leading-dash|dash-only|space]: --org=-evil, --org=- and --org='my org' exit 2 as unsafe-value, naming --org, with no plan written.
  • test_plan_without_org_takes_the_org_an_ascii_origin_names: control, org: cafe.
  • test_check_and_execute_adopt_a_source_whose_origin_is_not_utf8: plan ok, adoption verified.
  • test_a_utf8_clone_of_a_source_whose_path_is_not_utf8_adopts: adopt-project.py: a value execute writes into a file is asked whether a file can hold it, in plan, in check and in execute before any leg exists, so a byte that is not UTF-8 in --elected-by, --reference or a local --source directory no longer pushes both legs before write_lf raises (#255) #270's clone-URL refusal, then execute --source <UTF-8 clone> adopts (POSIX; skipped where no directory holds the byte 0xE9).
  • test_the_user_name_given_outranks_a_checkouts_own and test_the_user_name_given_is_appended_to_the_config_the_environment_holds (POSIX): the N2 route, on any machine.

Against main's code (this file at 96ff0c7 copied into a detached worktree of origin/main 28bdc84, and again of 92e29d9, the same 7 failing): 7 failed, 10 passed. The 7 fail for the issue's reason: 5 on the UnicodeDecodeError traceback (_elector once, _remote_repository four times), and [no-origin] and [no-pair] on REFUSED unsafe-value: --org is '-'. The 10 that pass are the controls, the three --org tests (that check exists on main) and the two user.name route tests. The 11-test file was 6 failed, 5 passed against edfa7c6, 72bb869 and ab623dd; the one more failure is no-pair.

The N2 case, emulated. This worktree's own config has no user.name, and the shared checkout's [user] section is gone, so a user.name that outranks GIT_CONFIG_GLOBAL was given from outside, in the environment (GIT_CONFIG_COUNT=1, GIT_CONFIG_KEY_0=user.name, GIT_CONFIG_VALUE_0=Outer; command scope outranks a local config, so this skips the old helper exactly as a local user.name does). dcbc37b's file under it: 7 passed, 4 skipped. This file under it: 17 passed, 0 skipped; plain: 17 passed; in a scratch clone whose own config sets no user.name: 17 passed; under the Windows job's own two entries plus the name: 17 passed.

The N2 survival proof, in a detached scratch worktree of dcbc37b whose only change is _elector decoding strictly (name.decode("utf-8")): dcbc37b's own test file under the outer user.name runs 7 passed, 4 skipped, 0 failed, so the mutant survives: the four tests that would kill it skip, which is the state N2 removes. The same file without the outer name fails 1 (test_plan_refuses_a_user_name_that_is_not_utf8), so the skip is the cause. This PR's file on the same mutated code under the outer name fails 4: that test, and the three adopt-no-org rows that fail only because dcbc37b's code still has the old sentence (N1).

Mutants, at 96ff0c7 in a scratch clone, each run with that outer user.name standing in for a local one, one per decisive line, each caught except where it says so:

mutant change failed
strict decode _elector returns name.decode("utf-8") 1: test_plan_refuses_a_user_name_that_is_not_utf8 (1 without the outer name too)
replace decode("utf-8", "replace") 1, the same test (1 plain)
ignore decode("utf-8", "ignore") 1, the same test (1 plain)
no .strip() _elector drops .strip() 3: that test and test_plan_records_a_user_name_that_is_utf8[ascii] and [utf8] (3 plain)
_remote_repository catches Refusal only except Refusal: 4: test_plan_reads_an_origin_that_is_not_utf8_as_none[not-utf8], test_plan_without_org_refuses_a_source_that_names_none[not-utf8], test_check_and_execute_adopt_a_source_whose_origin_is_not_utf8, test_a_utf8_clone_of_a_source_whose_path_is_not_utf8_adopts
the or "-" stand-in back args.org or ... .split("/")[0] or "-" 3: the three adopt-no-org rows
checked_value("--org", ...) deleted (N3) the line removed 3: the three test_plan_refuses_an_unsafe_org_that_is_passed rows (run with tests/test_adopt_plan.py: 3 failed, 70 passed, 1 skipped)
the old adopt-no-org sentence (N1) the old text back 3: the three adopt-no-org rows
the helper without its command-scope route (N2) the two lines in global_user_name removed plain: 2 failed, the two route tests; under the outer name: 1 failed (test_the_user_name_given_is_appended_to_the_config_the_environment_holds) and 5 skipped

Targeted runs, git-filter-repo 2.47.0 on PATH, each row at the head its last column names (cae1bf5 before the merges, dcbc37b after them, 96ff0c7 now; the dcbc37b rows of 14 files were run by the lane's review of dcbc37b):

file tests result run at
tests/test_adopt_git_reads.py (new) 17 17 passed 96ff0c7
tests/test_adopt_template_values.py 78 78 passed 96ff0c7
tests/test_adopt_plan.py 57 56 passed, 1 skipped (:393, needs a SHAPE_MEDXEHR_CLONE checkout) 96ff0c7
tests/test_git_output_decoding.py 67 67 passed 96ff0c7
tests/test_scaffold_leg_path_collisions.py 104 104 passed 96ff0c7
tests/test_cli_reference.py 13 13 passed 96ff0c7
tests/test_repo_hygiene.py 50 50 passed 96ff0c7
tests/test_shape_doctor.py 257 254 passed, 3 skipped (the PowerShell-paste test, Windows host only) 96ff0c7
tests/test_adopt_literal_names.py 14 passed dcbc37b
tests/test_adopt_clone_template.py 31 passed dcbc37b
tests/test_windows_paths.py 38 passed dcbc37b
tests/test_adopt_execute_refusals.py 37 passed dcbc37b
tests/test_adopt_e2e.py 19 passed dcbc37b
tests/test_adopt_verify.py 16 passed dcbc37b
tests/test_adopt_spec_only.py 14 passed dcbc37b
tests/test_adopt_entry_paths.py 64 passed dcbc37b
tests/test_adopt_history_gitlinks.py 45 passed dcbc37b
tests/test_adopt_leg_path_collisions.py 95 passed dcbc37b
tests/test_adopt_plan_escapes.py 59 passed dcbc37b
tests/test_adopt_submodules.py 133 passed dcbc37b
tests/test_adopt_finding_spelling.py 128 passed cae1bf5
tests/test_adopt_root_shape_paths.py 120 passed cae1bf5
tests/test_adopt_leg_paths.py 349 passed cae1bf5
tests/test_adopt_gitmodules_values.py 187 passed cae1bf5
tests/test_adopt_ignored_shape_files.py 30 passed cae1bf5
tests/test_adopt_mount_ignored.py 26 passed cae1bf5

scripts/render-cli-reference.py --check passes at 96ff0c7; no --help text changed. dcbc37b holds 20 tests/test_adopt_*.py files, where cae1bf5 held 18: test_adopt_clone_template.py and test_adopt_literal_names.py arrived with the merges, and were first run against this PR's code by the review of dcbc37b. Every one of the 20 was run at cae1bf5 or dcbc37b, and the six marked cae1bf5 were not re-run since. The two commits since dcbc37b change one sentence in adopt-project.py and one test file, and no other file under tests/ names the sentence (git grep -e 'to read an organisation' -e 'names no organisation' outside those two finds only test_setup_project_py.py and test_setup_sh.py, which are about setup-project.py).

Composes with

git merge-tree --write-tree at 96ff0c7 against every open head at 15:38Z, in this repository's object store (origin/main 28bdc84, a983e6a since, and 92e29d9 at 16:41Z, not fetched by this writer):

other head rc conflicts resolves
origin/main 28bdc84, a983e6a and 92e29d9 (#377, which changed adopt-project.py and four adopt test files) 0 none
the 11 open PRs on adopt-project.py: #394 48bdbf9, #387 69c54c9, #384 a96297e, #382 7e55d00, #378 2da8691, #377 c35c896, #373 5ac03de (lane 3), #371 34b9500 (lane 5), #356 5a5011d (lane-mate fix/adopt-check-hand-edited-plan), #314 a5438a5, #184 f416e13 0 each none
the 17 other open PRs that do not touch adopt-project.py (#395, #390, #381, #380, #379, #376, #375, #374, #357, #355, #354, #352, #347, #346, #343, #267, #266) 0 each none
#358 ebffb08 (fix/doctor-manifest-kinds-unreadable-pin, based on fix/doctor-root-shape-pin-unreadable) 1 shape-doctor.py the same conflict against origin/main 28bdc84 alone; this branch does not touch shape-doctor.py, so it adds none.
the lane's 13 other open heads at 16:50Z, against origin/main 92e29d9 too: #267 8badae0, #266 3410be7, #356 0aff5ae (the merge of a983e6a), #343 9e6caeb, #346 818db36, #347 e965ab0, #352 797c96b, #355 a03abcf, #401 6124cb4, #403 e2c78ee, #404 f9d420a, #386 d53602b (stacked on #356), #359 6eb8e4e 0 each none #386 conflicts with 92e29d9 alone and merges with this branch; the rest merge with both.
#358 2745890 (the head since ebffb08) 1 shape-doctor.py the same against origin/main 92e29d9 alone; this branch adds none.

The merge of 96ff0c7 and 92e29d9 (git merge --no-commit in a scratch tree: clean, tree b2b4b7f; its diff against 92e29d9 is adopt-project.py and tests/test_adopt_git_reads.py only) ran test_adopt_git_reads.py, test_adopt_plan.py, test_adopt_template_values.py, test_adopt_entry_paths.py, test_adopt_plan_escapes.py, test_adopt_literal_names.py, test_adopt_github_exits.py and test_scaffold_leg_path_collisions.py: 418 passed, 1 skipped (test_adopt_plan.py:393, SHAPE_MEDXEHR_CLONE unset).

#356 and #382 conflict with origin/main 28bdc84 alone (main's newer commits on adopt-project.py) and merge with this branch; that is theirs against main, not something this branch adds or lifts. At dcbc37b the review merged #356 5a5011d into this branch (ce952fb) and ran test_adopt_git_reads.py and test_adopt_execute_refusals.py on the result: 92 passed. That was not re-run at 96ff0c7. None of the 11 heads on adopt-project.py pins the old adopt-no-org sentence in a test: git grep for it finds it in adopt-project.py alone at each of them.

One meaning to keep in view when #314 (#261, a blank or malformed --elected-by) and this land together: both ask about the elector plan takes from git config user.name. They merge textually (rc 0); on this branch _checked_plan_inputs asks #270's question of the elector right after _elector returns.

Left out

Closes #292

🤖 Generated with Claude Code

Summary by Sourcery

Make project adoption robust when Git configuration or origin data contains non-UTF-8 bytes.

Bug Fixes:

  • Handle non-UTF-8 Git user names and origin URLs without terminating with UnicodeDecodeError tracebacks.
  • Report non-UTF-8 or malformed origins as unavailable repositories and provide appropriate organization guidance.
  • Reject unsafe explicitly supplied organization values before writing a plan.

Enhancements:

  • Validate unwritable Git-derived elector names with actionable refusal messages and preserve existing Git configuration precedence when testing and reading names.

Tests:

  • Add coverage for non-UTF-8 Git configuration values, malformed origins, organization validation, and adoption through check and execute.

brettheap and others added 8 commits October 11, 2026 22:38
…er a file can hold it, in `plan`, in `check` and in `execute` before any leg exists, so a byte that is not UTF-8 in `--elected-by`, `--reference` or a local `--source` directory no longer pushes both legs before `write_lf` raises (#255)

Since #247 the plan writer keeps a lone surrogate (a byte that is not
UTF-8, as Python decodes argv and paths) as `\udce9`, and the reader gives
it back. `plan --elected-by b'Jos\xe9'`, `--reference b'ref-\xe9'` and a
source under a directory `caf\xe9` with local remotes therefore passed
`plan` and `check`, and `execute` created and pushed both leg remotes
before `write_lf` raised `UnicodeEncodeError` (exit 1). Before #247 `plan`
raised and nothing was created. A hand-edited `\ud800` in `elected_by`,
`elected_on`, `reference`, `visibility`, a pin or a follow-up ended the
same way, and a NUL or a line break there went into the manifest.

`shape_materialize.unwritable_reason` asks the question, beside
`one_printable_line`: one line, no lone surrogate, no control character.
It is `one_printable_line` less `Cf`, and it takes the empty value: a
Persian name joins with U+200C, which a file holds and `execute` adopted
before #247 and since, and an empty `elected_by` is what `plan` records
when `git config user.name` names nobody.

- `plan` refuses `--elected-by` and `--reference` as
  `adopt-unwritable-value` and writes no plan.
- `check` reports `elected_by`, `elected_on`, `reference`, `visibility`,
  each pin and each follow-up as `plan-unwritable-value`, through
  `_entry_findings`, so `execute` refuses the same findings in `check`'s
  words through `_refuse_what_check_finds`, before `_create_leg_remotes`.
- `execute` refuses a local clone URL (the source's own path under
  `--local-remote-dir`) as `adopt-unwritable-value`, right after
  `_repository_urls`. `check` cannot know it will be written, and against
  GitHub it is an https URL, so `plan` and `check` still pass such a
  directory, as #247 made them.
- `Source.open` refuses a `local_path` that does not exist and is not text
  as `source-unresolvable`, where a NUL or a surrogate reached `print` or
  `subprocess` as a traceback.
- `check`'s WHAT WILL HAPPEN shows the plan's visibility, adopt branch,
  leg paths and follow-ups through `_shown`, so a surrogate there no
  longer raises on a strict stdout before the finding is printed.

tests/test_adopt_template_values.py covers each argv route, the local
source directory, each hand-edited value, `local_path`, the printed
values, and controls (`José`, U+200C, a `café` source directory) that
still adopt.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…value `execute` writes is refused before anything exists, and the clone-URL refusal's remediation moves the source and plans again, so no README line is shown reversed and following the remediation adopts instead of raising (#255)

Fix round 1 on PR #270, answering lane openRepoShape-2's review at
aed9190 (F1, F2, F3, and N1's test point).

F1. `unwritable_reason` was `one_printable_line` less all of `Cf`, so
U+202A..U+202E and U+2066..U+2069 passed `plan`, `check` and `execute`
and went raw into `project.yaml`, the README's `Elected by` line and the
split commit's message; a viewer showed the rest of that line reversed.
Issue #255's question, `one_printable_line`, refuses them. The question
is now `one_printable_line` less `Cf` other than those nine
(`BIDI_CONTROLS`, by code point), plus the empty value. The joiners
U+200C and U+200D, U+00AD, U+FEFF and the marks U+200E, U+200F and
U+061C stay writable: a Persian name needs U+200C, and a mark sets no
span's direction. `plan` refuses such a flag as
`adopt-unwritable-value`, `check` finds such a plan value as
`plan-unwritable-value`, `execute` refuses it before any leg exists, and
a local clone URL holding one is `adopt-unwritable-value`.

F2. The test file held a raw U+202E, which SonarCloud's text:S6389
flags, and other invisible literals a reader cannot see. Each is now an
escape; only `é` and `日本` stay literal. The two composite
assertions SonarCloud's python:S9073 flagged are four, each with a
message.

F3. The clone-URL refusal said to clone the source to a UTF-8 path and
pass it to `execute` with --source. Followed, that raised: the clone's
`origin` is the old path, and `_remote_repository` reads it as UTF-8.
The remediation now says to rename or move the source and run `plan
--source <its new path>`, `check` and `execute` again, and the test
follows it to `adoption verified`, for a byte that is not UTF-8 and for
a bidi override in the directory's name.

N1. The same-sentence test now asserts both exit codes and that no run
raised.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er.name` as `--elected-by (not given, so `git config user.name`)` when it refuses it, so the refusal no longer names a flag the person never passed (#255)

Fix round 1 on PR #270, N2 of lane openRepoShape-2's review at aed9190.

With no --elected-by, `plan` records `git config user.name`. A TAB
there was refused as `--elected-by is 'Jo\tSmith'`, naming a flag the
operator never passed. It is now named as #181's `FLAG_DEFAULTS` names a
value `plan` filled in, and the remediation says to pass the flag or
correct `git config user.name`. A test runs `plan` under
`GIT_CONFIG_GLOBAL` holding a TAB name, and skips where a repository's
own `user.name` answers first.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the manifest back through the reader instead of matching its line, so it holds on main, which since #250 writes a YAML template's value as `double_quoted` spells it (#255)

Fix round 1 on PR #270. Built on the merge with main be2790e, the
U+200C control failed: `execute` adopted, but main b30595c (#250)
renders a value between a YAML template's double quotes through
`double_quoted`, so `project.yaml` holds `"Ali‌Reza"` with the
character spelled as an escape, and the test looked for it raw. The
reader gives back U+200C either way, so the control now asks the reader,
and also asks for the name in the shape's README, which is not YAML and
holds it as it stands on this branch and on main. GitHub's checks run
on the pull request's merge with main, so they would have failed here.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lvable` before anything runs, so `gh repo clone` is no longer handed it as its first argument, where gh's own parser read it as one of its options (#255)

SonarCloud's S8705 on #270 asked whether a value reaches a command as
an option. The lane's re-verify found one that does: `plan
--source=-u/x` is no path that exists and holds a `/`, so
`Source.open` took it for an `org/repo` name and ran `gh repo clone
-u/x <dir> -- --quiet`. gh read `-u` as `--upstream-remote-name` with
the value `/x`, and `--source=--upload-pack=x/y` as a flag it does not
know. A hand-edited `local_path:` or `repository:` reaches the same
call from `check` and `execute`. It cannot reach git as an option
(gh hands git only what follows its `--`), and main does the same.

The guard sits in the condition this PR already edits. No GitHub owner
name begins with `-`, so nothing gh could clone is refused, and an
existing path that begins with `-` is still taken as a path.

The tests put a stub `gh` first on PATH that records each call: `plan`
with either value, and `check` and `execute` on a plan whose
`local_path:` holds it, each exit 2 `source-unresolvable` with no
traceback and no leg, and the stub records nothing. Against 7e9cdc3's
code all four fail with the stub's record of `repo clone -u/x ...` and
`repo clone --upload-pack=x/y ...`. A control shows the stub records
the clone of `testorg/Thing`.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s `_entry_findings` composed with #255's, and this PR's pair renamed `unwritable_findings`/`unwritable_remediation`

#231 (6f45bf9) puts each entry's path first in `_entry_findings` and
returns `unsafe_remediation` ahead of the rest; this branch appends each
value `execute` writes into a file that no file can hold. The two hunks
are its docstring's order sentence and its return. The sentence keeps
main's order and appends this PR's clause; the return is
`(findings + submodule_findings + unwritable_findings,
  unsafe_remediation + remediation + unwritable_remediation)`, so the
paths still come first, as #196 needs.

This PR's local pair `value_findings, value_remediation` is renamed
`unwritable_findings, unwritable_remediation`. #261's branch (ca21b57)
binds `value_findings, value_remediation` in the same function, from
`_manifest_findings`: a resolution keeping both lines under one name
would silently drop one PR's findings. With the rename it cannot.

The rest of main (#254/#281, #238/#287, #203/#236, #204/#227,
#249/#250, #241, #215 and the earlier merges) merges with no conflict.
The resolution is the lane's re-verify one (`resolve-main.py`, 385
passed on 6f45bf9), rename aside.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s `_entry_findings` order sentence composed with #255's

#313 (71b58a1, #262 and #263) makes `check` find a kept root path where
the shape writes a directory or its `shape/` copy, through
`_root_shape_findings` called from `_leg_findings`, and makes
`shape_materialize` refuse a copy whose own path is taken. This branch
appends each value `execute` writes into a file that no file can hold
to `_entry_findings`. The one conflict is that function's docstring, the
sentence naming the order of its findings: it keeps #313's words (each
entry's `leg:`, the two mount paths and what the root keeps where the
shape writes, then the source's own submodules) and appends this PR's
clause, ", then each value `execute` writes into a file that no file can
hold (#255)". Nothing else in the file conflicts: the return still reads
`(findings + submodule_findings + unwritable_findings,
  unsafe_remediation + remediation + unwritable_remediation)`, and
`_leg_findings` carries #313's findings inside `findings`.

Main's c0ed615 (#308, family.py) comes with it and touches none of this
branch's files. Main has since moved on to c7c02fa (#315, family.py);
this merge stops at 71b58a1.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-8 for #255's question, `_remote_repository` reads such an origin URL as no `org/repo`, and `plan` asks `adopt-no-org` before `checked_value`, so a Latin-1 name is refused as `adopt-unwritable-value` naming `--elected-by`, and `plan`, `check` and `execute` go on past such an origin, where both ended in a UnicodeDecodeError traceback (#292)

git keeps a config value and a remote URL as the bytes it was given, so a
repository can hold either without anyone having done anything wrong: a
name saved in a legacy code page, a clone of a directory named in one.
`_elector` (`git config user.name`, asked when `--elected-by` is not
given) and `_remote_repository` (`git remote get-url origin` of a local
source) read both through `git_out`, which decodes strictly, and caught
only `Refusal`. `UnicodeDecodeError` is a `ValueError`, so `main` did not
catch it either: `plan` (and, for the URL, `check` and `execute`) ended in
a traceback, exit 1, naming neither the setting nor the flag past it.

`_elector` now reads the name as bytes and decodes it with
`surrogateescape`, as `sys.argv` keeps such a byte. It does not carry on
with it: #270's `unwritable_values` asks it, in `_checked_plan_inputs`,
right after `_elector` returns, and refuses it as `adopt-unwritable-value`,
named as `--elected-by (not given, so `git config user.name`)`, with the
remediation to pass the flag or correct the setting, before any plan is
written.

`_remote_repository` returns None for a URL it cannot decode, as for a
source with no origin: no `org/repo` is named by it. `plan` takes `--org`
and records `repository: null`, and `check` and `execute` name the source
by its path and adopt it, as does `execute --source` with a UTF-8 clone of
a source whose own path is not UTF-8 (whose origin is that path).

Without `--org`, such a source was to meet `adopt-no-org`. It never could:
since v0.2 (#5) the `or "-"` that stood in for a missing org reached
`checked_value` first, which refused `--org is '-'`, a value nobody
passed. `adopt-no-org` is now asked first, for a source with no origin and
for one whose origin is not UTF-8.

`git_out` itself is untouched: `scripts/repo_shape.py` is a pinned copy,
and a change there would move every project's shape pin.

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

sourcery-ai Bot commented Oct 12, 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 10 hours and 18 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T17:01:21.798593Z 96ff0c7 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: cae1bf534c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

brettheap and others added 2 commits October 12, 2026 14:22
#270 landed on main as the squash f795cf7, and this branch was stacked on
#270, so it still carries an older copy of #270's commits. Merging #270's
final head 3cee853 first brings those commits up to the head the squash
holds, so the merge of origin/main that follows can take this branch's
side of adopt-project.py instead of conflicting with the squash.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…270 landed as the squash f795cf7

#270 landed on main as the squash f795cf7 and this branch, stacked on
#270, was retargeted to main. The previous commit merged #270's final
head 3cee853, so this merge changes adopt-project.py only by what that
commit already brought in: the squash's content of adopt-project.py is
3cee853's, and `git diff 3cee853 f795cf7 -- adopt-project.py` is empty.

adopt-project.py conflicted, and the whole file takes this branch's side,
which holds 3cee853 plus #292's own change. No other file conflicted.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-12T14:54Z)

This lane reviewed its own PR #353 at dcbc37b, read-only, in its own detached worktrees and one scratch clone. It authored nothing.

MERGEABLE WITH NITS. There is no sequencing constraint. dcbc37b already contains main ab623dd. tests-macos and tests-windows must still pass at this head.

The issue's promise holds. On plan, check and execute, neither read ends in a traceback:

  • A Latin-1 user.name is refused as adopt-unwritable-value, naming the setting and --elected-by, before any plan exists.
  • An origin URL that is not UTF-8 reads as no origin.
  • adopt-no-org is reached.

The two merges keep the PR's diff byte for byte.

Nits for the fix round:

Follow-ups. None is in #292's promise, and the PR makes none worse:

  • adopt-project.py:855 _default_branch: a branch named caf\xe9 ends plan in a traceback.
  • scripts/shape_advisory.py:440, and shape-doctor.py:3094 and :3538: an origin holding 0xE9 drops the Triad advisory, and shape-doctor.py --root crashes on it.
  • scripts/family.py:705: init with a Latin-1 user.name ends in a traceback.
  • adopt-project.py:1621: an organisation read from a UTF-8 origin (café, my repos) is refused as --org is 'café', a flag nobody passed.

Tests:

Merges with 21 open heads: 19 are rc 0. #314 and #358 conflict exactly as they conflict with main alone.

The full report is in opensoft/brett-wip at handoffs/xFactory/attachments/lane-openRepoShape-2/review353-dcbc37b/final-report.md.

brettheap and others added 2 commits October 12, 2026 15:33
…ource whose `origin` is not UTF-8 or ends in no `org/repo` is no longer told it has no `origin`, and a passed `--org` is pinned to its `unsafe-value` refusal (#292)

Review of dcbc37b, N1 and N3.

N1: #292 routes a source whose origin URL is not UTF-8 to `adopt-no-org`.
The sentence said "the source has no `origin` to read an organisation
from", which is false of that source: `git remote -v` shows its origin. It is
false too of a source whose URL ends in no `org/repo`. The refusal, its exit
(2) and its remediation (`pass --org <org>`) are unchanged; the sentence now
reads "the source's `origin` names no organisation this tool can read: it
has none, its URL is not UTF-8, or its URL ends in no `org/repo`".
`test_plan_without_org_refuses_a_source_that_names_none` takes the new bytes
and a third source, `no-pair`, an origin of `repo.git`.

N3: #292 moved `args.org = checked_value("--org", args.org)` below
`adopt-no-org`. Deleting that line survived this file and
`tests/test_adopt_plan.py`: `plan --org=-evil` exited 0 and wrote `org:
"-evil"`, `check` said `plan ok`, and only `execute` refused it, at
`_repository_urls`. `test_plan_refuses_an_unsafe_org_that_is_passed` pins
exit 2, `REFUSED unsafe-value: --org is '-evil'` and no plan file, for
`-evil`, `-` and `my org`.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…me in git's command scope as well as in `GIT_CONFIG_GLOBAL`, so they run, and the `_elector` fix is tested, in a checkout whose own config sets `user.name` (#292)

Review of dcbc37b, N2.

`global_user_name` skipped whenever `git config user.name`, asked in the
checkout, did not answer the name the test wrote to its `GIT_CONFIG_GLOBAL`
file, and a `user.name` in the checkout's own `.git/config` outranks that
file. In such a checkout (the shared one had one for a while) the file
reported 7 passed, 4 skipped, and mutants of `_elector`'s decode (strict,
`replace`, `ignore`, no `.strip()`), which only those four tests kill, went
uncaught.

On POSIX the name is now also given as `GIT_CONFIG_COUNT`,
`GIT_CONFIG_KEY_<n>` and `GIT_CONFIG_VALUE_<n>`, git's command scope, which
outranks every config file. The entry is APPENDED to a count the
environment already holds: the Windows job sets `GIT_CONFIG_COUNT=2`
(`core.autocrlf`, `core.longpaths`), and replacing it would drop both. The
`GIT_CONFIG_GLOBAL` file stays, as the only route where an environment
variable cannot carry the byte (Windows). The byte goes in through
`os.fsdecode`.

Two tests pin the route without depending on whose machine runs them:
`test_the_user_name_given_outranks_a_checkouts_own` makes a checkout with a
local `user.name` in `tmp_path` and reads the name given, and
`test_the_user_name_given_is_appended_to_the_config_the_environment_holds`
sets a count of 2 and reads both entries and the name.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-12T16:52Z)

The fix round on this lane's review of dcbc37b is complete at 96ff0c7: 344028e takes N1 and N3, and 96ff0c7 takes N2. The body is now v3.

  • N1. adopt-no-org now names all three causes: no origin, an origin URL that is not UTF-8, and a URL that ends in no org/repo.
  • N2. The four user.name tests also take the name from git's command scope, so they run where the checkout's own config sets user.name. Before, they skipped there, and a mutant of _elector that decodes strictly survived on dcbc37b (7 passed, 4 skipped). It is now killed.
  • N3. A test now pins checked_value("--org").

Tests at 96ff0c7

  • tests/test_adopt_git_reads.py: 17 passed.
  • tests/test_shape_doctor.py: 254 passed, 3 skipped (Windows-only).
  • Merged with main 92e29d9 (git merge-tree rc 0): 418 passed, 1 skipped, across the adopt files and test_scaffold_leg_path_collisions.py.
  • Mutants: 8 of 8 killed.
  • CI is green on all three OSes at 96ff0c7.

Details are in handoffs/xFactory/attachments/lane-openRepoShape-2/impl292-adopt-git-reads/fix-round-0279.md in opensoft/brett-wip.

@brettheap

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 96ff0c7a23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

This was referenced Oct 12, 2026
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit dd8d0ea into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/adopt-git-reads-not-utf8 branch October 12, 2026 19:11
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…plan-manifest-invalid` joins `_entry_findings` after #255's values, and this branch's `plan-root-symlink` stays after the source's own submodules

origin/main moved 92e29d9 -> dd8d0ea (twelve commits), merged by sha:
- f0a8bf6 (#330 via #381), scripts/family.py;
- 87d2eb7 (#333 via #379), update-shape.py;
- cd1fc79 (#327 via #375), shape-doctor.py;
- 45e01f4 (#348 via #396), tests/test_agents_md_codes.py: every
  code-shaped name AGENTS.md spells is a code a tool prints;
- 3ef059c (#258 and #261 via #314), adopt-project.py, scaffold-project.py
  and scripts/shape_materialize.py: the project's YAML reader and its
  `validate-manifest.py` are asked about the manifest's values before
  anything exists; `check` reports `plan-manifest-invalid` through
  `_manifest_findings` in `_entry_findings`; `_repository_urls` refuses
  `adopt-name-too-long`;
- f7f4b14 (#366 via #399), scripts/family.py;
- cb7063f (#301 and #280 via #390), scripts/repo_shape.py and the
  template's validate-manifest.py: a bracket or brace in a plain flow
  scalar is refused;
- 5678f68 (#278, #297 and #298 via #357), shape-doctor.py;
- 53bee5b (#289 via #352), scripts/bump-leg.py;
- f5cb616 (#369 via #398), scripts/shape_materialize.py: the docstrings
  of `force_stage` and `stage_written`;
- a92e60b (#326 via #400), scripts/family.py;
- dd8d0ea (#292 via #353), adopt-project.py: `_elector` keeps a
  `user.name` byte that is not UTF-8, `_remote_repository` reads an
  origin URL that is not UTF-8 as none, and `adopt-no-org` is asked
  before `checked_value`.

scripts/shape_materialize.py, scripts/repo_shape.py and every other file
merge with no conflict. adopt-project.py conflicts in two hunks of
`_entry_findings`, each resolved by keeping both sides:
1. The docstring. Main's order, with #261's manifest findings after
   #255's values and before #190's relative urls; this branch's symlinks
   stay after the source's own submodules.
2. The return. Main's `_manifest_findings(plan)` call, and the findings
   in main's order: each entry's path (#196), coverage, the legs and what
   the root keeps where the shape writes, the source's own submodules
   (#166), this branch's `_root_symlink_findings`, #255's values, #261's
   manifest findings, and #190's relative urls last. The remediation and
   the NOTEs are main's.

How each of main's commits that reach this branch's code meets it:
- 3ef059c: `_manifest_findings` asks the validator the template ships
  about the values `execute` writes; it reads no symlink and no path
  the shape writes. Its materializer half adds the validator's helpers,
  `refuse_unstorable_names` and `MANIFEST_VALIDATOR`, and holds
  `election_date` to the validator's `DATE_RE`; it leaves `place`,
  `_materialize`, `copy_tree`, `template_files` and `_refuse_unwritable`
  as they were.
  In `execute`, its `adopt-name-too-long` (in `_repository_urls`) comes
  before #255's clone-URL refusal, `_refuse_what_check_finds` and this
  branch's `core.symlinks` probe, all before any leg. Its new paragraph
  of the module docstring follows the one 6b53a6d corrected; both merge
  with no conflict.
- f5cb616: docstrings only.
- dd8d0ea: `_remote_repository`, `_checked_plan_inputs`' organisation and
  `_elector`, none of them this branch's.
- cb7063f: the plans `check` and `execute` read go through this reader.
  The plans `plan` writes for this branch's tests hold their entries as
  block mappings, each path `double_quoted` where it needs to be, and
  the rehearsal below read every one of them.

Checked on the merged tree:
- AST (fin183g/names.py, base 92e29d9): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py or scripts/repo_shape.py, and
  none is loaded but bound nowhere. scripts/shape_materialize.py binds
  `YamlError` and `parse_yaml` twice, as main dd8d0ea does (1f90a0f's
  import and 3ef059c's, the same objects); the merge adds none.
- Both sides intact (fin183g/sides_intact.py): the only lines that differ
  from either side's own change are those of the two hunks.
- A rehearsal of this merge (tree d21f8b4, the same as this one) passed
  tests/test_adopt_root_symlinks.py with main's
  test_adopt_{manifest_values,git_reads,relative_submodule_url}.py,
  test_scaffold_manifest_values.py, test_agents_md_codes.py and
  test_yaml_flow_brackets.py: `440 passed`.

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 13, 2026
… exists, a source symlink with an empty target and the source's own `project.yaml` kept in the root, and `plan` no longer offers `root` for that file (#303, #305) (#387)

Two ways `check` said `plan ok` to a source `execute` could not adopt, both reproduced on c7c02fa with the issues' own scripts.

**#303, a symlink whose target is empty.** A `120000` entry whose blob is the empty blob passed `check`, which never reads a link's target.
- With symlinks on (git's default where the disk can hold one), `execute` created both leg repositories, then failed in its first clone of the source: exit 2, nothing on stderr, two empty legs left behind. Linux refuses a symlink to nothing. This happened whatever the plan answered: `docs/empty-link` sent to spec, and a top-level `empty-link` answered `root` or `drop`.
- With `core.symlinks=false`, it said `adoption verified`. The spec leg held the empty link, a clone of that leg exited 128, and the assembly's recursive clone exited 1. The top-level link answered `root` or `code` verified the same way. Only `drop` gave an assembly that clones.

**#305, the source's own `project.yaml` answered `root`.** `plan` asked about it under `ambiguous-top-level-yaml`, whose question offers `root`. Answered `root`, `check` said `plan ok`. `execute` wrote the shape's manifest beside the source's file as `shape/project.yaml`, said "nothing was overwritten" and `adoption verified`. `shape-doctor.py` then said NOT A SHAPE ROOT (exit 2) and pointed at `adopt-project.py plan`. `make bootstrap` failed `naming (exit 2), manifest (exit 1), pins (exit 2)`. Answered `code` or `spec`, the doctor said COMPLIANT and `bootstrap ok`.

**What changed.**

- **`plan-empty-symlink` (#303).** `_empty_symlink_findings` reports each `120000` row of size 0 in `Source.tree()`. The size `ls-tree -l` gives a symlink is the length of its target, so no target is read; a SHA-256 repository reports size 0 too.
  - It is listed right after #196's `plan-unsafe-path`, through `_source_path_findings`, on the line `_entry_findings` already had. So `check` prints it, and `execute` refuses it through `_refuse_what_check_finds` before any leg exists (#168).
  - `EMPTY_SYMLINK_REMEDIATION` is said once after the findings, in `check` and in `execute`'s refusal. The fix is in the source: give the link a target, or remove it, commit, and re-run `plan`.
  - `plan` notes each such link in `check`'s words, and still writes the plan.
- **`ambiguous-project-yaml` (#305).** A new rule in `contracts/path-classification.yaml`, above `ambiguous-top-level-yaml`, for the file `project.yaml`. Its question asks spec, code or drop, and says `root` is not an answer for this file. `release.yaml` keeps the old question.
- **`plan-root-blocks-manifest` (#305).** #313's machinery is extended, not copied: `_shape_blocks`, which `_root_shape_findings` runs over each disk, now refuses the root holding `project.yaml` outright, with no copy question after it. It fires for a kept row at that path (file or symlink), or a kept directory there, on a disk that keeps case or one that ignores it. The finding's remediation is #313's `_fix_the_kept_path`.
  - A submodule there stays `plan-submodule-root`'s, said once.
  - `_kept_there` is the at-or-under lookup the copy branch already did, factored out for both.
  - `execute` refuses it before any leg exists, through the same list.
- **Files.** `adopt-project.py` (+205/-13), `contracts/path-classification.yaml` (+28), the two new test files (399 and 366 lines) and `tests/test_adopt_root_shape_paths.py` (+6/-2).

**Decisions.**

- **#303 is refused whatever the plan answers, and whatever `core.symlinks` says.**
  - Where symlinks are made, no answer adopts. The run fails at the first clone of the source, which checks the whole tree out before anything is taken out. Measured with the link in `docs/` (sent to spec), at the top level answered `root` and `drop`, and with every path rooted or dropped and both legs seeded: there the seeded legs were pushed and the assembly's clone exited 128.
  - `check` cannot know where `execute` will run. A check that passed `drop` with symlinks off would pass a plan the next Linux machine cannot adopt, and the root's own `validate` workflow runs on `ubuntu-latest`.
  - The remediation costs one commit in the source.
- **#305 covers the manifest only.** The issue asked whether other rendered root paths need the same rule. Measured on c7c02fa, with the source's own file kept in the root at each of the 18 `assembly_root_paths()`:
  - `check` passes all 18.
  - These verify over a broken assembly: `contracts/spec-pin.yaml` and `contracts/code-pin.yaml` (DRIFTED, pins fail); `scripts/validate-pins.py`, `scripts/validate-manifest.py`, `scripts/repo_shape.py` and `contracts/repository-naming.yaml` (INVALID); `scripts/validate-repository-naming.py` (COMPLIANT, naming fails); `scripts/bootstrap.py` (COMPLIANT, and `make bootstrap` runs the source's own script).
  - `contracts/shape-pin.yaml` fails verification after both legs exist.
  - README.md, AGENTS.md, CLAUDE.md, .gitignore, .gitattributes, AGENTS-shape.md and the workflow come out COMPLIANT with the copy beside them. `Makefile` is rooted by default, and the follow-up merge is its designed exit.
  - `project.yaml` is the one path `plan`'s own question offered `root` for. The others are reached only by hand-editing a `contracts/` or `scripts/` answer to `root`. They are left for a follow-up issue (under Left out), not folded in here.
- **A symlink at `project.yaml` is refused too.** On c7c02fa:
  - a link to the source's own file verified and was NOT A SHAPE ROOT;
  - a link to nothing had the manifest written through it, and came out COMPLIANT on Linux. A clone that makes no symlinks holds that link as a file naming its target, so it is refused anyway, and #183 refuses a link to nothing at a shape path regardless;
  - a directory `project.yaml/` verified and was NOT A SHAPE ROOT.
- **A case twin `Project.yaml` kept in the root is refused, with the case aside.** It adopts COMPLIANT on Linux. On a disk that ignores case, `place` writes the manifest beside it. This is the trade #172 and #313 already make.

**Evidence.** In this section and in the two below it, "this head" is 69c54c9, the branch's own commit and the one the review approved; main and #314 were merged into the branch afterwards (last paragraphs).

- **Reproductions, on this head** (exits as printed):
  - #303 run 1: `check exit 1`, one `plan-empty-symlink` finding; `execute exit 2`, `REFUSED plan-empty-symlink`, `leg repositories left:` empty.
  - #303 run 2 (`core.symlinks=false`): the same finding and refusal; no leg, no verification.
  - Top-level `empty-link`, symlinks off and answered root, code or drop, and symlinks on and answered root or drop: each `check exit 1`, `execute exit 2`, no leg.
  - #305 root: `check exit 1`, one `plan-root-blocks-manifest` finding; `execute exit 2`, `leg repositories:` empty.
  - #305 code, spec and drop: `plan ok`, `adoption verified`, the root's `project.yaml` begins `schema_version: 1`, doctor COMPLIANT, `bootstrap ok`.
  - The plan's entry for `project.yaml` now reads `rule: ambiguous-project-yaml`, with no `(root)` in its question.
  - `project.yaml` as a link to a file, a link to nothing, a directory, and the twin `Project.yaml`: each `check exit 1` and `REFUSED plan-root-blocks-manifest`.
  - No row needs real GitHub or a cloud tool.
- **New tests.** `tests/test_adopt_empty_symlinks.py` (28) and `tests/test_adopt_root_manifest.py` (27).
  - The symlinks are made with `git update-index --cacheinfo`, so no disk symlink is needed. The one test that checks a link out (a link given a target, end to end) is skipped on Windows by its marker, before it runs.
  - On c7c02fa: 37 failed, 18 passed. The 18 are the controls that must pass on both sides: a symlink with a target, the remediations adopting, `project.yaml` sent to a leg or dropped, a submodule there, other shape paths keeping their copy, and `release.yaml`'s question.
- **One existing test changed.** `test_a_file_kept_at_a_path_the_shape_writes_is_not_a_finding` in `tests/test_adopt_root_shape_paths.py` held that a kept file at any shape path is no finding. It now holds that for every path but the manifest, which is `plan-root-blocks-manifest`.
- **Mutation probes:** 14 of 14 hunks reverted one at a time, each killed by at least one new test.
- **Targeted runs.** The adopt list and the doctor ran on a local state of this branch (7a22a21) whose files were this head's but for `tests/test_adopt_root_shape_paths.py`. That file was re-run on this head, so the adopt list is two runs, not one sum.
  - The 15 adopt files (`test_adopt_empty_symlinks`, `_root_manifest`, `_root_shape_paths`, `_leg_path_collisions`, `_execute_refusals`, `_plan`, `_e2e`, `_submodules`, `_verify`, `_mount_ignored`, `_ignored_shape_files`, `_entry_paths`, `_finding_spelling`, `_history_gitlinks`, `_spec_only`) on 7a22a21: 837 passed, 1 failed, 1 skipped (a MedxEHR clone the plan test needs). The one failure is the #313 test this head changes.
  - `tests/test_adopt_root_shape_paths.py` alone, on this head: 120 passed.
  - `tests/test_shape_doctor.py`, as its own stream: 242 passed, 3 skipped (PowerShell lines only a Windows host prints).
  - `tests/test_scaffold_leg_path_collisions.py`, `tests/test_repo_hygiene.py` and `tests/test_cli_reference.py`: 167 passed.
  - This head merged with origin/main at 87373a6, which brings `tests/conftest.py`'s new `git()` identity: the two new files, `tests/test_adopt_root_shape_paths.py` and the three above, 342 passed.

**The review.** An independent opus review at 69c54c9 approved with nits: 0 blocker, 0 should, 8 nit. It reproduced every row of both issues on c7c02fa and on this head, and killed 6 of 6 mutants: two of the author's, re-run, and four of its own. Its targeted runs on this head merged with origin/main ab623dd (`git merge-tree` clean, with #349's session `gh` guard in place):
- the new files, `tests/test_adopt_root_shape_paths.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_adopt_execute_refusals.py`, `tests/test_repo_hygiene.py` and `tests/test_cli_reference.py`: 379 passed;
- the other adopt files, in two streams: 303 passed, 1 skipped (the MedxEHR clone), and 323 passed;
- the clone-template, literal-names, template-values, plan-escapes, stdout-encoding and Windows-path tests, which cover #268 and #270: 266 passed, 2 skipped (an operator `user.name` set; Windows only);
- `tests/test_shape_doctor.py`: 254 passed, 3 skipped (PowerShell lines only a Windows host prints).

c7c02fa's own `tests/test_adopt_root_shape_paths.py` against this head's code gave 1 failed (the `project.yaml` row, `:191`) and 119 passed, so no other #313 behaviour changed.

- **A cross-lane composition, with lane 1's #183.** On the tree merged with lane 1's `fix/adopt-root-kept-symlinks` (#183, 6c44591), `tests/test_adopt_root_symlinks.py::test_a_link_with_an_empty_target_is_fixed_in_the_source` fails: `AssertionError: assert 'plan-empty-symlink' == 'plan-root-symlink'`. An empty link at or above a shape path is reported by both findings, and `execute` refuses with the first, which is this branch's. The two remediations agree (fix it in the source). Whichever of the two lands second removes the duplicate report. The same file also fails 3 other tests on origin/main with #183 and without this branch (`[a-link-at-the-copy-itself]` and `test_the_materializer_refuses_before_it_writes`, two cases), because #313's `plan-root-blocks-shape-copy` and `materialize-copy-blocked` answer first; those are not this branch's.
- **Two known nits, left for a later round.**
  - An empty link AT `project.yaml` (or at a #313 copy path) gets `plan-root-blocks-manifest`, whose fix offers `drop`, and `plan-empty-symlink`, which says `drop` is no fix.
  - `plan` notes every empty link (25 links print 25 NOTEs), where `check` names 20 and counts the rest.
- The other nits are smaller. A symlink target that begins with a NUL byte passes `check` and fails like #303, and the issue's `_verify` bullet, are follow-up issues; the case twin `Project.yaml` is under Left out. The new tests' `SYMLINKS_OFF` replaces the Windows job's own `GIT_CONFIG_COUNT` settings (`core.autocrlf=false`, `core.longpaths=true`) for those child processes, which is harmless while none of them checks anything out.

**Merge-tree, as of 2026-10-12T15:14Z.** `git merge-tree --write-tree --name-only` of this head (69c54c9), against heads fetched then; every sha below is the one it had then, and main has moved since (see the merge paragraph below). It is a textual merge only; the semantic meeting with #183 was tested by the review (the first item above).
- origin/main: clean at 0f67f7b. That includes #316 (34ecbc2), #268 (e43fa95), #270 (f795cf7) and #245 (161c6c9), which have landed since this branch was cut. It was clean at 161c6c9 and at ab623dd, the review's merge, too.
- Open PRs touching `adopt-project.py`: #184 (f416e13), #353 (dcbc37b), #356 (5a5011d), #371 (34b9500), #373 (5ac03de), #377 (c35c896), #378 (2da8691), #382 (7e55d00) and #384 (a96297e) are all clean. #356 and #382 conflict with origin/main alone (2 and 5 blocks, from #245's landing), not with this head.
- #314 (4b8da3d) was NOT clean: one block, in `_entry_findings`' docstring, where each side adds a paragraph after the same one (this head's "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314's "A VALUE NO FILE CAN HOLD"). Keep both. It had been clean at f45edf4, and it had 3 blocks against origin/main alone since #245 landed, so it needed a merge of main in any case.
- Lane 1's branches without a PR: `fix/adopt-late-failure-notice` (9f1778d), `fix/adopt-record-submodule-placement` (70f4d29) and `fix/adopt-plan-quote-yaml-keywords` (d38e143) were clean. `fix/adopt-plan-quote-yaml-keywords` was 3 blocks against this head at e337f44, and conflicts with origin/main alone in 4.
  - `fix/adopt-root-kept-symlinks` (6c44591, #183) conflicts in 3 blocks of `adopt-project.py`: the module docstring's `check` paragraph, `_entry_findings`' docstring and its return line. They are the same 3 that c7c02fa itself has with that branch, and this branch's one added docstring line sits inside the first. It has 5 blocks against origin/main alone.
- Lane 5's `fix/adopt-pin-writes-pin-file` (d806e8f): clean. `docs/adopt-execute-exits` is #371, above.
- When #183 lands, an empty symlink kept at or above a shape path is reported twice, by `plan-empty-symlink` and by #183's `plan-root-symlink` (the first review item above). A dangling link kept at `project.yaml` is reported twice too, by `plan-root-blocks-manifest` and `plan-root-symlink`, and an empty one three times. Whichever lands second should keep one.

**Brought onto main past #314 (bbbb541).** Since this branch was cut from c7c02fa, main took #245 (relative `.gitmodules` urls), #270 (`plan-unwritable-value`), #371 (`execute` verifies before it exits on a refused `gh pr create`), #377 (`plan` asks about every path `execute` will not extract) and #314 (`plan-manifest-invalid`, 3ef059c), all in `adopt-project.py`. The merge of origin/main 3ef059c has ONE conflict, in `_entry_findings`' docstring, where this branch adds "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314 adds "A VALUE NO FILE CAN HOLD IS SAID BEFORE THE VALIDATOR'S WORD ON IT (#255 before #261)" after the same paragraph: both are kept, this branch's first, because it describes the head of the list and #314's its tail. Nothing in code conflicted. `check` and `execute` read one list, and it composes as each feature says: a source's unsafe paths (#196), its empty symlinks (#303), coverage, `_leg_findings` with `plan-root-blocks-manifest` (#305), #166's split, #270's `plan-unwritable-value`, #314's `plan-manifest-invalid`, then #245's relative urls last. So an empty symlink beside an invalid value is still refused wearing `plan-empty-symlink`, with no leg created; probed on the merged tree with an empty link plus `visibility: secret`, plus a line break in `visibility`, and with `project.yaml` answered `root` plus `visibility: secret`, each listing the findings in that order. No test expectation or order changed: the merged tree was rehearsed before #314 landed (69c54c9 + 45e01f4 + c6f441b) and is the same tree object as the pushed merge, and `git diff 3ef059c bbbb541` is this PR's five files and nothing else (+1004/-15). Targeted runs on bbbb541: the new files, `test_adopt_root_shape_paths`, `test_adopt_manifest_values`, `test_scaffold_leg_path_collisions`, `test_agents_md_codes` and `test_cli_reference` 418 passed; `test_shape_doctor` and #377's, #245's and #270's adopt tests 595 passed, 4 skipped (PowerShell lines only a Windows host prints; one operator `user.name`); the other adopt files 234 passed, 1 skipped (the MedxEHR clone) and the guards, literal-names, clone-template, plan-escapes, gitmodules, leg-paths, stdout and Windows-path tests 1101 passed, 1 skipped.

**Left out.**

- `_verify` and the materializer do not themselves refuse a root `project.yaml` that is not the shape's manifest. This is the issue's second bullet. Once `execute` refuses the plan before any leg, through the same list `check` prints, nothing reaches them with one. It is a follow-up issue.
- A top-level file named `project.yaml` only once case is ignored (`Project.yaml`) is still asked `ambiguous-top-level-yaml`'s question, which offers `root`. `check` refuses that answer with the case aside, before any leg.
- #171 (a failed clone exits 2 silently) is lane 1's PR #184. This PR only keeps the empty-symlink run from reaching that clone.
- Follow-up issues proposed by this PR (two since filed):
  - the source's own file kept in the root at one of the other shape paths the root's tools read (the pins, the validators, `scripts/repo_shape.py`, the naming policy, `scripts/bootstrap.py`, `contracts/shape-pin.yaml`), each measured above;
  - a symlink whose target begins with a NUL byte (`\0spec.md`, size 8), which `plan-empty-symlink`'s size test does not see: it fails like #303 (filed as #388);
  - `_verify` asking whether the split's root `project.yaml` is the shape's manifest (filed as #389);
  - an entry git cannot check out where `execute` runs, other than an empty symlink or a NUL-led one (a target longer than the platform allows, say), which is still found only by the first clone, after both legs exist. #303 leaves this as a note.

Reviewed adversarially at 69c54c9 (APPROVE WITH NITS: 0 blockers, 0 should-fixes, 8 nits). The review reproduced every row of both issues on c7c02fa and on that head, and killed 6 of 6 mutants. No fix round followed: the branch's own commit is as reviewed, and what was added after it is the merge described above. CI is green on Linux, macOS and Windows, with SonarCloud passing, at fce86fa (main 7a8b981 merged in). Its merge with main a73b4e9 passed the targeted set (the three adopt test files, `tests/test_shape_doctor.py` and `tests/test_scaffold_leg_path_collisions.py`): 597 passed, 3 skipped.

Closes #303
Closes #305

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