Skip to content

family.py init: the creator is asked whether a file can hold it whichever of --created-by, GIT_AUTHOR_NAME or git config user.name gave it, git is asked about the first commit's author and committer before the remote exists, and a --family that is a Windows device name is refused, --dry-run alike (#688) - #809

Merged
brettheap merged 5 commits into
mainfrom
fix/688-family-init-values
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #688 (comment)

What changed and why

#688's three lines of #459, each measured on main 4ce90457 before the change, and again on bf1f099b (init --dry-run --local-remote-dir: the newline name, a name holding U+202E, GIT_AUTHOR_NAME=' ', --family CON and --family Lpt9 each still exit 0 and print the plan). Every refusal below is asked before any remote or folder exists, and --dry-run refuses alike. Only scripts/family.py changes, in _creator_name, _checked_family_identity, one call in cmd_init and new code beside them.

1. The creator, from every source (R423 (a), L4-114). #426 (#641) asked unwritable_reason of the creator only when git config user.name supplied it. Measured on main: init --created-by $'Ada\nLovelace' --dry-run exited 0 and the plan printed created by Ada and Lovelace on 2026-10-16 on two lines; --created-by holding U+202E, an ESC, a tab or Jos<e9> exited 0; so did GIT_AUTHOR_NAME holding each of them. _creator_name now asks once, of whichever source answered:

REFUSED family-unwritable-value: --created-by is 'Ada\nLovelace', which `init` records in the holder's `family.yaml`, and it holds a line break, and it is written as one line.
Remediation: nothing was created, and `--dry-run` refuses the same. Pass --created-by again, retyped. A byte that is not UTF-8 reaches a command line from a terminal or a script whose encoding is not the locale's, which the manifest could keep only as a lone surrogate, and that is no character; …

The remediation names the source that answered (CREATOR_UNWRITABLE_REMEDIATIONS), as the issue asks. For GIT_AUTHOR_NAME it reads … `init` read the creator from GIT_AUTHOR_NAME, because no --created-by was given: correct that variable, or unset it, for git makes the holder's first commit as it too; --created-by 'Your Name' changes only who `family.yaml` records. The environment keeps it as the bytes it was given, …. It does not say to pass --created-by: with the flag the creator is fine and git is asked about the same variable, so that run was family-bad-commit-identity for it (lane 1's review). For git config user.name it is #426's text, byte for byte. The detail still names the creator as _manifest_flags (#307) does, so this refusal and family-manifest-invalid agree on who is meant: --created-by, or --created-by (not given, so GIT_AUTHOR_NAME or `git config user.name`).

A GIT_AUTHOR_NAME of whitespace alone, with no --created-by and no git config user.name, was family-no-creator saying "no GIT_AUTHOR_NAME" of a variable that was set, and told the person to pass --created-by, which led to the same family-bad-commit-identity. It now says the variable is the cause, and to unset it and pass the flag:

REFUSED family-no-creator: no --created-by, a GIT_AUTHOR_NAME of whitespace alone, and no `git config user.name`. Creating a family is a human's act and the manifest records whose.
Remediation: unset GIT_AUTHOR_NAME, which git would make the holder's first commit as and refuses, and re-run with --created-by 'Your Name'.

The first exit of each of these two remedies is run in the tests, dry and real, and goes on to a holder.

2. The commit's identity (R423 (b), L4-115). _creator_name passes over a GIT_AUTHOR_NAME of whitespace alone and reads git config user.name, but env_commit replaces a variable only when it is unset or empty. Measured on main: GIT_AUTHOR_NAME=' ' with a good user.name, a real init exited 2 with fatal: name consists only of disallowed characters after <remotes>/Orbit.git and the work directory existed. cmd_init now asks git, after the clone URL and manifest questions and before the landing and the plan. It runs git var GIT_AUTHOR_IDENT and git var GIT_COMMITTER_IDENT, from an empty directory and no repository as _ask_git_branch asks, for each role the person set a variable of, with stand-ins where env_commit puts its fallback:

REFUSED family-bad-commit-identity: git refuses the author the holder's first commit would be made as, from GIT_AUTHOR_NAME '   ' (`git var GIT_AUTHOR_IDENT`: fatal: name consists only of disallowed characters:).
Remediation: nothing was created, and `--dry-run` refuses the same. Correct each variable named, or unset it, and run again. `init` makes the holder's first commit with them only after the holder's remote and work directory exist, and git would refuse them there. Unset or empty, a name or an address is `init`'s own and a date is the moment of the commit; --created-by sets who `family.yaml` records, not who git makes the commit as.

Git echoes a value it refuses (fatal: invalid date format: <the date>) as it was given, so the refusal quotes git's words with shown_path, as a path is quoted (#610): as they are when printable, as repr spells them when not. Raw, a GIT_AUTHOR_DATE holding a line break began a second line of the refusal of its own choosing, and U+202E reached the terminal. For a date of x and a line break and REFUSED family-fake: not real, the refusal is one line, ending:

… from GIT_AUTHOR_NAME 'Ada', GIT_AUTHOR_EMAIL 'ada@example.invalid' and GIT_AUTHOR_DATE 'x\nREFUSED family-fake: not real' (`git var GIT_AUTHOR_IDENT`: 'fatal: invalid date format: x\nREFUSED family-fake: not real').

The issue gave two directions: ask git, or name a blank variable. I chose to ask git because it is the only one that reaches the issue's goal ("every identity … it cannot write down"). A git commit in a scratch repository also fails on a name of only <> and on a GIT_AUTHOR_DATE or GIT_COMMITTER_DATE it cannot read (fatal: invalid date format). git var gave the same exit as git commit in every case measured. A blank check would leave those two failing after the remote exists. It is also the house pattern of refuse_bad_branch (#283): git's own answer, not a copy of its rules.

3. The holder's name (#459 comment 6192725913, the Windows family-name line). --family must be one CamelCase token (^[A-Za-z][A-Za-z0-9]*$). CON, Con, nul, PRN, Aux, COM1 and Lpt9 are each such a token, and on main each exited 0, real runs included. No Windows disk can make a folder of that name. _checked_family_identity now asks the leg path rule (shape_materialize._unmountable_reason) of the name, after the policy, as the issue directs:

REFUSED family-bad-holder-name: --family is 'CON', which names the holder and the folders `init` makes for it, and `CON` is a Windows device name (with or without an extension), which cannot be created there.

Where the issue and main differ. The issue says a trailing . or space is accepted. On main both were already refused before anything existed: Orbit. as family-bad-id (the derived id orbit.), or naming-unclassified with --id orbit, because the policy's token holds no dot; and 'Orbit ' as unsafe-value. They stay so. Asking the rule after the policy keeps those refusals unchanged, and leaves the device name as the only answer the rule can give here. Their rows pin it.

Tests

tests/test_family_init_asks_every_value.py (new, 78 tests, on the harness of tests/test_non_utf8_git_reads.py, #426). It asserts the exit code, the refusal code and its full words, the remediation in full, an empty stdout and nothing made (no remotes, work directory or family folder), for the dry run and the real run:

  • the creator: --created-by and GIT_AUTHOR_NAME with a line break, U+202E, an ESC and a tab, and Jos<e9> from each (POSIX only, skipped on Windows); the git config user.name row's remediation whole; the first exit of the variable's remedy (correct it, or unset it) and of the blank-variable refusal's (unset it and pass the flag), each run to a holder; controls José (flag) and Mehr<U+200C>dad (variable) initialise and are recorded;
  • the identity: the issue's row (both names ' ', a good user.name, under --work-dir and --into), each role alone, a name of only <>, a bad GIT_COMMITTER_DATE, and --created-by not getting past a blank GIT_AUTHOR_NAME; controls: every name and address empty still initialises, and an empty name or address beside a valid date in the same role (a role git IS asked about) still does, one row per role and variable; git's words for a date holding a line break and for one holding U+202E are pinned spelled, never raw. Git's words are fixed text under LC_ALL=C (PYTHONUTF8=1);
  • the holder's name: the seven device names (dry run), CON real under both landings, the trailing dot and space rows, and controls COM0, LPT10, CONX, Conan and Nullify.

ONE targeted run on the merged head 22b7903a (main bf1f099b merged in; scripts/family.py sha256 ae4dfea6…, the new file 4d5d46b3…), 33 files: the new file, the 29 existing files that run family.py init (27, and tests/test_derived_id_refusals.py and tests/test_free_plan_hint_reads.py, which main gained since), and tests/test_repo_hygiene.py, tests/test_cli_reference.py and tests/test_agents_md_codes.py: 2533 passed, 23 skipped, 1 xfailed in 24 min, exit 0. The 23 skips are 16 and 3 in tests/test_family_init_values.py (rows that do not apply to the template in use, which quotes the placeholder or id:), 3 in tests/test_shape_doctor.py (the PowerShell line, Windows only) and 1 in tests/test_stdout_encoding.py (Windows' own default); the xfail is tests/test_shape_doctor.py's strict one for #735. CI's full Linux suite is the rest.

Fix round 1, 0748fc46 (SonarCloud: S6389 on a raw U+202E in the test source, and S1192 on the identity stand-ins): the test spells U+202E and U+200C as escapes, the stand-ins are spelled once, and the new file alone ran again, 57 passed.

Fix round 2, 6958dfbc (lane 1's review): ONE run of the new file, tests/test_family_init_values.py, tests/test_family.py, tests/test_clone_url_bytes.py, tests/test_non_utf8_git_reads.py, tests/test_repo_hygiene.py, tests/test_cli_reference.py and tests/test_agents_md_codes.py: 513 passed, 19 skipped, exit 0, in 4 min, the 19 skips being tests/test_family_init_values.py's template rows above (scripts/family.py sha256 bf599aa3…, the new file 69e214c1…).

Mutants, each against the new file, all run on the bytes of 6958dfbc:

mutant result
M1: the creator asked only when git config user.name answered (main's rule) killed, 18 failed
M2: the setting's remediation for every source killed, 18 failed
M3: a blank-variable check in place of git var killed, 9 failed
M4: cmd_init does not ask for the commit's identity at all killed, 18 failed
M5: no holder-name question killed, 9 failed
F1: the variable's remedy back to "pass --created-by 'Your Name', or correct that variable" killed, 9 failed
F2: no blank-variable refusal (family-no-creator as it was) killed, 2 failed
F3: git's words quoted raw, not by shown_path killed, 4 failed
F4: a stand-in only where a variable is unset, not where it is empty (lane 1's R5) killed, 8 failed

What it cannot tell

  • Windows is reasoned, not run: this host cannot make a CON folder. The rule is the one the leg path already rides on (tests/test_adopt_leg_paths.py).
  • The identity question gets git var's verdict, and git var agreed with git commit in every case measured (git 2.43). It is not git commit itself.

Shared files

scripts/family.py only. scripts/shape_materialize.py is not touched: _unmountable_reason is imported, as adopt-project.py imports _refuse_bad_leg_paths and update-shape.py _ask_git_branch. Every hunk is in the import block (two imports after unwritable_reason), after _classify_family_name, one line in _checked_family_identity, one call in cmd_init, and _creator_name with the constants above it. Merging main (bf1f099b) conflicted once, in the import block, where #660 added its own from shape_materialize import (free_plan_secrets_note, repository_visibility) at the same place; the two imports are independent and both stay.

git merge-tree --write-tree of this head 6958dfbc, each head read with ls-remote just before (2026-10-17):

Closes #688

🤖 Generated with Claude Code

Summary by Sourcery

Refuse unrecordable creators, invalid first-commit identities, and Windows-incompatible family names before family initialization creates anything.

New Features:

  • Validate creator values from all supported sources before initialization and provide source-specific remediation.
  • Validate Git author and committer identities before creating remotes or work directories, including dry runs.
  • Reject Windows device names used as family names.

Bug Fixes:

  • Prevent unsafe creator values, invalid Git identities, and unholdable Windows family names from being accepted or created after partial setup.
  • Ensure rejected values are safely quoted in refusal messages.

Enhancements:

  • Align holder-name validation with the existing leg-path portability rules.
  • Add comprehensive coverage for creator sources, Git identity failures, Windows names, dry runs, and no-side-effect guarantees.

Tests:

  • Add focused tests covering 78 refusal, remediation, control, and initialization scenarios for family initialization.

Brett Heap and others added 2 commits October 16, 2026 14:26
…t: the creator is asked whether a file can hold it whichever of `--created-by`, GIT_AUTHOR_NAME or `git config user.name` gave it, git is asked about the first commit's author and committer before the remote exists, and a `--family` that is a Windows device name is refused, `--dry-run` alike (#688)

#426 (#641) asked `unwritable_reason` of the creator only when `git config
user.name` supplied it, so `--created-by $'Ada\nLovelace' --dry-run` exited 0
with the plan's `created by` line broken in two, and a `--created-by` or a
GIT_AUTHOR_NAME holding U+202E, an ESC, a tab or a byte that is not UTF-8
exited 0 too. `_creator_name` now asks it once, of whichever source answered,
and `family-unwritable-value`'s remediation names that source
(`CREATOR_UNWRITABLE_REMEDIATIONS`): retype the flag, correct the variable,
or correct the setting, whose remediation is #426's, byte for byte. The
detail still names the creator as `_manifest_flags` (#307) names it, so this
refusal and `family-manifest-invalid` agree on who is meant.

A GIT_AUTHOR_NAME of whitespace alone is passed over when the creator is
read, but `env_commit` replaces a variable only when it is unset or empty,
so `git commit` was handed the whitespace and refused it, exit 2, after
`<remotes>/<Family>.git` and the work directory existed. `cmd_init` now
asks git, after the clone URL and manifest questions and before the
landing and the plan, `git var GIT_AUTHOR_IDENT` and `git var
GIT_COMMITTER_IDENT`, from an empty directory and no repository as
`_ask_git_branch` asks, about each role the person set a variable of, with
stand-ins where `env_commit` puts its fallback; what git refuses is
`family-bad-commit-identity`, naming the role's variables and quoting git.
Of the issue's two directions this is the one that also catches what git
refuses that is not whitespace, a name of only `<>` and a date it cannot
read, both of which failed at the same commit.

`--family CON` (`Nul`, `com1`, any Windows device name) is one CamelCase
token, which is all the naming policy asks, and no Windows disk can make the
holder's folder. `_checked_family_identity` now asks the leg path rule
(`shape_materialize._unmountable_reason`) of the name, after the policy, and
refuses it as `family-bad-holder-name` in that rule's words. The rule's other
Windows answers, a trailing dot or space, were already refused before
anything existed (`family-bad-id` or `naming-unclassified`, and
`unsafe-value`) and still are.

Each question is asked before any remote or folder exists, and `--dry-run`
refuses alike. tests/test_family_init_asks_every_value.py holds every row,
on the harness of tests/test_non_utf8_git_reads.py.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in scripts/family.py's import block: #688 added `from
shape_materialize import _unmountable_reason` after `unwritable_reason`, and
main's #660 added `from shape_materialize import (free_plan_secrets_note,
repository_visibility)` at the same place. The two imports are independent,
so both stay, #688's first, each under its own comment. No function of
either side was touched; every other hunk merged without a conflict.

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

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR adds preflight validation to family initialization so unsafe creator values, identities Git would reject, and Windows-uncreatable family names are refused consistently before any remote or filesystem artifacts exist, including during dry runs, with extensive regression coverage.

Sequence diagram for family init preflight validation

sequenceDiagram
    participant User
    participant Init as family.py init
    participant Git
    participant PathRule as shape_materialize._unmountable_reason
    participant Filesystem

    User->>Init: cmd_init(args)
    Init->>Init: _creator_name(args)
    Init->>Git: git config user.name
    Init->>Init: unwritable_reason(created_by)
    alt creator is unwritable
        Init-->>User: Refusal family-unwritable-value
    end
    Init->>PathRule: _unmountable_reason(family)
    alt family names an uncreatable Windows device
        Init-->>User: Refusal family-bad-holder-name
    end
    Init->>Git: git var GIT_AUTHOR_IDENT
    Init->>Git: git var GIT_COMMITTER_IDENT
    alt Git rejects a configured identity
        Init-->>User: Refusal family-bad-commit-identity
    else all preflight checks pass
        Init->>Filesystem: Create remote and work directory
        Init-->>User: Print plan or complete initialization
    end
Loading

Flow diagram for dry-run and real-run preflight refusals

flowchart TD
    A[family.py init] --> B[_creator_name]
    B -->|unwritable creator| R1[Refuse family-unwritable-value]
    B --> C[_checked_family_identity]
    C -->|uncreatable family name| R2[Refuse family-bad-holder-name]
    C --> D[_refuse_unusable_commit_identity]
    D -->|git var rejects identity| R3[Refuse family-bad-commit-identity]
    D --> E{--dry-run}
    E -->|yes| P[Print initialization plan]
    E -->|no| F[Create remote and work directory]
    R1 --> G[Nothing created]
    R2 --> G
    R3 --> G
Loading

File-Level Changes

Change Details Files
Validate creator values from every supported source before initialization proceeds.
  • Apply unwritable-value checks to --created-by, GIT_AUTHOR_NAME, and git config user.name.
  • Select source-specific remediation text while preserving the existing refusal details.
scripts/family.py
Preflight Git’s author and committer identities before creating remotes or working directories.
  • Use git var GIT_AUTHOR_IDENT and GIT_COMMITTER_IDENT in an empty temporary context.
  • Substitute the same fallbacks used by env_commit and report Git’s native rejection details.
  • Run the check for dry runs and real runs after manifest validation.
scripts/family.py
Reject Windows device names supplied as the family identity.
  • Reuse _unmountable_reason after family naming-policy validation.
  • Raise family-bad-holder-name with remediation explaining affected holder paths and Windows restrictions.
scripts/family.py
Add comprehensive regression coverage for early refusals, source-specific diagnostics, and unchanged valid behavior.
  • Cover unsafe creator inputs, invalid Git identities, Windows device names, dry-run parity, landing modes, and no-artifact guarantees.
  • Verify valid Unicode names and near-device-name controls still initialize.
tests/test_family_init_asks_every_value.py

Assessment against linked issues

Issue Objective Addressed Explanation
#688 Refuse an unwritable creator name from any source—--created-by, GIT_AUTHOR_NAME, or git config user.name—before creating anything, including under --dry-run, and identify the source in the remediation. ✅
#688 Before creating anything, ask Git whether the author and committer identities the first commit will use are acceptable, so invalid environment-provided identity values are refused up front, including under --dry-run. ✅
#688 Refuse a --family name that Windows cannot represent as a folder, before creating anything and including under --dry-run. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

…urce spells U+202E and U+200C as escapes, and the identity git is asked with stands in once, so SonarCloud's S6389 and S1192 find nothing new in #688's lines (#688)

SonarCloud failed the quality gate on 22b7903: new_security_rating 3,
text:S6389 at tests/test_family_init_asks_every_value.py:120, "This line
contains a bidirectional character". The test row `Ada<U+202E>Lovelace` and
the control `Mehr<U+200C>dad` (two places) held the character itself. Each is
now the escape in the string literal, `‮` and `‌`, so the test builds
the same runtime string (`ast.dump` of the file is the same before and after)
and a reader of the source sees what the row feeds the tool. The file now
holds no raw character of U+200B-U+200F, U+202A-U+202E, U+2060-U+2069,
U+FEFF or U+061C.

`COMMIT_IDENTITY_STAND_IN` spelled `openRepoShape family` and
`family@openreposhape.invalid` twice each, beside the two copies
`commit_on_branch` already holds, so S1192 flagged both at four. The
stand-ins are now two constants spelled once, and are not that fallback:
`git var` is asked whether git takes the person's variable beside an
acceptable name and address, which does not depend on which one stands in.
The one `" and "` this PR added, in `_refuse_an_unusable_commit_identity`'s
list of variables, is an f-string, as the other eleven are not, so the
literal's count in the file is main's again. Behaviour is unchanged.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T11:15:31Z)

Lane 1's independent review of #809 at 0748fc4: CHANGES NEEDED

All three of #688's lines are fixed, and I measured each one. Nothing is made and gh is never called before any of the three refusals. git var gave the same answer as git commit in every identity case I tried, and ordinary output is byte-identical to main. Two small defects remain in the words of the new refusals, along with one test gap and one stale paragraph in the body. Each takes a line or two to fix.

How I measured. The head 0748fc46 already contains main bf1f099b (the merge base is main), so the local merge is the head itself. I compared it with main in separate checkouts. Every probe used:

  • a stub gh first on PATH that logs each call and exits 97;
  • GH_TOKEN=bogus, an empty GH_CONFIG_DIR and a probe-local global git config;
  • --local-remote-dir and --work-dir under a case directory, with the case tree listed before and after each run.

The delta 22b7903a..0748fc46

  • The stand-in identity cannot reach a commit. It exists only in the identity dict passed to run([... "var", ident], env=identity). run builds {**os.environ, **env} for that one process. env_commit builds its own environment from os.environ with its own fallbacks.
    • A real run with only GIT_AUTHOR_NAME=Ada and GIT_COMMITTER_EMAIL set made the holder's commit as author Ada <scaffold fallback address> and committer openRepoShape scaffold <the given address>, on the work tree and on the remote.
    • "identity check" appears in no commit header. The only hits in the holder are two existing template docstrings (siblings.py:703, validate-family.py:617), which main has too.
  • The git var answer cannot differ because of the stand-in. Git judges the name, the address and the date separately. In 19 identity cases, a stand-in name never changed the verdict on a date or an address, and the reverse held too. env_commit always sets both a name and an address, so user.useConfigOnly, an empty user.name and EMAIL never take part. All of these were measured below.
  • Nothing else changed. All 33 of my probe outputs (19 identity, 14 creator) are byte-identical between 22b7903a and 0748fc46. The test file's AST is unchanged, and no raw Cf character is left in its source.

The issue's lines

  1. R423 (a), the creator from every source: fixed. Dry runs; main exits 0 with the plan, and the head exits 2 with family-unwritable-value, an empty stdout, the value shown with !r, and nothing made. Every source got its own remedy.
    • --created-by holding LF, CR, U+2028, U+0085, DEL, U+009B, U+2066, Jos<e9>.
    • GIT_AUTHOR_NAME holding U+202E, ESC, <e9>, the bytes ED A0 80, LF after a name.
    • U+FEFF is accepted on both, as unwritable_reason intends.
  2. R423 (b), a blank GIT_AUTHOR_NAME: fixed for the issue's row, partly fixed without user.name (finding 2).
    • Main: git commit exit 128 with fatal: name consists only of disallowed characters:, after 119 paths existed under the case directory (remotes/Orbit.git, work/Orbit).
    • Head: family-bad-commit-identity, 0 paths.
    • The same holds for author only, committer only, a tab name, a name of only \x1b\x07, and for GIT_*_DATE set to now, @0, 99999999999 +0000 or ESC bytes. Main fails all of these at the commit and the head refuses them first.
    • Agreements that need no refusal: useConfigOnly=true with no identity, an empty user.name, EMAIL alone, the author name set but not the committer's, and an address of ' ', '<>', a\nb or a non-UTF-8 byte. Each exits 0 on both.
  3. The Windows family name: fixed.
    • CON, con, Con, Nul, COM1, LPT9 and Lpt9 exit 0 on main and are family-bad-holder-name on the head.
    • CONSOLE, COM10 and COM0 pass on both.
    • NUL and CONIN$ are unsafe-value on both, and AUX. and CON.txt are family-bad-id on both. The body is right that main already refused the dot and space names; the issue's premise was wrong there.
    • COM¹ and LPT³ are unsafe-value on both, because SAFE_ARG_RE is ASCII.
    • The check is the shared shape_materialize._unmountable_reason, imported, not a second copy.

The GitHub path (no --local-remote-dir), real runs. The head refuses the blank author, --family CON and a tab in GIT_AUTHOR_NAME with zero gh calls and nothing made. The control run reaches gh repo view Acme/Orbit --json name. Main reaches gh repo view in all four.

Findings, ranked

1. The new identity refusal quotes git's words raw, and git echoes the bad value. Should fix.

The detail escapes each variable ({os.environ[key]!r}) and then pastes exc.output raw. Git only turns C0 controls other than tab and LF into ?. A line break, U+202E or a C1 control in a GIT_*_DATE therefore reaches the terminal as written, and so does a line break in a name made only of disallowed characters.

GIT_AUTHOR_DATE=$'x\nREFUSED family-fake: not real' python3 scripts/family.py init --org Acme --family Orbit \
  --created-by 'T H' --created-on 2026-09-02 --local-remote-dir "$T/remotes" --work-dir "$T/work" --dry-run

At the head, stderr has 3 lines, and the second is a forged one:

REFUSED family-bad-commit-identity: ... from GIT_AUTHOR_DATE 'x\nREFUSED family-fake: not real' (`git var GIT_AUTHOR_IDENT`: fatal: invalid date format: x
REFUSED family-fake: not real).

With GIT_AUTHOR_DATE=$'2026\u202e-01-01', stderr holds one raw U+202E. Main printed the same raw words inside the git commit failure, after the remote existed. That is older behaviour, but this refusal is new text and should follow #610's rule (shown_path: printable as is, otherwise repr).

The fix, which I ran on the head with the new file at 57 passed (every expected word is printable, so no test changes):

        except CommandFailed as exc:
            # Git echoes a value it refuses (a date) as it was given.
            said = (exc.output if exc.output.isprintable()
                    else repr(exc.output))
            ...
                f"made as, from {listed} (`git var {ident}`: {said})")

After it, the same probe gives 2 lines: ... (git var GIT_AUTHOR_IDENT: 'fatal: invalid date format: x\nREFUSED family-fake: not real'). I suggest one row that pins it.

2. Two remedies whose first exit leads to the next refusal for the same cause. Should fix ("Offer only exits that work").

(a) A blank GIT_AUTHOR_NAME and no user.name. --dry-run gives:

REFUSED family-no-creator: no --created-by, no GIT_AUTHOR_NAME and no `git config user.name`. ...
Remediation: re-run with --created-by 'Your Name'.

Following that remedy (--created-by 'Ada') gives REFUSED family-bad-commit-identity: ... from GIT_AUTHOR_NAME ' ' .... The detail says there is no GIT_AUTHOR_NAME when there is a blank one, and the blank one is the cause.

(b) GIT_AUTHOR_NAME=$'\x1b\x07', no --created-by. This gives family-unwritable-value, whose remedy begins pass --created-by 'Your Name', or correct that variable. Passing --created-by 'Ada' then gives family-bad-commit-identity for the same variable.

Replacement text:

  • In CREATOR_UNWRITABLE_REMEDIATIONS["GIT_AUTHOR_NAME"], replace " `init` read the creator from GIT_AUTHOR_NAME, because no --created-by was given: pass --created-by 'Your Name', or correct that variable." with " `init` read the creator from GIT_AUTHOR_NAME, because no --created-by was given: correct that variable, or unset it, for git makes the holder's first commit as it too; --created-by 'Your Name' changes only who `family.yaml` records." (ENV_REMEDIATION in the test follows.)
  • In _creator_name, when GIT_AUTHOR_NAME is set but blank, use the detail "no --created-by, a GIT_AUTHOR_NAME of whitespace alone, and no `git config user.name`" and the remediation "Remediation: unset GIT_AUTHOR_NAME, which git would make the holder's first commit as and refuses, and re-run with --created-by 'Your Name'."
  • Or, instead, ask _refuse_an_unusable_commit_identity() before _creator_name. Each remedy then works in turn, but the identity process would come before the clone URL's question, which the docstring puts after it. That is your call.

3. Test gap: "empty is env_commit's fallback" is not pinned inside a role that is asked.

Mutant R5, identity = {key: os.environ.get(key, stand_in) ...} (a stand-in only when unset, not when empty), passes all 57 tests. It does make a wrong refusal:

GIT_AUTHOR_NAME= GIT_AUTHOR_DATE='2026-01-01T00:00:00 +0000' ... init ... --created-by 'T H'
  • Head: exit 0, holder made.
  • Main: exit 0, holder made.
  • R5: exit 2, ... from GIT_AUTHOR_DATE '2026-01-01T00:00:00 +0000' (git var GIT_AUTHOR_IDENT: fatal: empty ident name (for <...>) n...

The existing control empties every variable, so no role is asked at all. Add a control row with an empty name and a valid date in the same role, one per role, that initialises.

4. The body's merge-tree paragraph names the old head, and two of its sibling heads have moved.

Replace the paragraph that begins "git merge-tree --write-tree of this head 22b7903a" and its list with:

"git merge-tree --write-tree of this head 0748fc46, each head read with ls-remote just before (2026-10-17): main bf1f099b: clean, tree 0831e0db; #767 fix/685-spellings-remedies bbb961f8: clean, tree 247f4d1f; #731 fix/692-raw-folder-paths ab55c29f: clean, tree 8ce2d357; #670 fix/646-add-member-variant-remedy 6d7e4ff0: clean, tree 80971577; lane 4's branches that have no PR yet, all in scripts/family.py: fix/684-family-holder-states 70153862, clean, tree 25c84004; fix/686-bump-reachability 444934d9, clean, tree 53385043; fix/689-next-push-refspec fa513848, clean, tree c18cd55b; fix/693-local-remote-dir 831d76c6, clean, tree 0f89383a."

I measured all eight as clean. #670 and fix/693 moved since the body was written.

The rest of the body is true at 0748fc46:

  • The sha256 values check out: 653018ab… for family.py and f524e44d… for the test at the head; ae4dfea6… and 4d5d46b3… at 22b7903a.
  • There are 57 tests.
  • "git var gave the same exit as git commit in every case measured" matches my 19 cases.
  • "Every refusal below is asked before any remote or folder exists" matches the case trees and the stub's empty log.

Tests

revert result
R1: the creator asked only when user.name answered killed, 18 failed (the 8+1 flag rows and 8+1 variable rows)
R2: no holder-name call killed, 9 failed
R3: no identity call in cmd_init killed, 13 failed
R4: the committer role dropped from COMMIT_IDENTITY killed, 8 failed
R5: stand-in only when unset survived, 57 passed (finding 3)
  • The asserts are honest: whole-word refusal and remediation, an empty stdout, nothing_was_created, and the manifest read back on the real controls.
  • The new file has no pytest.raises and no composite assert … and.
  • By hand, _refuse_an_unusable_commit_identity has a cognitive complexity of about 9.
  • At my read, the rollup showed SonarCloud SUCCESS, tests-git-bash SUCCESS and package SUCCESS, with tests still running.

Runs

The runs were at 0748fc46, with git-filter-repo on PATH and GIT_ALLOW_PROTOCOL unset: 905 passed, 19 skipped, 0 failed over 13 files. The files were:

  • test_family_init_asks_every_value.py and test_non_utf8_git_reads.py: 104 passed;
  • test_clone_url_bytes.py and test_family_init_values.py: 136 passed, 19 skipped;
  • test_family_init_landing.py, test_family_refuse_up_front.py and test_family_dry_run.py: 147 passed;
  • test_agents_md_codes.py, test_cli_reference.py, test_repo_hygiene.py, test_derived_id_refusals.py and test_adopt_leg_paths.py: 518 passed.

Ordinary output is byte-identical to main. After masking the case path, the shape sha and the holder sha, stdout and stderr match main's in three cases:

  • user.name creator, dry run: 642 bytes;
  • the same, real run: 2243 bytes;
  • every identity variable set, real run: 2279 bytes. The two holders' work trees differ only in the clone path and the shape pin, and the commit's author, committer and dates match.

No probe left a temporary directory behind, and the change adds no network call.

Windows

This is reasoned; CI runs on Linux. git var is run the same way as _ask_git_branch, which already runs there. os.environ is case-insensitive there, which does not matter here. The tests that need non-UTF-8 bytes skip on Windows for the right reason. No text-mode byte counts are involved. The device rule matters only on Windows, and it is the rule the leg path already uses.

For the checklist (outside #688)

  • commit.gpgsign=true with a gpg that fails. --dry-run exits 0, and the real run fails at git commit (error: gpg failed to sign…) after 120 paths exist, at the head as on main. The commit can still fail after the remote exists for reasons other than identity.
  • A bad GIT_AUTHOR_NAME with --created-by. GIT_AUTHOR_NAME=$'Ada\x1b[31mX' with --created-by 'T H' exits 0, and the holder's first commit author is Ada^[[31mX. The manifest is clean, but the commit keeps the raw ESC.
  • WINDOWS_DEVICE_RE lacks the superscript device names. It has no COM¹–COM³, LPT¹–LPT³, CONIN$ or CONOUT$, and _unmountable_reason('COM¹') is None. These are unreachable today through SAFE_ARG_RE, for both --family and leg paths, but the shared rule should say so.
  • Optional trim. The clause "and a name ending in a dot or a space is made under another name" in HOLDER_NAME_REMEDIATION describes names that never reach this refusal.

Lane: openreposhape-1 (openRepoShape-1)

…d as a path is, a GIT_AUTHOR_NAME's remedy says to correct or unset it, and a blank one that no other creator backs is named as the cause (#688)

Lane 1's review of 0748fc4 found four small defects in the words of #688's
refusals and one gap in its tests.

1. `family-bad-commit-identity` pasted git's answer raw after `git var`.
Git echoes a value it refuses as it was given (`fatal: invalid date format:
<the date>`), so a GIT_AUTHOR_DATE holding a line break began a second line
of the refusal of its own choosing, and one holding U+202E reached the
terminal. The words are now quoted by `shown_path` (#610): as they are when
printable, as Python's `repr` spells them when not. A printable answer reads
as it did.

2. The remedy for an unwritable GIT_AUTHOR_NAME began "pass --created-by",
and `--created-by` changes only who `family.yaml` records, so the next run
met `family-bad-commit-identity` for the same variable. It now says to
correct that variable or unset it, and why. And a GIT_AUTHOR_NAME of
whitespace alone with no `--created-by` and no `git config user.name` said
`family-no-creator`, "no GIT_AUTHOR_NAME", of a variable that was set, and
told the person to pass `--created-by`, which led to the same next refusal.
That case now says the variable is the cause, and tells the person to unset
it AND pass `--created-by`. Each remedy's first exit is run in the tests and
goes on to a holder, dry and real.

3. Mutant "a stand-in only where the variable is unset, not where it is
empty" passed every test, though it refuses a holder `init` makes today:
an empty name or address beside a date in the same role. One control row per
role and per variable pins that an empty one is still `env_commit`'s
fallback inside a role git is asked about.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T13:05:40Z)

Lane 1's re-verification of #809 at 6958dfb: PR-READY

All four findings are fixed, measured on a local merge of 6958dfbc with current main 98f14976. The merge was clean and gave tree 76c2dfa8, the same tree git merge-tree predicts. One sentence of the body needs a refresh before landing, and one small new wording point is below; neither blocks.

# Finding Measured on the merge Status
1 git's words quoted raw in family-bad-commit-identity Line-break date (GIT_AUTHOR_DATE=$'x\nREFUSED family-fake: not real'): exit 2, stderr 2 lines, no forged line, 0 raw U+202E, 0 non-printable characters besides line ends. git's words are now 'fatal: invalid date format: x\nREFUSED family-fake: not real'. U+202E date (GIT_AUTHOR_DATE=$'2026\u202e-01-01'): exit 2, 'fatal: invalid date format: 2026\u202e-01-01', no raw U+202E. Nothing was made in either case. Current main exits 0 on both dry runs, because the failure there only comes at the commit. The quoting goes through shown_path, which is #610's rule (text if text.isprintable() else repr(text)); repo_shape.py is the same file on main. fixed
2 remedies whose first exit led to the next refusal for the same cause Blank GIT_AUTHOR_NAME, no user.name: family-no-creator: no --created-by, a GIT_AUTHOR_NAME of whitespace alone, and no .... Following it literally (variable unset, --created-by 'Ada'), the real run exits 0 and makes the holder. GIT_AUTHOR_NAME=$'\x1b\x07', no --created-by: family-unwritable-value with "correct that variable, or unset it". Unset with a good user.name: the real run exits 0. Corrected to Ada: the real run exits 0. Unset with no user.name: the plain family-no-creator, a different cause; then --created-by 'Ada': the real run exits 0. No remedy now leads into a refusal for the same cause. fixed
3 the empty-fallback control was not pinned inside a role that is asked Mutant R5 (os.environ.get(key, stand_in)) on the merge: 8 failed, 70 passed. The failures are the author-name and committer-name rows of the new "empty beside a date" test, both remedy-exit tests' unset rows, and the blank-creator remedy test, each dry and real. The address rows pass under R5, as they should: git accepts an empty address. fixed
4 stale merge-tree paragraph Every tree in the paragraph reproduces exactly at 6958dfbc. Since it was written, though, main moved to 98f14976 and #767, #670 and #812 landed; #795, #813 and fix/684 have new heads. Replacement text is below. true as of its read, now outdated

Ordinary init matches current main byte for byte after masking the case path, the shape sha and the holder sha, in three cases:

  • user.name creator, dry run;
  • the same, real run;
  • every identity variable set, real run.

Stdout and stderr are identical in all three; the byte counts differ only by the case path's length. The two holders' trees differ only in the clone path, the shape pin and tree_sha256. The body's sha256 values check out: bf599aa3… for family.py and 69e214c1… for the new file, which has 78 tests.

Runs on the merge, in one mode with git-filter-repo on PATH: 1224 passed, 19 skipped, 0 failed over 20 files.

  • My earlier 13 files.
  • Main's new family tests: test_family_add_member_variant.py, test_family_add_refusals.py, test_family_add_spellings_remedies.py, test_family_bootstrap_gate_order.py, test_family_members_case_variants.py, test_validate_family_unnamed_mounts.py and test_setup_family_folder.py.

Checks at 6958dfbc: tests, tests-git-bash, package and SonarCloud Code Analysis are all SUCCESS.

Body refresh before landing. Replace the paragraph that begins "git merge-tree --write-tree of this head 6958dfbc" and its list with:

"git merge-tree --write-tree of this head 6958dfbc, each head read with ls-remote just before (2026-10-17): main 98f14976 (#767, #670 and #812 have landed in it): clean, tree 76c2dfa8; #731 fix/692-raw-folder-paths ab55c29f: clean, tree 1e9b301b; lane 4's other PRs, all in scripts/family.py: #795 fix/686-bump-reachability 7d7a69b1, clean, tree 21afbf72; #813 fix/689-next-push-refspec 15c3b5ab, clean, tree 36ab5304; lane 4's branch with no PR yet, fix/684-family-holder-states fdc90a6e: clean, tree 35fb34dd."

The local merge with main is also clean against each of those four heads.

New, minor (for the checklist): GIT_AUTHOR_NAME=$'\u00a0' (or any other non-ASCII whitespace) with no user.name gets the new blank-variable family-no-creator. Its remediation says the variable is one git "refuses", but git accepts NBSP: the same variable with --created-by Ada exits 0. The remedy itself still works (unset it and pass --created-by); only the word "refuses" is untrue for those characters. If you want it exact, "which git would make the holder's first commit as" without "and refuses" is true for every blank value.

Lane: openreposhape-1 (openRepoShape-1)

@sourcery-ai

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

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 42aed78 into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/688-family-init-values branch October 17, 2026 17:29
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…mand-scope, and tests/test_local_remote_dir_urls.py calls `allow_file_protocol()`, so #812's test file still imports once #758 lands (#758)

The retarget after #748 landed as 431c8cf. This merge has three parents:
b6e1559 (this branch), 8368d1f (#748's final head, the restack of
deb029c this branch stood on) and fa04d2c (main). Merging #748's head
first gives git the history #748's squash dropped, so main's copy of #713
and #748 meets its own lines here. Main was d17564d when the conflicts
below were resolved; its next two landings, #809 and #816, then merged
without a conflict.

Conflicts, and how each was resolved:

- tests/test_adopt_empty_symlinks.py, against 8368d1f, which carries #388
  through main 741679e. Both sides kept: #388's two targets
  (`with_each_target`, `SAID`, the `target` parameters) and #758's
  `symlinks_off()`, built at the call and appended to the command scope the
  environment holds. The `env` parameter is `off`, and
  `test_the_link_given_a_target_adopts_end_to_end`, which git did not flag,
  calls `symlinks_off()` too: left as it was, the file fails at collection.
- tests/test_clone_just_pushed.py, tests/test_doctor_legs_members_next.py
  and tests/test_family_bump_remove_rollback.py, against main d17564d.
  Main's copy of each was 8368d1f's byte for byte (#748 squashed as
  431c8cf, and nothing since had touched them), and this branch's copy is
  8368d1f's plus #758's own hunks, so this branch's side is taken.

Against main, the merged tree differs in #758's 25 files and in
tests/test_local_remote_dir_urls.py. Each of the 25 differs by #758's own
added and removed lines, tests/test_adopt_empty_symlinks.py aside.

rv818's MUST 1 (lane 2): tests/test_local_remote_dir_urls.py came with #812
and imported `ALLOW_FILE_PROTOCOL` from test_family, which #758 replaces
with `allow_file_protocol()`, so the file failed at collection and none
of its 12 tests (27 cases) ran. It now imports `allow_file_protocol` and
calls it at the paste. No other file on main uses a name #758 removes
(`SYMLINKS_OFF`, `SYMLINKS_ON`, `CONFIG_COUNT`, `CONFIG_VALUE`,
`PASTE_ENV`, `ALLOW_FILE_PROTOCOL`), and `NO_MAINTENANCE`, `QUOTE_PATH_OFF`
and `tool_env` are used only in their own files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained #748 (#662, `431c8cf8`), #731 (#692, `d66a9054`), #810
(#674, `d17564d9`), #809 (#688, `42aed788`), #816 (refs #684,
`fa04d2cd`), #813 (#689, `f6d17bc5`) and #723 (#663, `bad05d21`) since
64ec47c. No conflict, and none of them touches README.md, the handbook,
AGENTS.md, docs/ or the README cap's ledger, so every handbook marker
reads as before. README.md is 1474 lines, its cap; AGENTS.md 494, its cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
#813 and #723, v1.0.0's quoted-header and plain-path-clone limits closed and the counts current to main bad05d2 (#563)

Each entry is the one RV630 corrected in its pre-review of the B2 stage,
put at its anchor, and checked against its squash on main: the
`git merge-tree --write-tree` of main before the squash with the head the
entry was written from equals the squash's tree for all seven.

- #810 (#674, `d17564d9`): a new bullet, "`apply`'s lines, its branch and
  its headers.", after "Headers, branches and messages.". It closes
  v1.0.0's quoted `files:`/`shape:` header (v1.0.0.md:439) and `apply`'s
  half of the paths listed as staged (:433); `bump-leg.py`'s half waits
  for #746.
- #731 (#692, `d66a9054`): appended to "Folders, asked before they are
  made.".
- #813 (#689, `f6d17bc5`): appended to "`--tracking-branch`.".
- #809 (#688, `42aed788`): a new bullet, "What `family.py init` records
  and commits as.", after "Names that are not UTF-8.".
- #816 (part of #684, `fa04d2cd`): a new bullet, "More holder states,
  named.", after "family.py refuses before it writes.". #816 closes no
  issue; #684 keeps its reftable case and is on v1.1.1.
- #748 (#662, `431c8cf8`), then #723 (#663, `bad05d21`): in the CI
  bullet, after #713's sentence. With #554 and #578, #748 closes v1.0.0's
  limit on fixtures that clone a just-pushed remote by plain path (:530).

The counts, in the header comment, the counts paragraph, "What changed"
and the open list: main `bad05d2`; 120 pull requests since `bfed143`;
at 18:02Z, 3 of the `v1.1.0` milestone's 133 issues open and 130 closed.
The open list is #563 (#630), #672 (#746) and #686 (#795); #684 and #695
(#738) are on v1.1.1. #746 and #795 have not landed, so neither has an
entry yet. Nothing since 2000488 touches pyproject.toml,
src/openreposhape/, install.sh or .github/workflows/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…nd what they tell git to the `GIT_CONFIG_COUNT` entries the environment holds, so a pasted line keeps the session's maintenance settings and the Windows job's two (#758) (#818)

Against main. #748 landed as `431c8cf8`; this branch merged #748's final head and main (see Fix round 1). After that merge, #723 (`bad05d21`) and #746 (`fc867207`) landed. The branch is refreshed over main `fc867207` as `4a553e17`, one merge commit. Its only conflict was `tests/test_non_utf8_git_reads.py`'s `from conftest import` block, resolved by keeping both sides' names, as rv818 asked. The merged tree differs from main in #818's own 27 files, and #818's count scan flags no file. Tests only: 25 files under `tests/`, no shipped code, no pinned file, no README, handbook, `AGENTS.md` or `docs/cli.md` change.

Two new commits on `b6e15591`: the retarget merge `2908e46f`, then the fix commit `eaa72d9e`. Tests only, as before.

### The retarget: merge commit `2908e46f`
#748 landed as `431c8cf8` from its final head `8368d1f8`, and GitHub moved this PR's base to main. The merge commit has three parents: `b6e15591`, `8368d1f8` and main `fa04d2cd`. #748's head goes in first, so git sees the history the squash dropped.

- **#748's head did not merge clean**, as rv818 found. `tests/test_adopt_empty_symlinks.py` conflicts, because `8368d1f8` holds main `741679e7`, which carries #388.
  - I took w758's earlier resolution of that file, which rv818 checked. Main's side of the file is the same at `974d4827`, `8368d1f8` and `fa04d2cd`, so it still fits.
  - Both sides stay: #388's two targets (`with_each_target`, `SAID`, the `target` parameters) and #758's `symlinks_off()`, built at the call.
  - `test_the_link_given_a_target_adopts_end_to_end` calls `symlinks_off()` too. Git does not flag it, and left as it was the file fails at collection.
- **Main then conflicted in three files** (main was `d17564d9` when I resolved them): `tests/test_clone_just_pushed.py`, `tests/test_doctor_legs_members_next.py` and `tests/test_family_bump_remove_rollback.py`.
  - Main's copy of each was `8368d1f8`'s byte for byte.
  - This branch's copy is `8368d1f8`'s plus #758's own hunks, so this branch's side is taken.
  - Main's next two landings, #809 and #816, merged with no conflict.
- **rv818 MUST 1.** `tests/test_local_remote_dir_urls.py` came with #812 and imported `ALLOW_FILE_PROTOCOL` from `test_family`.
  - Without a change, the file fails at collection: `ImportError: cannot import name 'ALLOW_FILE_PROTOCOL' from 'test_family'`, and none of its 27 cases run.
  - It now imports `allow_file_protocol` and calls `pasted(lines, paste, allow_file_protocol())`.
  - `git grep` finds no other file on main that uses a name #758 removes (`SYMLINKS_OFF`, `SYMLINKS_ON`, `CONFIG_COUNT`, `CONFIG_VALUE`, `PASTE_ENV`, `ALLOW_FILE_PROTOCOL`). `NO_MAINTENANCE`, `QUOTE_PATH_OFF` and `tool_env` change shape, and each is used only in its own file.
- **Checked:**
  - Against main, the merged tree differs in #758's 25 files and in `tests/test_local_remote_dir_urls.py`.
  - Each of the 25 differs by exactly #758's own added and removed lines, `tests/test_adopt_empty_symlinks.py` aside.
  - Collecting the whole suite at the head finds 12,320 tests and no error.

### The fix commit `eaa72d9e`
- **SHOULD 2:** `configure` in `tests/test_adopt_reencoded_files.py`, the seventh append from #758's line 2, now sets what `appended_to_command_scope(os.environ, pairs)` returns. It gives the same entries in the same order, so no test can fail on the old form.
- **SHOULD 3:** the scan also reads a name the module binds to `"GIT_CONFIG_COUNT"` (`CONFIG_COUNT = "GIT_CONFIG_COUNT"`, annotated or not) as the count. It does so in a dict key, a setter's first argument and a subscript.
  - rv818's repro: append the base's two lines to `tests/test_adopt_root_symlinks.py`.
  - The old scan passes. The new one names line 2137.
- **SHOULD 4:** `counts_set_to_a_number` is split into one reader per node type: `in_a_dict`, `in_a_call` and `by_subscript`. The walk takes each node's reader from `READERS`. By hand, no function scores more than 5 on S3776. I could not run Sonar.
- **NIT 5:** `conftest.py` is no longer skipped. It scans clean, at the head and on today's main.
- **NIT 6:**
  - New "sees" cases: `os.putenv("GIT_CONFIG_COUNT", "1")` and a bare `setenv("GIT_CONFIG_COUNT", "1")`.
  - The walk is now `files_off_their_count(directory)`. A new test runs it over a folder of its own, and checks three things:
    - `conftest.py` and a helper module are read;
    - a `.txt` file is not;
    - a file `OWN_COUNTS` allows is named for fewer counts as well as for more.
  - Four of rv818's six surviving mutants now fail a case: `putenv` dropped, the bare-name setter dropped, `!=` changed to `>`, and `glob("test_*.py")`.
  - The fifth, `conftest.py` scanned, is now how the code works, and putting the skip back fails the new test.
  - The sixth, the count's value taking any string, still survives: it is a stricter scan. Git refuses a count that is not a number (`bogus count in GIT_CONFIG_COUNT`, exit 128), so a test that holds one fails on its own.
- **NIT 7, in part:**
  - An empty count (`""`) is now a count of its own. Git 2.43 and 2.47.1 read it as no entries at all. Measured: `GIT_CONFIG_COUNT= GIT_CONFIG_KEY_0=a.b GIT_CONFIG_VALUE_0=c git config --get a.b` exits 1.
  - The docstring says the scan reads the count's value as a constant only.
  - Left out:
    - a value built by a call (`str(len(pairs))`) or held in a variable;
    - an int;
    - `dict()`, or `environ={}` by keyword, handed to the helper;
    - `setenv` with keyword arguments;
    - an annotated subscript.

    rv818 judged only `str(len(entries))` likely, and telling it from a count worked out needs what the call means, not how it is spelled.
- **NIT 8:** the paste case deletes `GIT_CONFIG_PARAMETERS` first. Measured on the merge before the fix commit, with `GIT_CONFIG_PARAMETERS="'probe.param'='p'"`, the case failed (`Left contains one more item: 'probe.param=p'`). It passes now.
- **NIT 9, left as it is:** main's three appends built by hand each work the count out right: `tests/test_family_head_lost_gitmodules.py:97`, `tests/test_family_bootstrap_gate_order.py:135` and `tests/test_family_add_spellings_remedies.py:137`. Each of those files is also changed by another open PR (#828 `fix/761-add-move-back-gitmodules`, #820 `fix/762-validate-family-remedies-texts`, `fix/763-holder-docs-agree`), so converting them here would only add conflicts.
- **NIT 10:** the comment on `NO_MAINTENANCE` now says its `maintenance.auto` and `gc.auto` repeat the session's two, which reach these gits in the command scope. Only `gc.autoDetach` adds a setting. The two stay, so the constant alone still keeps maintenance off for these gits.
- **NIT 11:** the count was in lane 5's #473 summary, not in this body. The seven sets held 2,401 distinct passes, not 2,609: the 208-test run repeats three files of the 256-test run.

### Tests at `eaa72d9e`
All runs went through `run9.sh`, with the real `gh` unreachable. All passed, none skipped:
- **The 26 test files the PR changes against main**, in one full-file run each: 2,206 passed (1,108 + 1,098).
  - These are #758's 24 test files, `tests/test_local_remote_dir_urls.py` and `tests/test_adopt_reencoded_files.py`.
  - The count matches the collection exactly.
- **The 43 files that import any of those 26, with `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`:** 1,646 passed (825 + 821).
- **The same merge made on main `d17564d9`** (a local commit, `c16a7e1a`, never pushed), before the fix commit: `tests/test_local_remote_dir_urls.py` and `tests/test_no_auto_maintenance.py`, 55 passed.

### Fails on the base
The new tests were run against the base's scan and walk, as `b6e15591` has them, with `GIT_CONFIG_PARAMETERS` set: 6 failed, 30 passed. The six:
- the paste case;
- the `nothing`, `a-name-bound-to-it`, `an-annotated-name` and `a-name-by-subscript` cases;
- the walk test.

The three other new cases (`a-bare-setenv`, `putenv`, `a-name-bound-to-another-key`) are there to kill mutants, and they pass on any correct scan. MUST 1 fails on main's form at collection, as above. SHOULD 2 and NIT 10 change no behaviour.

### Mutants
I made 26 mutants. 25 are caught, and one survives as rv818 said it would:
- **22 on the scan and the paste case**, each run against `tests/test_no_auto_maintenance.py`:
  - the delenv dropped;
  - `putenv` dropped from `SETTERS`, or the bare-name setter;
  - the name branch of `is_the_count` dropped, or any name taken as the count;
  - `""` no longer a count;
  - the `Assign` names dropped, or the `AnnAssign` names;
  - no names collected;
  - each reader ignoring the names;
  - each of the three readers dropped from `READERS`;
  - `conftest.py` skipped;
  - `!=` changed to `>` or to `<`;
  - `glob("test_*.py")`, or `glob("*")`;
  - `by_subscript` taking any value;
  - the walk pointed at an empty folder.

  The last passes on a clean tree, like any change to an assertion that nothing is found. It is caught by rv818's SHOULD 3 repro, where the real walk fails and the mutant passes.
- **The survivor:** the count's value taking any string. It makes the scan stricter (see NIT 6).
- **`configure`:** with `appended_to_command_scope({}, pairs)`, the scan names the file. With the pairs dropped, three of its tests fail.
- **MUST 1:** main's import put back fails collection with `ImportError`.

### Merge checks at `eaa72d9e`
I ran `git merge-tree --write-tree` against `origin/main` (`fa04d2cd`) and the 53 other `fix/*` and `l3-*` heads on origin:
- **Clean:** `origin/main` and 43 heads.
- **#792 (`l3-785-non-ascii-paths`)** merges clean, but `tests/test_doctor_non_ascii_paths.py` imports `PASTE_ENV`. Whichever PR lands second changes `:50` (the import), the `:266` docstring and `:268` to `paste_env()`.
- **#723 (`fix/663-non-utf8-name-tests`), #746 (`fix/672-bump-leg-branch-and-push-line`, which now holds #723) and #806 (`fix/757-non-utf8-test-dirs`)** conflict only in the `from conftest import` block of `tests/test_non_utf8_git_reads.py` (and of `tests/test_adopt_git_reads.py`, for #806). Both sides add names, so keep them all.
- **Seven heads conflict with main itself, not with this PR:**
  - `l3-732-latin1-section` and `l3-733-ignored-sections` (`tests/test_doctor_legs_members_next.py`);
  - `fix/712-validate-pins-contracts`, `fix/766-spelled-paths` and `fix/798-adopt-legs-asked-first`;
  - `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`.
- I scanned each clean merged tree's changed test files for a name #758 removes, or a count the scan flags. Only #792 shows up.
- **Main moved after the push.** #723 landed as `bad05d21`, and the two now conflict in one place: the `from conftest import` block of `tests/test_non_utf8_git_reads.py`. The fix is to keep both sides' names: `ORG, PROJECT, REPO, appended_to_command_scope, cloned_just_pushed, git, run_script, skip_message, skip_where_fsencode_gives_a_surrogate_no_byte, windows_fsencode`. Every other change merges in. On that merged tree, with the block resolved that way, the scan flags no file, and no file uses a name #758 removes.

On `deb029c9` a probe test imported `PASTE_ENV` and pasted `git config --show-origin --list` through `paste()`:
- `PASTE_ENV` held `GIT_CONFIG_COUNT=1` (`protocol.file.allow`), a stale `GIT_CONFIG_KEY_1=user.email` left from collection, and no `GIT_CONFIG_GLOBAL`.
- `os.environ` at the call held a count of 4 and the session's global file.
- The pasted git listed one command-line entry, `protocol.file.allow=always`, and `git config --get maintenance.auto` exited 1. So a pasted line ran with git's automatic maintenance on, in both scopes, as the issue said.

`paste_env()` replaces it. It reads `os.environ` at each call (minus `PYTHONPATH`), adds the identity and `PYTHONDONTWRITEBYTECODE`, and appends `protocol.file.allow` with `appended_to_command_scope`. The six uses call it. The import-time copy also predated the session's `gh` guard on `PATH`, as `test_doctor_next_is_a_command.py`'s docstring says of its own environment; a pasted line now gets the `PATH` of the call. That docstring now says `paste_env()` reads at the call too.

- **The seven.** Six are on this base, each now one call to `appended_to_command_scope`: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_root_symlinks.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`. The seventh, `test_adopt_reencoded_files.py`, came with #743 and is converted in fix round 1. `test_clone_just_pushed.py:96` stays, as the issue says: it is the helper's own test.
- **The other literals.** Each sets a count of its own and is laid over `os.environ`, where the session's two settings and the Windows job's two sit, so each replaced them (`NO_MAINTENANCE` put the two maintenance keys back and dropped the job's two). I read each; each is converted, none left:
  - `test_family.py`: `ALLOW_FILE_PROTOCOL` is now `allow_file_protocol()`; its two importers (`test_siblings_family_name.py`, `test_family_siblings_placement.py`) and the four uses in `test_family.py` call it.
  - `test_family_dry_run.py`: `NO_MAINTENANCE` is a tuple of entries, appended by `isolated()`.
  - `test_non_utf8_git_reads.py`: `QUOTE_PATH_OFF` is a tuple of entries, which `tool_env()` appends; `user_name()` appends too.
  - `test_adopt_empty_symlinks.py`: `SYMLINKS_OFF` is `symlinks_off()`.
  - `test_adopt_root_symlinks.py`: `SYMLINKS_OFF` and `SYMLINKS_ON` are gone; the four tests that set the first call `links_as_files` and the two that set the second call `told_git`, which already appended.
  - one-off environments: `test_scaffold_reuse_seeded_legs.py`, `test_adopt_split_branch.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`, `test_setup_project_py.py`, `test_setup_sh.py`, `test_doctor_next_is_a_command.py`.
  - The two tests that build a held environment on purpose to see it kept (`test_adopt_git_reads.py`, `test_clone_just_pushed.py`) now build it with `appended_to_command_scope({}, ...)`.

Measured with a probe on a throwaway bare remote after a push (18 loose objects): the hand-built options and `clone_just_pushed`'s give the same clone, one pack and no loose object, with `upload-pack` and `pack-objects` in `GIT_TRACE`, and `origin` keeps the plain path. A plain local clone of the same remote keeps all 18 loose objects. For a clone that is not recursive the helper's `insteadOf` rewrite is inert, because no submodule is cloned; the helper adds one place for the options, not a different read.
- `test_bump_leg_dry_run_truth.py:98`, the private bare copy of the session's spec remote: a session remote. `cloned_just_pushed(..., bare=True)`; the fixture now asserts the copy is bare.
- `test_bump_leg_dry_run_truth.py:110`, `private_work`, a clone of that private remote, which tests push tags into: `cloned_just_pushed(..., recurse=False)`.
- `test_doctor_legs_members_next.py:1828` and `:1929`, `Ghost`, a clone of the first member's checkout in the test's own holder copy: not a remote, and the hand-built `-c protocol.file.allow=always` was dead weight on a direct clone. Both call `clone_member`, one helper over `cloned_just_pushed(..., recurse=False)` that asserts the legs stay empty.
- `test_family_bump_to_checks.py:235`, a clone of `a_private_remote`, a bare repository filled by a push: `cloned_just_pushed(remote, work, "main", recurse=False, env=env)`.
- `test_doctor_pin_restore_merges.py:714` is **left as it is**: a `--filter=blob:none` clone of a file URI. The helper takes a path, and has no way to pass `--filter`.

- `test_family_dry_run.py`: the comment on `NO_MAINTENANCE` says what the entries are for, the gits `isolated()` hands an environment to, whose global file `excluding()` replaces, and that the session fixture covers the module's own setup commits, which pass no `env=`.
- `tests/conftest.py`: the two i645 replacements, "repacks once its estimate, taken from `objects/17` alone, is above 256 loose objects, so two loose objects there are enough", and the `objects changed` sentence. **#713's body holds the matching sentence and needs the same two replacements; I cannot edit a body.**

`tests/test_no_auto_maintenance.py` has 17 new cases (28 in the file, 11 before):
- `test_a_pasted_line_keeps_the_entries_the_environment_holds_at_the_call`: the environment holds two entries as the Windows job does, then the two settings as the session fixture appends them, set after the doctor tests were imported. `git config --show-origin --list` through `paste()` lists the four, in order, then `protocol.file.allow`, and reads both settings from the session's global file.
- `test_no_test_sets_a_git_config_count_of_its_own`: reads every `tests/*.py` but `conftest.py` with `ast` and fails a `GIT_CONFIG_COUNT` set to a number (dict, keyword, `setenv`/`setdefault`/`putenv`, subscript) and an `appended_to_command_scope` call handed a dict written out. Three files are allowed the ones that build a held environment on purpose, with a count each in `OWN_COUNTS`.
- 15 cases that hold the scan itself: seven spellings it must see, eight it must leave (`env.get("GIT_CONFIG_COUNT", "0")` is a read).

Runs at the head `b6e15591` through `run9.sh` (the real `gh` unreachable), all passed, none skipped:
- `test_no_auto_maintenance.py`, `test_doctor_legs_members_next.py`, `test_doctor_next_is_a_command.py`: 208 passed.
- `test_clone_just_pushed.py` and `test_doctor_validator_rows.py` (it imports the doctor file), with those three: 256 passed, run one commit earlier (the later commit only moved two blank lines and reworded one failure message).
- The sweep files: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_root_symlinks.py`, `test_adopt_ignored_shape_files.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`, `test_adopt_empty_symlinks.py`, `test_adopt_split_branch.py`: 445 passed.
- `test_non_utf8_git_reads.py`, `test_family.py`, `test_siblings_family_name.py`, `test_family_siblings_placement.py`, `test_scaffold_reuse_seeded_legs.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`: 999 passed.
- `test_family_dry_run.py`, the five files that import its `isolated` (`test_family_bump_to_a_tag.py`, `test_family_bump_to_checks.py`, `test_family_bump_to_local_only.py`, `test_family_refuse_up_front.py`, `test_family_remove_gitmodules.py`) and `test_bump_leg_dry_run_truth.py`: 216 passed.
- `test_setup_project_py.py`, `test_setup_sh.py` and their importers (`test_adopt_shape_pin_and_work_dir.py`, `test_dry_run_asks_remotes.py`, `test_clone_url_bytes.py`, `test_setup_by_hand_commands.py`): 313 passed.
- `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_doctor_pin_restore_merges.py`, `test_git_helper_identity.py`: 172 passed. `render-cli-reference.py --check` is ok.

**On the base (`deb029c9`)**, with the new `test_no_auto_maintenance.py` copied in: 2 of the 17 fail, the paste case and the scan (it finds 12 files). The other 15 hold the scan itself and pass on any tree.

Each of the new hunks was broken in a throwaway and run against its tests:
- `paste_env`: a literal count of 1, a snapshot taken at import, and `appended_to_command_scope({}, ...)`: each fails the paste case, and the first and third the scan too.
- The 18 sites that call `appended_to_command_scope(os.environ, ...)` (or `env`), each with `{}` in place of the environment: each fails the scan. Sites: the six of line 2, `allow_file_protocol`, `isolated`, `tool_env`, `user_name` (non-UTF-8), `symlinks_off`, `git_reads_github_from`, `github_at`, `user_name` (free text), `test_adopt_plan_yaml_quoting.py`, the two setup tests, `test_doctor_next_is_a_command.py`.
- The scan: eight changes (ignore dicts, keywords, `setenv` calls, subscripts, `appended_to_command_scope({}`; flag `os.environ`; flag a read; allow fewer in `OWN_COUNTS`) each fail a scan case.
- The clone sites: dropping `bare=True` from the private remote, `recurse=False` from `clone_member`, and `"main"` from the checks clone each fail. Two **survive as equivalents**: `recurse=False` in `private_work` (the spec leg has no `.gitmodules`, so a recursive clone of it is the same clone) and `env=env` in the checks clone (a clone reads no config the isolated environment changes).
- The two comments cannot be mutated.

`git merge-tree --write-tree` of this head against `origin/main` (`974d4827`), #748 (`deb029c9`), #713 (`979bb394`) and every `fix/*` and `l3-*` head on origin (46 refs):
- **Clean:** #748, #713, and 24 of the 46 heads: 21 `fix/*` heads (every lane-5 PR but #723 and #756, and most of lane 4's) and `l3-677`, `l3-678`, `l3-rehearsal-v1.1.0`.
- **`origin/main`: one file, `tests/test_adopt_empty_symlinks.py`** (four hunks), against #388's landing (`5c990708`). The parent #748 merges clean, so this arrives with the retarget. Resolve by keeping main's lines and my `symlinks_off()`. Two more spots need the same change and git does not flag them:
  - `tests/test_adopt_empty_symlinks.py::test_the_link_given_a_target_adopts_end_to_end` uses `SYMLINKS_OFF`, which this change removes: give it the `off` parametrization.
  - `tests/test_adopt_reencoded_files.py::configure`, the seventh hand-built append, goes to `appended_to_command_scope(os.environ, pairs)`.
  - `tests/test_family_head_lost_gitmodules.py::on_disk` (on main) is one more hand-built append: `{**env, **appended_to_command_scope(env, [("core.ignorecase", ignorecase)])}`. The scan does not read for hand-built appends, so it is not required, but it is the same thing.

  I made that merge in a scratch tree (main at `bf1f099b`), resolved it as above, and ran the files this change touches that main also changed, with `test_adopt_empty_symlinks.py`, `test_adopt_reencoded_files.py`, `test_family_head_lost_gitmodules.py`, `test_no_auto_maintenance.py` and the files that import `isolated` or the doctor helpers: 653 and 245 passed, in two runs. The scan finds no other file of main's with a count set by hand.
- **#723 (`fix/663-non-utf8-name-tests`):** `tests/test_non_utf8_git_reads.py`, the `from conftest import` block, where both add names.
- **`l3-732`, `l3-733`:** `tests/test_doctor_legs_members_next.py` (the `from conftest import` block) and `tests/test_adopt_empty_symlinks.py`.
- **19 more** (`fix/664`, `fix/729` (#756) and 17 `l3-*` heads): `tests/test_adopt_empty_symlinks.py` only, because they carry main's #388 as main does.
- **Not a textual conflict, but it breaks on merge:** `l3-785-non-ascii-paths` imports `PASTE_ENV` (`tests/test_doctor_non_ascii_paths.py:47` and `:223`); it must call `paste_env()`. #812 (`fix/693-local-remote-dir`) landed first, with `tests/test_local_remote_dir_urls.py` importing `ALLOW_FILE_PROTOCOL` (`:51`, `:349`). This PR's retarget merge makes it call `allow_file_protocol()` (rv818 MUST 1).

- A pasted `git submodule update --init` still clones a local path: `paste()` takes no remotes, so no `insteadOf` rewrite reaches it (rv662's "build it from `read_through_git`"). It runs under the session's maintenance settings now, which is what the issue asks.
- Three more appends built by hand beside `appended_to_command_scope`, in files this base does not hold: `tests/test_family_head_lost_gitmodules.py:97` (main), `tests/test_family_bootstrap_gate_order.py:135` (#682), `tests/test_family_add_spellings_remedies.py:137` (#767). The scan reads for counts set to a number, not for appends worked out by hand.

Review: lane 2's independent review (issuecomment-6236874291) and its re-verification of eaa72d9, READY (issuecomment-6238781272); its new SHOULD is taken in #848, and 4a553e1 merges main over eaa72d9. CI (Brett Heap's ruling 27): H0 is the head 4a553e1 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base fc86720; and the local gate passed on its predicted squash tree 9d6c099 over main d8b8eb3: the adopt_empty_symlinks, adopt_git_reads, adopt_ignored_shape_files, adopt_nested_attributes, adopt_plan_yaml_quoting, adopt_reencoded_files, adopt_root_symlinks, adopt_split_branch, bootstrap_after_bump, bump_leg_dry_run_truth, clone_just_pushed, doctor_legs_members_next, doctor_next_is_a_command, family, family_bump_remove_rollback, family_bump_to_checks, family_dry_run, family_siblings_placement, local_remote_dir_urls, no_auto_maintenance, non_utf8_git_reads, scaffold_free_text_values, scaffold_reuse_seeded_legs, setup_project_py, setup_sh, siblings_family_name, repo_hygiene, agents_md_codes, cli_reference sets, 2359 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 #758

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant