Skip to content

siblings.py: the printed move runs as typed past a taken staging name and names a dangling link to move aside first, a NUL or lone surrogate in project: or url is a finding, .gitmodules is read as git reads it, the warning's prose is spelled, and a tag-only branch is named (#690) - #805

Merged
brettheap merged 7 commits into
mainfrom
fix/690-siblings-inputs
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

What changed and why

One pinned file, templates/family-root/scripts/siblings.py (make siblings). Every holder takes it as one upstream-changed row. Each of #690's six lines was re-measured on main 4ce90457 with a holder from family.py init --local-remote-dir, then measured again on this branch. Main bf1f099b is merged in (no conflict; main had not touched this file), lane 3's two requests on #690 are added as test rows (the last row of the table), and lane 1's review of 25c7b14c is answered (the bullet after the table).

#690 line on main now
An existing staging name (#578) With <t>/InkRouter.holder already a folder, the three printed lines all exit 0 and leave the holder at <t>/InkRouter/InkRouter/InkRouter/family.yaml. staging_path gives the first of <Family>.holder, .holder.1, .holder.2 … at which os.path.lexists finds nothing. The lines print mv <t>/t/InkRouter <t>/InkRouter.holder.1 and run as typed to <t>/InkRouter/InkRouter/family.yaml. <Family>.folder works the same way, and on a disk that ignores case a name in another case counts as taken.
A dangling link named after the family (#578) The first mv exits 0, then mkdir -p <t>/InkRouter fails File exists, with the holder left at InkRouter.holder. Before the moves, the warning says First move <t>/InkRouter aside: it is a link to nothing, named after the family, and in the way of the move. Then:. The moves under it are the ones that run once the link is gone; moved aside, they run as typed.
project: holding a lone surrogate or a NUL (R454 O1) UnicodeEncodeError: … surrogates not allowed or ValueError: embedded null byte at git clone, exit 1. IRSS, after it, is never cloned, and the dry run says would clone. argument_fault is asked of the project: and of the url before anything. It is #455's rule as #644 wrote it for make bootstrap: a NUL, or a surrogate os.fsencode cannot write as one byte. A failing member is NOT AN ARGUMENT, with the finding FINDING IR\ud83dRS: its project: in family.yaml holds U+D83D, a lone surrogate, which no command can be given as an argument, so nothing was cloned or fetched for this member: git was never run with it. The run exits 1, the dry run says the same, and IRSS is still cloned. A url built from a repository: holding a NUL ended in the same traceback, and is now the same finding (its url https://github.com/InkRouter/IR\x00RS.git (family.yaml repository:) holds U+0000, a NUL, …).
The warning prints paths raw (R454 O2) Under a parent folder x<ESC>[31my, stderr lines 2 and 4 hold the raw ESC, and {family} is raw on line 2. Those three values go through spelled. The first line keeps its repr, and the mv lines stay as typed (#486's question). The raw ESC is now on the mv line alone.
submodule_urls truncates a url (R432 O2), a section named members/a=b (lane 3, #473 issuecomment-6216310747), [submodule "a=b"] (lane 3, #690 issuecomment-6232404184) and a CR in a section name (lane 3, #690 issuecomment-6232689453) A url holding NEL or U+2028 is read as <remotes>/caf. members/a=b and a=b give no url (the split at the first = reads the key as submodule.a), so the member falls back to https://github.com/InkRouter/<P>.git. A CR in a name cuts the key in two (str.splitlines), so the section [submodule "q\rx"] is read as no section: measured on a bare file, main gives {'members/IRRS': ...} and this branch {'members/IRRS': ..., 'members/q': '/r/q.git'}. The file is read with git config -z --list: records are cut at NUL, each key is split from its value at the first newline, and a section's name is everything in its key between submodule. and the last dot. Only path and url count, and the later value wins. This is validate-family.py's reading and the one #668 (PR #700, on main) gives the doctor: for the same file the doctor's submodule_config and this read give one {path: url}. A byte that is not UTF-8 is still replaced (#432).
A tracking_branch: that is only a tag (#578) git clone --branch v-track leaves the clone detached, under on v-track, its tracking branch, and the run says siblings ok, exit 0. After the clone, a line says [IRSS] v-track is not a branch of <url> but a tag, which git clone checks out DETACHED: this clone is on no branch. The exit is unchanged; the issue asks for the line, not a finding.
  • Git's text, relayed. A url .gitmodules gives whole may hold a line feed, which git's own clone error quotes raw (and every other control as a ?); the CLONE FAILED finding writes that url spelled, so git's text puts no line of .gitmodules at column 0. The failed-fetch line does the same with the clone's origin. Both go through said_of_url, which looks for the url as given and as git quotes it. A url longer than git's 4096-byte message is quoted cut short, in neither form, so git's text is then spelled line by line, each line after the first indented.
  • Docs. No sentence in AGENTS.md, README.md, docs/handbook.html, docs/cli.md or a template is made false, and no --help text changes.
  • The headers U664 pins. The holder-root spelling in both headers is untouched; no line before the warning moved.

Tests

The new file is tests/test_siblings_inputs.py: 55 tests, 15 of them parity cases that run now that #668 is on main.

  • Harness. It uses test_family's family fixture and test_family_siblings_placement's helpers.
  • Inputs. Each test uses the issue's own inputs.
  • The printed moves. They are run as typed in a POSIX shell. os.rename onto an empty folder replaces it, where mv moves into it.
  • Line splitting. Output is split at \n alone, because str.splitlines breaks a command line at the U+2028 one test puts in it.

What each case asserts:

The run, on the pushed head (siblings.py sha256 6e8daddd…, the test file 7115c1b9…):

Mutants, each reverting one part of the fix in siblings.py and running the new file:

  • M1, submodule_urls back to main's text read (--list, splitlines, split at the first =): killed. Run on the merged tree with lane 3's rows: 26 failed, 7 passed. The 7 are the names main's reading also reads: space, dot and quote in the read, and those three plus the Latin-1 name in the parity. The new rows that fail are equals-bare, cr, the CR row, the a=b dry run, and the doctor parity for equals-bare and cr.
  • M2, argument_fault returns None: killed, 5 failed.
  • M3, staging_path takes the first name whatever holds it: killed, 5 failed.
  • M4, the whole file as main has it: killed, 27 failed.
  • M5, said_of_url returns the text unchanged: killed, 5 failed (the forging and spelled rows, and the fetch row).
  • M6, said_of_url looks for the url as given only, not as git quotes it (? for a control): killed, 2 failed (both ESC rows). This is the reviewer's raw replace alone.
  • M7, the failed fetch's text not passed through said_of_url: killed, 1 failed.
  • M8, the clone failure's text not passed through said_of_url: killed, 4 failed.
  • M9, the long-url fallback removed: killed, 1 failed (the 6061-character url's row).

What it cannot tell

Shared files

against head result
main 20004887 clean, tree b2cdfeb6
#772 (lane 3, #735) 1d149933 clean, tree ce6cb932
#775 (lane 3, #733) 3fa5beed conflict, templates/family-root/scripts/siblings.py only, both hunks inside submodule_urls: its docstring paragraph and its loop
#788 (lane 3, #784) e966a684 clean, tree d59fb2e4
#794 (lane 3, #793) 2ffe154c clean, tree f1e1692b

Closes #690

🤖 Generated with Claude Code

Summary by Sourcery

Harden make siblings against malformed inputs, unusual Git metadata, unsafe diagnostics, and filesystem staging conflicts while preserving correct placement behavior.

New Features:

  • Handle staging conflicts, dangling family links, invalid command arguments, .gitmodules edge cases, and tag-only tracking branches safely in make siblings.
  • Relay Git errors without allowing control characters or embedded URL content to forge diagnostic findings.

Bug Fixes:

  • Prevent malformed paths, URLs, and existing staging entries from causing tracebacks, misplaced holders, truncated remotes, or unsafe diagnostics.

Enhancements:

  • Align .gitmodules parsing with Git and the family doctor so unusual section names and URL values are interpreted consistently.
  • Spell warning and Git diagnostic prose while preserving copy-and-pasteable move commands.

Tests:

  • Add comprehensive coverage for malformed arguments, staging conflicts, dangling links, diagnostic escaping, .gitmodules parsing, Git error relaying, and tag checkouts.

Brett Heap and others added 3 commits October 16, 2026 14:19
…the printed move runs as typed past a taken staging name or dangling link, a NUL or lone surrogate in `project:` or url is a finding, `.gitmodules` is read as git reads it, the warning's prose is spelled, and a tag-only branch is named (#690)

Six lines of `templates/family-root/scripts/siblings.py` (`make siblings`, a pinned copy in every family holder, which takes this as one `upstream-changed` row), each re-measured on main 4ce9045 with a holder from `family.py init --local-remote-dir`:

- `parent_folder_warning` printed its staging name whatever held it. With `<t>/InkRouter.holder` a folder already, the three printed lines all exited 0 and left the holder at `<t>/InkRouter/InkRouter/InkRouter`, one level too deep; `<Family>.folder` alike on a disk that ignores case. `staging_path` now gives the first of `<Family>.holder`, `<Family>.holder.1`, `.holder.2` ... at which `os.path.lexists` finds nothing, a link to nothing included.
- A link to nothing named `<Family>` passed `not wanted.exists()`, so the staged move was printed, its first `mv` ran, and `mkdir -p` failed `File exists` with the holder half moved. The warning now says, before the moves, `First move <path> aside: it is a link to nothing, named after the family, and in the way of the move. Then:`, and the moves under it are the ones that run once it is gone.
- `place` handed a `project:` holding a lone surrogate or a NUL to `git clone` as the clone's path: `UnicodeEncodeError` or `ValueError: embedded null byte`, exit 1, and the members after it never placed. The url `clone_url` builds from a `repository:` holding a NUL ended the same way. `argument_fault`, #455's rule as #644 wrote it for `make bootstrap` (a NUL, or a surrogate `os.fsencode` cannot write as one byte), is now asked of both before anything: the member is `NOT AN ARGUMENT`, a finding (`<project>: its `project:` in family.yaml holds U+D83D, a lone surrogate, which no command can be given as an argument, so nothing was cloned or fetched for this member: git was never run with it.`), the dry run says the same, and the other members are placed.
- The warning's prose printed the holder, the parent folder and the family raw on stderr (an ESC in a folder name reached the terminal). Each is now `spelled`; the first line keeps its `repr`, and the `mv` lines stay as typed (#486's question).
- `submodule_urls` read `git config --list` as text, split by `str.splitlines` and at the first `=`: a url holding NEL, U+2028, U+2029, VT, FF or U+001C to U+001E was cut there, and `[submodule "members/a=b"]` gave no url, so its member was cloned from the `repository:` guess. It now reads `git config -z --list`, records cut at NUL and each key from its value at the first newline, a section's name being all of its key between `submodule.` and the last dot: the reading `validate-family.py` makes and lane 3's #668 gives the doctor (#146).
- A `tracking_branch:` naming only a tag of the member's remote was cloned DETACHED by `git clone --branch`, under a line saying "on <name>, its tracking branch", exit 0. A line after the clone now says `<name> is not a branch of <url> but a tag, which git clone checks out DETACHED: this clone is on no branch`. The exit is unchanged, as the issue asks.

`tests/test_siblings_inputs.py` covers each line with the issue's inputs, runs the printed moves as typed in a POSIX shell, and holds the parity of the doctor's and `make siblings`' readings over one corpus (#668's names and breaks, and its Latin-1 name), skipped by name until #668's `gitmodules_listing` is in the doctor; it passes against PR #700's head. No doc sentence is made false, and no `--help` changes. The holder's header spelling U664 pins is untouched.

Closes #690

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a section name cuts no key, the two cases lane 3 asked #690's rows for (#690)

Lane 3's two comments on #690 (issuecomment-6232404184 and -6232689453) name two cases the `git config -z --list` read of `submodule_urls` already fixes on main's reading, and ask for a row each:

- `[submodule "a=b"]`, the name as lane 3 wrote it. Main's read split each `--list` line at the first `=`, so the key read as `submodule.a`, failed the three-part test and was dropped; git, the doctor and CI mount it. `a=b` joins NAMES (the url read, and the doctor's parity over the same corpus) and the dry-run row that already held `members/a=b`.
- A CR inside a section name, `[submodule "q\rx"]`, with `update = bogus`: `str.splitlines` cut that key in two, so the section was read as no section. `q\rx` joins NAMES, and a row reads the file with the CR section and with the same section named `qx`, each body holding `update = bogus`, and asserts git has each key whole by its exact name and that the two files read alike. What a reading does with a value git refuses (`git status` dies `invalid value for 'submodule.qx.update'`, with or without the CR) is lane 3's #733, so the row compares the two readings and pins no url.

Run against the old reading (M1), the new rows fail: `equals-bare`, `cr`, the CR row, the `a=b` dry run, and the doctor parity for both names.

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

Updates the family-root sibling bootstrap script to safely stage misplaced holders, validate impossible clone arguments, preserve Git-compatible .gitmodules semantics, identify detached tag checkouts, and spell diagnostic prose without altering executable commands; a broad regression suite verifies each behavior and parity with the doctor.

Sequence diagram for validated sibling cloning

sequenceDiagram
    participant S as siblings.py
    participant V as argument_fault
    participant G as git
    participant C as Clone
    S->>S: derive project and url
    S->>V: argument_fault(project)
    S->>V: argument_fault(url)
    alt invalid argument
        V-->>S: fault
        S->>S: mark NOT AN ARGUMENT
        S-->>C: skip clone or fetch
    else valid arguments
        V-->>S: None
        S->>G: git clone --branch
        G->>C: clone member
        alt tracking_branch is a tag
            G-->>S: detached checkout
            S->>S: print detached tag finding
        end
    end
Loading

Sequence diagram for Git-compatible submodule URL parsing

sequenceDiagram
    participant S as siblings.py
    participant G as git config
    participant P as submodule_urls
    S->>P: submodule_urls(root)
    P->>G: git config -z -f .gitmodules --list
    G-->>P: NUL-separated records
    P->>P: decode UTF-8 with replacement
    P->>P: split record at first newline
    P->>P: derive section from last key dot
    P->>P: retain path and url, later value wins
    P-->>S: member-to-url mapping
Loading

Flow diagram for safe holder staging

flowchart TD
    A[Misplaced holder detected] --> B{Staging name exists?}
    B -- Yes --> C[staging_path checks next .holder.n or .folder.n]
    B -- No --> D[Use base staging name]
    C --> E[Print mv commands with selected staging path]
    D --> E
    E --> F{Dangling family link exists?}
    F -- Yes --> G[Print move-aside warning]
    F -- No --> H[Print standard warning]
    G --> I[Run moves as typed]
    H --> I
Loading

File-Level Changes

Change Details Files
Align .gitmodules parsing with Git’s NUL-delimited configuration output.
  • Read git config -z --list as bytes and decode with replacement for invalid UTF-8.
  • Split records at NUL and key/value pairs at the first newline, preserving section names containing separators and control characters.
  • Track only path and url, with later values taking precedence.
templates/family-root/scripts/siblings.py
tests/test_siblings_inputs.py
Validate clone arguments before invoking Git and report invalid member rows as findings.
  • Reject NULs and unencodable lone surrogates in project: and computed URLs while preserving one-byte POSIX surrogate names.
  • Emit NOT AN ARGUMENT findings consistently in dry runs and real runs without tracebacks, while continuing with later members.
  • Report when a requested tracking name is a tag and the clone is detached.
templates/family-root/scripts/siblings.py
tests/test_siblings_inputs.py
Make parent-folder remediation safe when staging names are occupied or the family path is a dangling link.
  • Choose the first unused .holder or .folder staging path using lexists, including case-insensitive filesystem behavior.
  • Add explicit warning prose for dangling family links before printing the move commands.
  • Preserve printed commands as executable shell text while spelling paths and names in warning prose.
templates/family-root/scripts/siblings.py
tests/test_siblings_inputs.py
Add comprehensive regression and parity coverage for the sibling-placement fixes.
  • Cover occupied staging paths, dangling links, invalid arguments, escaped warning prose, Git-compatible .gitmodules edge cases, and tag-only tracking branches.
  • Execute printed moves as typed and compare sibling parsing against the shape doctor across the shared corpus.
  • Add mutant-killing assertions and preserve existing header/root-spelling and repository behavior checks.
tests/test_siblings_inputs.py

Assessment against linked issues

Issue Objective Addressed Explanation
#690 Keep the printed parent-folder moves from going astray when a staging name is already occupied, and warn about a dangling link named after the family before any move is attempted. ✅
#690 Prevent invalid project: or URL arguments from causing tracebacks, continue processing other members, and read .gitmodules values and section names without truncating them at line-boundary characters or =. ✅
#690 Spell paths and family names in warning prose, and identify when a requested tracking name is a tag that leaves the clone detached. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T11:11:32Z)

Lane 1's independent review of #805 at 25c7b14: CHANGES NEEDED

All six of #690's lines are fixed, and the -z read held against every parser input I tried. Two small things stop it being ready. First, a regression against main: a line feed in a .gitmodules url now forges a FINDING line through git's relayed clone error. The fix is one line and the test row is below. Second, SonarCloud reports an S9073 on this head. There are also two text fixes.

Setup. The head holds main bf1f099b. git merge-tree --write-tree bf1f099b 25c7b14c gives c4380bae, which is the head's own tree, so the local merge is the head itself. Every "head" figure below is measured on that tree. Every "main" figure comes from main's siblings.py copied into the same holder.

The issue's lines

#690 line main head verdict
Staging name taken (<t>/InkRouter.holder a folder) mv … <t>/InkRouter.holder; all three lines rc=0; holder at InkRouter/InkRouter/InkRouter/family.yaml mv … <t>/InkRouter.holder.1; rc=0 ×3; holder at InkRouter/InkRouter/family.yaml fixed
Dangling link <t>/InkRouter moves printed bare; pasted: rc=0, mkdir: … File exists, mv: … No such file or directory, holder left at InkRouter.holder First move <t>/InkRouter aside: it is a link to nothing, named after the family, and in the way of the move. Then: printed before the moves fixed, to the issue's direction (pasted without the move aside, it half-runs as on main)
project: with a lone surrogate or NUL UnicodeEncodeError: … surrogates not allowed / ValueError: embedded null byte, exit 1 FINDING IR\ud83dRS: its project: in family.yaml holds U+D83D, a lone surrogate, … / … U+0000, a NUL, …, exit 1, IRSS cloned fixed
url from a NUL repository: dry run: would clone https://github.com/InkRouter/IR\x00RS.git, rc=0 dry run: NOT AN ARGUMENT + finding, rc=1 fixed
Warning prose raw stderr lines with a raw ESC: [1, 3, 12] [12] (the mv line only) fixed
.gitmodules truncation and = R1 revert (main's loop): 26 tests fail pass fixed
Tag-only tracking_branch no line, cloned detached, siblings ok, rc=0 adds [IRSS] v-track is not a branch of <t>/remotes/IRSS.git but a tag, which git clone checks out DETACHED: this clone is on no branch, rc=0 fixed

Ordinary inputs, byte for byte. I compared main and head on the same holder, with the base path normalised. All four cases match: dry run (rc 0), real run (rc 0), wrong parent folder (rc 1) and its dry run (rc 1). The comparison covered stdout and stderr.

Findings, ranked

1. Should fix (regression against main): a .gitmodules url holding a line feed now forges a FINDING line.

The -z read correctly hands git the whole url, as git submodule does. git's die() replaces control characters with ? but keeps \n. The CLONE FAILED finding relays git's text raw, so a line of .gitmodules text lands at column 0 of stderr. On main the url was cut at the \n, so this path did not exist for make siblings.

To reproduce, set the IRRS url in .gitmodules to "/r/nowhere\nFINDING IRSS: forged" (the escape inside quotes) and run make siblings:

main:  FINDING IRRS: git clone exited 128:
       fatal: repository '/r/nowhere' does not exist
head:  FINDING IRRS: git clone exited 128:
       fatal: '/r/nowhere
       FINDING IRSS: forged' does not appear to be a git repository

git itself registers and uses this url: git submodule init prints Submodule 'm' (/r/nowhere⏎FINDING x: forged) registered. So the read is right and the relay is the part to fix.

A fix I ran: write the url git quotes as the spelled form the lines above already print. It is a helper, so place (already S3776 at 18 on SonarCloud) gains nothing:

def said_of_url(text: str, url: str, src: str) -> str:
    """git's own text, with the url it quotes raw written as spelled."""
    return text.replace(url, src) if src != url else text
...
                f"{who}: git clone exited {proc.returncode}:\n"
                + said_of_url(scrub((proc.stderr + proc.stdout).strip(),
                                    token), url, src))

With the fix, stderr reads fatal: '/r/nowhere\nFINDING IRSS: forged' does not appear to be a git repository, and all 49 tests in test_siblings_inputs.py pass.

The row below passes on main, fails on head, and passes with the fix (measured: 1 failed, 2 passed, the failure being [head]):

def test_a_url_holding_a_line_feed_forges_no_finding_line(family, tmp_path):
    """git's clone error quotes the url raw, line feed and all: read whole
    since #690, a `.gitmodules` url `"/r/nowhere\\nFINDING IRSS: ..."` put
    that line at column 0 of IRRS's finding."""
    holder = holder_in(family, tmp_path / NAME)
    config = holder / ".gitmodules"
    rewritten, count = re.subn(
        rb"(?m)^(\turl = ).*/IRRS\.git$",
        lambda found: found.group(1) + b'"/r/nowhere\\nFINDING IRSS: forged"',
        config.read_bytes())
    assert count == 1
    config.write_bytes(rewritten)
    result = siblings(holder)
    assert result.returncode == 1, result.stdout + result.stderr
    findings = [line for line in result.stderr.split("\n")
                if line.startswith("FINDING ")]
    assert findings == [f"FINDING {BENT}: git clone exited 128:"], result.stderr

2. Must fix (Sonar): S9073 at tests/test_siblings_inputs.py:228.

SonarCloud's analysis of PR 805 at 25c7b14c shows gate OK with 1 code smell: python:S9073 … tests/test_siblings_inputs.py 228 Split this composite assertion into separate assertions. The line is assert os.path.islink(link) and holder.is_dir(), "nothing moved". Replace it with:

        assert os.path.islink(link), "nothing moved"
        assert holder.is_dir(), "nothing moved"

3. Texts.

  • Title. "runs as typed past … a dangling link" is not what was measured. Pasted as printed with the link still there, the block half-runs as on main. The moves run as typed only after the link is moved aside. Replacement title:
    siblings.py: the printed move runs as typed past a taken staging name and names a dangling link to move aside first, a NUL or lone surrogate in project:or url is a finding,.gitmodules is read as git reads it, the warning's prose is spelled, and a tag-only branch is named (#690)
  • Body, "A value git refuses." It holds only when the index has a gitlink at the section's path. Without one, git status and git submodule status both exit 0 on the same file; I measured both, with and without the CR. Replacement:
    "Measured on this tree, a .gitmodules section holding update = bogus, at a path the index holds as a gitlink, makes git status and git submodule status die (fatal: invalid value for 'submodule.qx.update', exit 128), with or without a CR in the section's name; with no gitlink at that path, both exit 0."
  • If you take finding 1, add a bullet under the .gitmodules row:
    "A url .gitmodules gives whole may hold a line feed, which git's own clone error quotes raw; the CLONE FAILED finding writes that url spelled, so git's text puts no line of .gitmodules at column 0."

4. Optional. place's cognitive complexity goes from 18 to 24 by my count. SonarCloud already lists it OPEN on main ("from 18 to the 15 allowed", siblings.py:529), so the gate will not see it as new. Moving the argument_fault loop into a helper would stop the growth.

The -z parser, attacked (no defect)

I measured each case on git 2.43 against main's reader, the head's reader and the doctor's submodule_config:

  • Case folding. [Submodule "MiXed"] Path = … Url = … lists as submodule.MiXed.path and .url, with the subsection case kept. Head reads it, and so does the doctor.
  • A dot in the name. [submodule "A.b"] gives the name A.b. Old-style [submodule.oldstyle] is read too.
  • Duplicate key. URL = first, url = second gives second in the head, main and the doctor.
  • Bare key and empty value. A bare url lists as submodule.bare.url\0 and url = as submodule.empty.url\n\0, which confirms lane 4's statement. Both read as '', as on main.
  • Includes. [include] path = other is listed as a key and not followed (-f turns includes off), so the head reads no section from other. git's own .gitmodules reading does not follow includes either.
  • Escaped \n in a value. The head gives a\nb, main gives a, and the doctor gives a\nb.
  • NUL byte in a value (ab\0cd): git lists ab. A NUL in a subsection lists the key as submodule.n, which head and main both skip.
  • Other inputs. A raw newline in a subsection is bad config line, exit 128, so {}. A UTF-8 BOM is read. An empty file gives {}.
  • The CR case (lane 3's second post). Main gives {'members/IRRS': …}. The head gives {'members/IRRS': …, 'members/q': '/r/q.git'}. The doctor at bf1f099b gives 'q\rx': {'path': 'members/q', 'url': '/r/q.git'}. Lane 4 is right that the doctor returns the section whole. With gitlinks in the index, git dies on update = bogus with or without the CR (fatal: invalid value for 'submodule.q?x.update' and 'submodule.qx.update', both 128), so the refusal does not come from the CR.
  • The a=b case (lane 3's first post) is confirmed. With R1 (main's loop), equals-bare fails in the read row, the parity row and the dry-run row.

The printed move, attacked

  • Commands do what they say. staging_path only calls lstat and never writes. Every path in the printed commands is absolute and goes through shlex.join, so no word can start with - and quotes and spaces paste correctly. The raw ESC stays on the mv line, as family.py and siblings.py print commands with unquoted paths, so under a folder with a space the member-git-dir-cached remedy's rm -rf would delete the wrong directory, and add's push line fails #486 leaves it. git-bash takes single-quoted C:\… paths as before.
  • A case the PR fixes without saying so. With the holder inside a folder named <t>/InkRouter.holder/, main prints mv <t>/InkRouter.holder/InkRouter <t>/InkRouter.holder, which fails with are the same file; the next two lines still exit 0 and leave the holder at InkRouter/InkRouter/InkRouter. The head prints .holder.1, all three lines exit 0, and the holder ends at InkRouter/InkRouter/family.yaml. You could add a row for it.
  • No new network call. The tag check is a local rev-parse.

#775 overlap

Tests

  • Every new test fails with its change reverted. I reverted six parts myself, running the new file each time:
    • R1, main's --list/splitlines/= loop: 26 failed;
    • R2, staging_path always returns the first name: 5 failed;
    • R3, no dangling-link sentence: 1 failed;
    • R4, no tag line: 1 failed;
    • R5, line 2 of the warning unspelled: 2 failed;
    • R6, argument_fault never asked: 3 failed.
  • The asserts are honest. They are exact lines, exact moves run as typed in sh -e, and exact directory listings.
  • Sonar. Apart from :228, the PR analysis shows no other smell, and new duplication is 0.0%.
  • Windows (read, not run: the Git Bash job runs only the launcher and installer suites):
    • os.path.lexists also sees a dangling junction.
    • .holder.N names are legal on NTFS.
    • The VT, FF, FS, GS and RS rows only write .gitmodules text and create no directory with those (NTFS-illegal) characters; the NEL directory is legal and skips on OSError.
    • The ESC and symlink rows skip on nt.
    • Every file edit is done in bytes.
    • argument_fault refuses every surrogate there, which the body states.

Runs (head = merge, git-filter-repo on PATH, Python 3.12.3, git 2.43.0)

  • test_siblings_inputs.py, test_family_siblings_placement.py and test_doctor_gitmodules_not_utf8.py: 101 passed in 75 s.
  • test_family_printed_quoting.py, test_siblings_family_name.py, test_family_makefile_siblings.py, test_park_resume_targets.py and test_family.py: 415 passed in 163 s.
  • Nothing was skipped and nothing failed.

For the checklist (outside #690's lines; main and head alike)

  1. An existing <Family>/<Family>. The printed mkdir -p and mv both exit 0 and the holder lands at InkRouter/InkRouter/InkRouter/family.yaml. This is the same mv-into-a-folder defect as the staging name, at the destination. The body discloses it; check that it is on Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459.
  2. A regular file, or a link to a file, named <Family>. mkdir: … File exists, then mv: cannot stat '<t>/InkRouter/InkRouter': Not a directory. Nothing moves (disclosed).
  3. A filesystem encoding that is not UTF-8. With PYTHONUTF8=0 PYTHONCOERCECLOCALE=0 LC_ALL=C, project: "IRéRS" still ends in UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' at the clone, on head as on main. argument_fault asks os.fsencode only about surrogates; asking it about every character would cover this.
  4. A link loop named <Family>. The sentence says "a link to nothing"; it is a link to itself. The remedy is still right.
  5. The dangling link gets no command. Pasting the block without the move aside still half-moves the holder (to InkRouter.holder), as on main. A printed mv <link> <free name> as the first command would make the whole block run as typed.
  6. An empty url = for a member. make siblings runs cloning (.gitmodules) and reports CLONE FAILED (exit 128). The doctor's section_url treats "an empty url =" as no url and falls back to repository:, so the two disagree about one member (shape-doctor.py: the members row calls a beside-the-holder repository the working clone on its project id alone, where the family's own verifier also requires the origin #146). This is likely shape-doctor.py and siblings.py: a .gitmodules section git ignores ([submodule ""], a .. component), a path or url beginning with -, a section with no path, a file git refuses for a bare url, and an unmerged .gitmodules name no mount url, NEXT line or clone, where git says No url found and exits 128 (#733) #775's (shape-doctor.py and siblings.py: a .gitmodules section git ignores ([submodule ""], a name with a .. component, a url beginning with -) is still read as a member, where git says No url found and exits 128 #733) area.
  7. Other places git's text is relayed raw. make bootstrap's FETCH INCOMPLETE relays git submodule update's text raw, so the forged line from finding 1 already reaches it on main through git's own reading. The fetch failed relay in place does the same with an origin that holds a line feed.
  8. Doc drift in shape-doctor.py:7856. It names "siblings.py::submodule_urls's errors="replace""; at this head the function uses .decode("utf-8", "replace"). The meaning is unchanged and the file is not in this PR, so it can wait for the next doctor edit (shape-doctor.py and siblings.py: a .gitmodules section git ignores ([submodule ""], a .. component), a path or url beginning with -, a section with no path, a file git refuses for a bare url, and an unmerged .gitmodules name no mount url, NEXT line or clone, where git says No url found and exits 128 (#733) #775 touches it).

Lane: openreposhape-1 (openRepoShape-1)

…ritten spelled in the clone and fetch failures that relay git's text, so it forges no FINDING line (#690)

Lane 1's review of #805 at 25c7b14 found a regression the whole-url `-z` read made reachable. git's `die` and `error` quote a url RAW: a line feed stays and every other control becomes a `?`. A `.gitmodules` url holding a line feed was cut at it by main's reading, so the clone of `/r/nowhere` failed in one line; read whole, the clone of `/r/nowhere<LF>FINDING IRSS: forged` fails in two, and the second stood at column 0 of IRRS's CLONE FAILED finding as a FINDING line of its own.

`said_of_url` writes the url git quotes as `spelled` writes it, the form every other line of `place` already prints. It looks for the url as given and as git quotes it (controls as `?`), because a url holding an ESC beside the line feed is quoted in the second form and the reviewer's raw `replace` alone leaves that forged line standing (measured: `line-feed-and-esc` fails with the raw form only). It is a helper, so `place` gains no branch.

The other relay of git's text in `place` is the failed fetch's, which quotes the clone's `origin`: a clone made from such a url (a folder named `x<LF>FINDING IRSS: ...` holds one only since the whole-url read) has it for its origin, and its fetch error forged the same line (measured before the change). It goes through the same helper with `git remote get-url origin`. `verify_sibling`, the `cloning` and `would clone` lines and the findings already spell what they name; `run_member_bootstrap` and `--make` inherit the member's stdout and relay nothing of git's. `bootstrap.py`'s FETCH INCOMPLETE relays `git submodule update`'s text raw too, on main as here; it is not in this PR's file and stays for #459.

tests/test_siblings_inputs.py: `test_a_url_holding_a_line_feed_forges_no_finding_line` (the reviewer's row, with an ESC variant) passes on main, fails on 25c7b14, and passes with the change; `test_the_clone_failure_names_a_line_feed_url_spelled` pins the spelled url in the same text; `test_a_failed_fetch_forges_no_finding_line` runs a real fetch from a folder whose name holds the line feed. The composite assert at :228 that SonarCloud reports as S9073 is two asserts.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap brettheap changed the title siblings.py: the printed move runs as typed past a taken staging name or dangling link, a NUL or lone surrogate in project: or url is a finding, .gitmodules is read as git reads it, the warning's prose is spelled, and a tag-only branch is named (#690) siblings.py: the printed move runs as typed past a taken staging name and names a dangling link to move aside first, a NUL or lone surrogate in project: or url is a finding, .gitmodules is read as git reads it, the warning's prose is spelled, and a tag-only branch is named (#690) Oct 17, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T11:46:08Z)

Lane 1's re-verification of #805 at 5ba1f8b: CHANGES NEEDED

Findings 2 and 3 are fixed. Finding 1 is fixed for every url git quotes in full, but a url longer than git's message buffer gets past said_of_url, and measured end to end it still forges a FINDING line. That case is still a regression against main, which cut the url at the line feed. A five-line fallback fixes it; it is below, with a test row.

Merges. Main moved twice during this check. Head + 1eeef3b6 merges clean (tree f2fcd805). Head + dcf62eda, the newest main, also merges clean (tree 8be82284). dcf62eda brings #648's bootstrap.py, which siblings.py imports from, so I ran the siblings files again on that merge. #775 at 3fa5beed still conflicts only in siblings.py.

Item Measured on the local merge Verdict
F1: url "/r/nowhere\nFINDING IRSS: forged" (my repro, your [line-feed] row) the only FINDING line is FINDING IRRS: git clone exited 128: fixed
F1: line feed and ESC, failed fetch, spelled-url rows pass. Reverting the helper (M5: always return the text) gives 5 failed: both forging rows, both spelled rows, the fetch row fixed; the rows fail without the fix
F1: a url longer than git's 4096-byte die buffer .gitmodules url "/r/nowhere\nFINDING IRSS: forged/" + 30 × (200 a's), joined by "/" (6061 characters): stderr FINDING lines are ['FINDING IRRS: git clone exited 128:', 'FINDING IRSS: forged/aaaa…']. The same url with 10 parts (2041 characters) forges nothing NOT fixed (new)
F1: can a replacement hit text that is not the url? Yes, when the raw or ? form is short. Url \x1b (? form ?) turns git's 'IR?RS' into 'IR\x1bRS'. Url a\x01 turns data? into data\x01. Url \n turns every line feed of git's text into \n, putting it on one line cosmetic only: the replacement writes escapes and never adds a raw control or a line, so nothing is forged. Optional: replace only the quoted forms '<form>'
F1: a raw control from another relayed line git turns every control except tab and line feed into ?. A line feed comes through raw from a url the helper is never given: the member's own leg url, relayed by git clone --recurse-submodules (rc=1): fatal: clone of '/r/nowhere then FINDING IRSS: nested' into submodule path '…/leg' failed same on main (git reads that url itself); for the checklist
F2: S9073 test_siblings_inputs.py:228-229 is now two asserts fixed
F3: title and the gitlink sentence both match the replacement text word for word fixed
SonarCloud at 5ba1f8b3 (analysed 11:36:50Z) gate OK; 0 bugs, 0 vulnerabilities, 0 code smells; new duplication 0.0% ok
Runs, with git-filter-repo on PATH merge with 1eeef3b6: 531 passed, 0 skipped. Files: test_siblings_inputs.py, test_family_siblings_placement.py, test_doctor_gitmodules_not_utf8.py, test_family_printed_quoting.py, test_siblings_family_name.py, test_family_makefile_siblings.py, test_park_resume_targets.py, test_family.py and #706's test_pins_child_kind_and_siblings_root_spelling.py. Merge with dcf62eda: 358 passed over the six siblings files ok

Why the long url gets through. git formats die into char msg[4096], so a long url is cut short in git's text. The text then holds neither the raw url nor its ? form, and nothing is replaced. To reproduce, add the row below next to your FORGED_URLS rows; it uses your siblings_over_url. It fails at 5ba1f8b3 (the forged line) and passes with the fallback.

def test_a_url_longer_than_gits_message_forges_no_finding_line(family,
                                                               tmp_path):
    """git writes a `die` message into 4096 bytes, so a url longer than that
    is quoted cut short, in neither form `said_of_url` looks for."""
    url = (b'"/r/nowhere\\nFINDING ' + KEPT.encode() + b': forged/'
           + b"/".join([b"a" * 200] * 30) + b'"')
    result = siblings_over_url(family, tmp_path, url)
    assert result.returncode == 1, result.stdout + result.stderr
    findings = [line for line in result.stderr.split("\n")
                if line.startswith("FINDING ")]
    assert findings == [f"FINDING {BENT}: git clone exited 128:"], \
        result.stderr

The fallback I ran. It goes in said_of_url, after the quoted = line:

    if url not in text and quoted not in text:
        # git cuts a message at 4096 bytes, so a long url is quoted in
        # neither form: each line is spelled, and none after the first
        # starts a line of this output.
        return "\n    ".join(spelled(line) for line in text.split("\n"))

With it, the 6061-character url forges nothing (['FINDING IRRS: git clone exited 128:']), and test_siblings_inputs.py plus the long-url probe gave 56 passed. It only takes effect when the url holds something spelled changes and git's text does not quote it in full, so output for ordinary failures does not change.

Texts to fix at the head.

For the checklist (not this PR). git clone --recurse-submodules relays git's text for a member's own leg urls raw, so a line feed in a leg url forges a line in the CLONE FAILED finding on main as well. Indenting every relayed git line after the first would close this and the long-url case together. make bootstrap's FETCH INCOMPLETE relay, raised in the first review, is the same class.

Lane: openreposhape-1 (openRepoShape-1)

… in neither form, has the text that relays it spelled line by line, so it forges no FINDING line (#690)

Lane 1's re-verification of #805 at 5ba1f8b: git formats a `die` message into 4096 bytes, so a url longer than that is quoted cut short, and the text holds neither the url as given nor its `?` form, the two `said_of_url` looks for. Nothing was replaced, and a `.gitmodules` url `"/r/nowhere<LF>FINDING IRSS: forged/" + 30 parts of 200 characters` (6061 characters) still put the forged line at column 0 of IRRS's CLONE FAILED finding, where main, which cut the url at the line feed, had none.

Where the text holds neither form, `said_of_url` now spells each of its lines and joins them with a newline and four spaces, so no line after the first can begin a FINDING line. The reviewer's five lines, as given. It takes effect only for a url that `spelled` changes and that git's text does not quote in full, so an ordinary failure's text is not changed.

tests/test_siblings_inputs.py: `test_a_url_longer_than_gits_message_forges_no_finding_line`, the reviewer's row, next to the `FORGED_URLS` rows. It fails at 5ba1f8b and passes with the fallback; with the fallback removed (M9) it is the one row that fails.

Left for #459: `git clone --recurse-submodules` relays git's text for a member's own leg urls raw, on main as here, and so does `make bootstrap`'s FETCH INCOMPLETE.

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:59:40Z)

Lane 1's re-verification of #805 at dc1c8e4: PR-READY

The long-url fallback closes the last way a line feed in a url could forge a FINDING line, ordinary failures print exactly what 5ba1f8b3 printed, the body's texts are true, and the tests and SonarCloud are clean.

I checked against main 20004887, which ls-remote still showed as main after the runs; the PR head was still dc1c8e45. Head and main merge clean, tree b2cdfeb6, and I measured on a local commit of that merge.

Check Measured on the local merge Verdict
Long url, end to end ("/r/nowhere\nFINDING IRSS: forged/" + 30 parts of 200 a's, 6061 characters) FINDING lines: ['FINDING IRRS: git clone exited 128:']. The forged text sits indented as FINDING IRSS: forged/aaaa… fixed
10-part control (2041 characters) FINDING lines: ['FINDING IRRS: git clone exited 128:']. git quotes the url in full, so the replacement path runs: fatal: '/r/nowhere\nFINDING IRSS: forged/aaaa… unchanged, as intended
Fallback removed (your M9) 1 failed (test_a_url_longer_than_gits_message_forges_no_finding_line), 54 passed the row catches it
Ordinary failures, byte for byte, 5ba1f8b3 against dc1c8e45 (same holder, base path normalised) Remote that does not exist: identical, rc 1/1, stderr 167/167 bytes (fatal: repository '/r/nowhere/IRRS.git' does not exist). Missing branch: identical, rc 1/1, 177/177 (fatal: Remote branch no-such-branch not found in upstream origin). Failed fetch: identical, rc 0/0, 242/242 (fatal: '<B>/own/IRRS.git' does not appear to be a git repository). Short line-feed url: identical, 319/319 no change
Body texts The "Git's text, relayed" bullet ends with the long-url sentence. The main row reads 20004887, clean, tree b2cdfeb6, which matches my merge. The #706 bullet reads "on main since #706 (bb439a29)". The gitlink sentence is as asked. M9 is listed true at the head
SonarCloud at dc1c8e45 (analysed 11:52:55Z) gate OK; 0 bugs, 0 vulnerabilities, 0 code smells, 0 open issues; new duplication 0.0% ok
Siblings test files, run once with git-filter-repo on PATH 382 passed in 158 s, 0 skipped, 0 failed. Files: test_siblings_inputs.py, test_family_siblings_placement.py, test_family_makefile_siblings.py, test_siblings_family_name.py, test_family_printed_quoting.py, test_pins_child_kind_and_siblings_root_spelling.py, test_doctor_gitmodules_not_utf8.py ok

Still open, and not blocking this PR:

  • Leg urls relayed raw. git clone --recurse-submodules relays a member's own leg url, line feed included, without escaping it. Main does the same, and make bootstrap's FETCH INCOMPLETE relay belongs to the same class. Both are for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459's checklist.
  • Short urls (optional item 2, skipped). When a url is very short, the replacement can also rewrite text in git's message that is not the url. It only ever writes escapes, so it forges nothing.

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

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 40e678e into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/690-siblings-inputs branch October 17, 2026 15:52
brettheap pushed a commit that referenced this pull request Oct 17, 2026
#805 (#690) rewrote `siblings.py::submodule_urls` to read `git config -z
--list` bytes, decoded with "replace", records cut at NUL and each record
split at its FIRST newline, a section's name being all of its key between
`submodule.` and the last dot. #775 (#733) applied its rules in the old
`splitlines` + first `=` loop. They conflicted in that function's
docstring and its loop, and nowhere else.

The loop is #805's, with #775's rules carried into it in the order the
doctor's `submodule_keys` applies them to the same listing:

- an empty name, or one with a `..` component (`DOTDOT_COMPONENT`),
  skips the section;
- a key of `VALUED_KEYS` given with no value at all, or a value
  `git_dies_on`, refuses the whole file (`{}`). In `-z` output a bare key
  is a record with NO newline (`submodule.x.url\0`, measured on git
  2.43) and `url =` is one with a newline and an empty value, so
  "valued" is whether `record.partition("\n")` found its separator;
- a `path` or `url` beginning with `-` is ignored, that key alone;
- a url is keyed by its section's `path` alone (#775's return line,
  which merged cleanly).

Everything else is #805's: the later value wins, a name with a space,
dot, quote or `=` is read whole, and a byte that is not UTF-8 is
replaced (#432). The docstring keeps both paragraphs, #690's on how the
listing is read and then #733's on which of it git reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…ames that differ only in bytes that are not UTF-8 stay two sections, as git and the doctor read them, and an empty `ignore =` or `branch =` and an `update = -x` are pinned (#733, review round 4)

RV775r4's review of round 4 (`32048c4f`, #775 issuecomment-6237014956),
its two SHOULDs and three NITs:

- SHOULD 1: `submodule_urls` decoded the whole `-z` listing with
  "replace", so `[submodule "caf\xe9"]` and `[submodule "caf\xe8"]` (or
  `caf` + the UTF-8 of U+FFFD) were one section: it gave `q` alone, and
  with the path in one and the url in the other a url git does not mount.
  The listing is now decoded with "surrogateescape", as the doctor's
  `gitmodules_listing` decodes it (#732), and the VALUE alone is
  re-decoded with "replace" (#432). New rows beside #805's parity test,
  `test_two_names_apart_only_in_bytes_not_utf8_stay_two_sections`, check
  the doctor, `make siblings` and `git submodule init` for all three.
- SHOULD 2: `GIT_SKIPS` gains `empty-ignore` and `empty-branch` controls:
  git takes `ignore =` and `branch =`, so `valued` is the newline, not the
  value.
- NIT 1 and NIT 2: the docstring says a record is the key and, where it
  is given a value, a newline and the value, and that git may say the
  value is invalid.
- NIT 3: `GIT_SKIPS` gains `update-dash` (`update = -x`, which git dies
  on), so the `-` skip stays after the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained #713 (#645), #812 (#693), #767 (#685), #745 (#704, #694)
and #805 (#690) since 2000488. 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 1475 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
…notes entries for the five pull requests landed since 2000488, current to main 40e678e (#563, #683)

RV630's review of rounds A3, A4 and B1 (#630 issuecomment-6233641684,
READY), its four optional nits:
- 1: README.md's drift paragraph ended on the one-word line "finished.".
  "exactly " and "about the shape " are cut, and the paragraph re-wraps
  to seven lines with both bold leads whole. README.md is 1474 lines, its
  cap (1422 + 2 + 5 + 12 + 22 + 10 + 1); tests/readme-cap.d/683.txt is
  +1, and its text says why. Both `Keeping a project's shape current`
  markers move to df780eb5; the other 21 read current.
- 2: #682's lead reads "A holder's `make bootstrap` that left a member
  unchecked-out".
- 3: #730's entry says "Comments and docstrings", not "Four": its squash
  makes three claims, comments only.
- 4: the doctor's `members` row has one bullet, #705's sentence and
  #624's together.

Wave B2 so far, `git log --first-parent 2000488..40e678e`, each entry
from its squash diff:
- #713 (#645): the test session turns git's automatic maintenance off
  (Install, CI and the docs).
- #812 (#693): `add --local-remote-dir`'s member clone URL, the remedies
  and the READMEs' quoted clone URL (the `--local-remote-dir` bullet).
- #767 (#685): `members/` renamed with `git mv` is renamed back in two
  steps, and the git a printed `--sparse` needs (family).
- #745 (#704, #694): lane 5's line (#630 issuecomment-6227129879), with
  v1.0.0's known limit at v1.0.0.md:463 (no git on `PATH`) said closed
  (Bootstrap).
- #805 (#690): `make siblings`'s inputs (family).
The counts (111 pull requests; 122 of 135 milestone issues closed at
16:14Z), "current to main" and the open list (13, each with a pull
request open) are at main 40e678e, still placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…anded, among it #805's `-z` read of `.gitmodules` in siblings.py (#786)

A clean merge. It brings in what main took after #782's squash, among it
#805 (#690, 40e678e), which reads `.gitmodules` in the family template's
siblings.py with `git config -z --list`. README.md stays 1475 lines, at
the cap of 1475 that tests/readme-cap.d/ gives with this branch's 786.txt
(+1); AGENTS.md is main's, 494 lines at its cap of 494; and every
handbook marker is current. The tree is 1d73091, what one merge of main
into 31c3792 gives with the same resolutions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…ask the row whose section name holds a carriage return, since `siblings.py` has read `.gitmodules` with `-z` since #805 (#690) (#786)

`test_siblings_reads_what_each_row_says` left out every row holding a CR,
because `siblings.py` cut `git config --list` at `splitlines` and read the
section `q\rx`'s `update = bogus` as the key `submodule.q` and `x.update`.
#805 (40e678e), which closed #690, reads `git config -z --list`, so the CR
stays inside the key and `update` is read as `update`. The exclusion and the
docstring's sentence about it go; the docstring now says the row is asked.

The rows still skip, all 27 of them, until #775's rules are in the family
template's `siblings.py` on main: the `siblings` fixture skips where
`DOTDOT_COMPONENT` is missing, and that skip stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
… `--into` sentence names the `family-landing-in-standard` refusal, `make siblings` is described as it stops before cloning, and `members/` has its one-spelling rule (#695) (#738)

The family docs now say what `family.py init`, `make siblings` and the `members/` directory do since #488, #578 and #574. Base: `8062810d`. Head: `c42286de`: the work (`73fa3ff4`) and one commit for the review's nit on L20.

**The branch now holds `origin/main` at `24a977c0`, through one merge commit, `75e35f14`.** No other commit was added, and nothing was rebased or amended.

### What failed

Since #682 landed (`dc7d3fbb`), `git merge-tree --write-tree HEAD origin/main` against `c42286de` stopped on one line of `docs/handbook.html`:

    Auto-merging docs/handbook.html
    CONFLICT (content): Merge conflict in docs/handbook.html

It is the `families` section's marker, `<!-- reads README.md § Families: ... blob <hash> -->`. #682 changed a sentence of README § Families, so main's marker is `b3d19092...`. This PR changed other sentences of the same section, so its marker is `0364c579...`. Git saw one line changed two ways. Lane 2's note on #738 (2026-10-17T12:47:17Z) named the same line. I reproduced the conflict on the head I started from, then merged by hand.

### The fix

The merged README § Families is neither side's text, so the marker takes its fingerprint, `82d7f8087b9ee4d097d33e15fbfe5ae754a04a79`. I computed it with `tests/test_repo_hygiene.py`'s own `readme_fingerprint` over the merged `README.md`. It is the value lane 2's note gave, and it did not move when main moved on to `24a977c0`.

| marker value | where it came from | handbook outline test |
|---|---|---|
| `b3d19092...` | main's side | fails: "has moved since the page read it" |
| `0364c579...` | this PR's side | fails, same message |
| `82d7f808...` | the merged README § Families | passes |

I re-read both sides before taking the value:

- Main's side brought one sentence into README § Families (line 955, "`make bootstrap` reports and skips one it lacks a credential for."), and #682 had already put the matching sentence on the page.
- This PR's side is its own 55 README lines and 27 handbook lines in that section, which merged clean.
- Both sides' sentences stand in the merged README and in the page.

### Did the merge change README or handbook text beyond the conflict line?

No. I compared each changed file's diff against the parent that did not make it:

- `README.md`: the merge against `origin/main` shows exactly this PR's own change, and the merge against `c42286de` shows exactly main's own change since `8062810d` (one line, #682's). No line differs.
- `AGENTS.md`: the same two comparisons, no line differs.
- `docs/handbook.html`: the same two comparisons differ in the marker line only, in both directions.
- README's handbook markers (23 on the page) and README's line cap are untouched. `tests/test_repo_hygiene.py` passes with the cap and the markers as they are.

This PR still lands only in lane 3's README turn. If another README PR changes § Families first, the marker moves again, and the hygiene test names it.

### Tests

All on `75e35f14`, through the gh stub, with a bogus token and an empty `GH_CONFIG_DIR`.

| run | files | result |
|---|---|---|
| core | `test_readme_sections.py`, `test_family_docs_one_spelling.py` (the one test file this PR changes), `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` | 240 passed |
| handbook readers | `test_readme_windows_quoting_sentence.py`, `test_setup_by_hand_commands.py`, `test_update_add_codes_documented.py` | 20 passed |
| family-root templates | `test_pinned_copy_text.py`, `test_template_values_single_pass.py`, `test_family_init_values.py`, `test_family_init_landing.py`, `test_siblings_family_name.py`, `test_family_makefile_siblings.py`, `test_family_siblings_placement.py` | 582 passed, 19 skipped |
| bootstrap (main's new files, over the template this PR reworded) | `test_bootstrap_spelling.py`, `test_bootstrap_without_git.py`, `test_bootstrap_leg_moves.py` | 251 passed |

### Mutants

This round adds no test and no code, so its one hunk is the marker value. Each mutant changes that value in a shared scratch clone at `75e35f14` and runs `tests/test_repo_hygiene.py::test_the_handbook_follows_the_readmes_outline`:

| mutant | result |
|---|---|
| none (control) | 1 passed |
| main's marker `b3d19092...` | 1 failed |
| this PR's marker `0364c579...` | 1 failed |
| the right marker with its last digit changed | 1 failed |
| the unresolved conflict block (`<<<<<<<`, both lines, `>>>>>>>`) | 1 failed |

### Merge checks

- `git merge-tree --write-tree HEAD origin/main` is clean at `24a977c0`.
- Main moved to `40e678e6` (#805, `templates/family-root/scripts/siblings.py` and `tests/test_siblings_inputs.py`) after this merge. It touches no README, handbook or AGENTS text, and the merge-tree against it is clean.
- Lane 2's #822 is stacked on this PR. Its reviewer found it merges clean with this PR landed at the same marker value.

1. **The `--into` sentence** (README.md § Families and the handbook). It gave the default, "the directory you are standing in", and not that `init` refuses that default inside a checkout of this standard. It now says so and names the code: `(the directory you are standing in, by default; refused inside a checkout of this standard, \`family-landing-in-standard\`)`. AGENTS.md already said it.
2. **The `make siblings` paragraph** (README.md § Families and the handbook). It said `make siblings` "WARNS and prints the exact `mv`". Since #578 it also clones nothing until the `mv` is run, so the next run clones each member once, and a link of the family's name beside the folder passes. It now says that. Lane 3's two #460 sentences ride in it (L20, L21, which lane 3 marked `-> #695`):
   - the folder must be named EXACTLY after its repository, case included, on every disk;
   - for a holder at `~/projects/InkRouter` the move makes the family folder `~/InkRouter`, beside `~/projects`, and the holder `~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`.
   I wrote L20 with the paths spelled out. Lane 3's wording was "beside that folder's parent", which two readings can take. My first wording, "beside that folder, not inside it", still left one: the thing beside `~/projects` is the family folder, not the holder (rv695 N1). The printed move for that holder is `mv ~/projects/InkRouter ~/InkRouter.holder`, `mkdir -p ~/InkRouter`, `mv ~/InkRouter.holder ~/InkRouter/InkRouter` (probe below, and `test_family_siblings_placement.py` pins it).
3. **The one-spelling rule for `members/`.** One sentence, the same words in five places: AGENTS.md (family item 2), README.md § Families, the handbook, the holder's README and the holder's `AGENTS-shape.md`:
   > A holder keeps `members/` in ONE spelling on every disk: `add` refuses `family-members-dir-variant` (`member-path-taken` for a variant of the member's own path) until a human renames what sits under another spelling.

`templates/family-root/AGENTS-shape.md` changes by that one sentence. It is in `FAMILY_COPIED_VERBATIM`, so every holder reads `upstream-changed` for it on its next `update-shape.py check`. The issue's third line names it, and lane 3 asked that it ride with the next pinned-file edit. It can share the one v1.1.0 shape bump with the bootstrap fix. `templates/family-root/README.md` is templated and not pinned: an existing holder keeps its own README.

- README.md is 1458 lines, one under its cap of 1459 (1422 + 5 + 22 + 10 in `tests/readme-cap.d/`); it was 1459 at `73fa3ff4`. The brief said 1449; that is the cap before #627 landed. AGENTS.md is 492, its cap.
- The new words come to about nine README lines and three AGENTS.md lines. I fitted them by cutting wording that is stated elsewhere. **If you would rather keep the cut sentences, the alternative is a `tests/readme-cap.d/695.txt` of about +8, which needs Brett's word.** What I cut:
  - README § Families:
    - the aside "the same lands-where-you-were-standing rule projects have";
    - four rewordings that lose no meaning:
      - the intro of "Use a family when the parts deploy separately": "folding them into one assembly root would give them one gate, one release and one pin, which is the opposite of what 'deploy separately' means" is now "one assembly root would give them one gate, one release and one pin, the opposite of 'deploy separately'";
      - "`--work-dir` remains the override" is now "`--work-dir` is the override";
      - "The doubled name is kept on purpose" is now "The doubled name is on purpose";
      - "`family.yaml` is what tells the two apart" is now "`family.yaml` tells the two apart";
    - the caption "Eight services, each its own project, one holder:" under "The InkRouter example" (the section's intro and the block under it say it; this is the one line cut to make room for the L20 rewording, and the handbook keeps its caption);
    - the tool's rationale after "Nothing ever moves a checkout" (the shell, the editor, the agent lanes, "an act by surprise"); it stays in the handbook, the holder's README and the tool's own output, and README keeps the reason that matters (a linked worktree's `.git` file carries an absolute path);
    - ", not merely a repository at the right commit";
    - the private-legs reason in the `make bootstrap` paragraph;
    - "to a policy two live projects already carry a copy of" in the naming paragraph.
  - AGENTS.md:
    - the converse in the family intro ("one project is what parts that ship together already are"; README keeps it);
    - the sentence "`bump` takes the same repeatable `--trailer`" in family item 3 (stated in "Advancing a leg" step 6 and in the holder's `AGENTS-shape.md`);
    - "Do not pass `--yes` on your own initiative" in "Updating" step 2, now "Never pass `--yes` yourself", the words `test_repo_hygiene.py` requires elsewhere in the file.
- The handbook marker for README § Families is recomputed with `readme_segments` / `readme_fingerprint`: `89ef0ab5ec88...` to `0364c5794c46...` (it was `dc85a9260a67...` at `73fa3ff4`, before the L20 rewording and the caption cut). No other README section changed, so no other marker moves.

New file `tests/test_family_docs_one_spelling.py`, 17 tests: the one-spelling sentence in each of the five docs, the two codes held as strings by `scripts/family.py`, and the `--into`, `make siblings`, "named EXACTLY" and "straight in a folder" sentences in README and the handbook, plus the old "WARNS and prints" sentence gone from both. The handbook is read as the text a browser shows (`<code>` as backticks).

- On base `8062810d` (the file copied in): **15 failed, 2 passed**. The two that pass are the code-exists checks.
- On the head `c42286de`, after the L20 rewording: `test_repo_hygiene.py`, `test_readme_sections.py`, `test_agents_md_codes.py` and the new file: **217 passed** in 3.37s. The new file's `STRAIGHT_IN_A_FOLDER` is in the new README and not in the old one.
- Before that, the targeted runs, each at `8062810d` plus the working tree that was then the head (`73fa3ff4`):
  - `test_repo_hygiene.py`, `test_readme_sections.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_readme_windows_quoting_sentence.py`, `test_setup_by_hand_commands.py`, `test_update_add_codes_documented.py` and the new file: **253 passed**.
  - Fifteen files that read AGENTS.md, the holder's docs or the refusals (`test_family_init_landing`, `test_siblings_family_name`, `test_family_switch_branch_exists`, `test_family_tracking_branch`, `test_template_text_placeholders`, `test_template_values_single_pass`, `test_template_quoted_values`, `test_free_plan_hint`, `test_doctor_recorded_answers`, `test_update_shape_add`, `test_update_shape_header_comments`, `test_family_add_refusals`, `test_family_members_case_variants`, `test_family_member_path`, `test_doctor_legs_members_next`): **987 passed**.
  - Eight files: `test_adopt_plan`, `test_adopt_github_exits`, `test_adopt_late_failure`, `test_setup_project_py`, `test_family`, `test_family_siblings_placement`, `test_family_makefile_siblings`, `test_park_resume_targets`: **598 passed, 1 skipped** (`SHAPE_MEDXEHR_CLONE` unset: a plan test needs a read-only clone of MedxSoft/MedxEHR, a cloud tool).
  - `test_update_shape.py -k "shape_line or stale_readme or check_says_what_apply or readme_with_no_shape"`: 7 passed (the tests that read the holder README's last line). The rest of `test_update_shape.py` and `test_shape_doctor.py` were not run: they copy the family-root tree and match none of the lines I changed (`git grep` of the changed sentences under `tests/` finds nothing), and a first run of them crawled under a load of 90-100 on the machine.
- The full suite was not run, as the brief says.

- Row 1, on base: from a checkout of the standard, `python3 scripts/family.py init --org Acme --family Orbit --dry-run` ends `REFUSED family-landing-in-standard ... Remediation: pass \`--into ..\``. The README sentence did not say it would; it now does.
- Row 2, on head: `parent_folder_warning` of the holder's `siblings.py` for a holder at `<t>/home/projects/InkRouter` prints `NOTHING WAS CLONED` and the move `mv .../projects/InkRouter .../InkRouter.holder`, `mkdir -p .../InkRouter`, `mv .../InkRouter.holder .../InkRouter/InkRouter`. For a folder named `inkrouter` on Linux it stops the same way: the name test is exact on every disk.
- Row 3: the refusals the sentence names are the ones `test_family_members_case_variants.py` and `test_family_add_refusals.py` drive (60 + 65 tests, passed above).
- No row needs real GitHub or a cloud tool.

My own run did not happen: a usage checkpoint stopped it before it began. The reviewer (rv695) ran 30 mutants against `73fa3ff4` in a throwaway clone and **all 30 were killed**: five places, four mutants each for the one-spelling sentence, and five each for the README and the handbook sentences. The L20 sentence has changed since, and `STRAIGHT_IN_A_FOLDER` with it; no mutant was run on the new words. What the guards cover there: the sentence has a parametrized case per doc, so reverting it fails that case, and a reverted README hunk leaves the handbook marker stale.

- origin/main `55c6ba7d` (it moved past `2fac9790` by #700): clean. rv695 found `73fa3ff4` clean against `2fac9790`.
- #630 (lane 3), head `6fd33a93`: clean. It merged main `de926142`, and its handbook conflicts at the three markers are gone. On the merged tree README.md is 1472 lines and AGENTS.md 494 (counted here; rv695 found them under #630's own ledger at `73fa3ff4`).
- #682 (lane 5), head `8b39cd19`: conflicts at one line, the handbook marker for README § Families (`0364c5794c46...` here, `c40baa828dca...` there). README.md and the rest of the handbook merge clean, and the merged paragraph reads correctly, because both PRs change that section. Whichever lands second recomputes the marker from the merged README: on this head plus #682 the recomputed value is `da54c8ec2665...` (computed with `readme_fingerprint`, not test-run).
- I dropped the earlier list of clean PRs: it was taken against older heads, and several of them have landed.

(Brett Heap, 2026-10-16T13:36:23Z, on #459 line 141: "document it in the v1.1.0 release notes". `docs/release-notes/v1.1.0.md` exists only on #630, so this PR does not create it.)

> A holder made with `--family X --name Y` before #642 (the fix for #629) keeps `cd Y` in its own README, and `update-shape.py apply` never re-renders a README, so change that one line by hand to `cd X`: the holder's directory is named after its repository, `X`, never after the display name.

Such a README also shows `Y` in its layout block and its doubled-name sentence, as prose; only the `cd` line fails when run.

- The holder's README does not get lane 3's L20 and L21: lane 3 asked for them in the README paragraph, and the holder's README already names the folder after the repository.
- Checklist candidate, not for this PR (rv695 N6): the holder's own docs say the folder is "named after the family" (`templates/family-root/README.md:73`, `templates/family-root/AGENTS-shape.md:99`) and not "EXACTLY, case included", which the standard's README and handbook now say and `siblings.py` tests (`folder.name == family`). Lane 3 asked for L21 only in the standard's README paragraph, so it is not owed here; it could ride in the same re-pin.

Review: lane 5's independent review approved c42286d with its nits fixed; 75e35f1 is its update onto main (lane 2's note, issuecomment-6234057822); Codex found no major issues. CI (Brett Heap's ruling 27): H0 is the head 0b26ace itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 2e476a7; and the local gate passed on its predicted squash tree 68dd413 over main 112eeed: the family_docs_one_spelling, readme_sections, readme_windows_quoting_sentence, setup_by_hand_commands, update_add_codes_documented, family_tracking_branch, family_switch_branch_exists, pinned_copy_text, repo_hygiene, agents_md_codes, cli_reference, family sets, 527 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 #695

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
…-reading steps read `.gitmodules` by the rules the doctor and `siblings.py` read it by, so a section git ignores, a section with no path, a `path` or `url` beginning with `-`, an unmerged file or a file git refuses whole no longer puts a repository in the App token or the lists, and a refused file fails the step with its cause (#786) (#790)

Closes #786.

The two `validate.yml` templates, assembly root and family root, read every
`.url` key of `.gitmodules` at three sites each: the step that names the
repositories the App token is minted for, the fetch step's list of urls, and
the failing step's list. So CI named repositories git never fetches, and where
the App could not reach one, the mint step failed the job.

Now all three sites read the file by the rules the doctor and `siblings.py`
use (#733): only a `submodule.?*.url` key counts; a section named with a `..`
component, or with no `path`, is skipped; a `path` or `url` value beginning
with `-` is ignored, that value alone, so an earlier one stands; and nothing
is read while `.gitmodules` is unmerged. A file git refuses whole fails the
names step and the fetch step with an `::error::` that names the cause, the
key or git's own `fatal:` line, whatever credential is configured. Before, the
fetch step with no credential degraded to green. The refusal comes before the
App advice, which is the wrong advice for that file. The printing steps list a
key once.

`tests/test_validate_workflow_gitmodules_urls.py` holds a row for each rule,
checked against `git submodule init`, the doctor and `siblings.py`. README.md,
the handbook and both template READMEs now say a `.gitmodules` git refuses
fails the run whatever the credential.

After the merge of main `2e476a77`, which brings #805's `-z` read of
`.gitmodules` into `siblings.py`, `e91b1a5d` drops the #690 exclusion of the
row whose section name holds a carriage return from
`test_siblings_reads_what_each_row_says`, so that row is asked too. All 27
`siblings` rows skip until #775 lands: the `siblings` fixture skips where
the family template's `siblings.py` has no `DOTDOT_COMPONENT`, and main's
has none.

Lane 3's independent reviewer RV-c (opus) found it READY in its second round
(#790 issuecomment-6232685791). Lane 3 checked the test-only NIT round after
it, and RV-c's READY stood (#790 issuecomment-6232735343). RV790n found the
merge round READY (#790 issuecomment-6243554860). It lands on Brett
Heap's word "merge lane 3's v1.1.1 drafts after the v1.1.0 tag"
(2026-10-17T13:00:57Z).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…(`[submodule ""]`, a `..` component), a `path` or `url` beginning with `-`, a section with no `path`, a file git refuses for a bare `path`, `url`, `ignore`, `update` or `branch` key or for a value it dies on (`update = !cmd`), and an unmerged `.gitmodules` name no mount url, NEXT line or clone, where git says `No url found` or refuses the file and exits 128 (#733) (#775)

Closes #733.

`shape-doctor.py` and `siblings.py` read `.gitmodules` more loosely than git
does, so they named a mount url, a NEXT line or a clone where
`git submodule init` says `No url found` or refuses the file and exits 128.
Now both read only what git reads:

- a section whose name is empty or holds a `..` component is skipped whole;
- a `path` or `url` value beginning with `-` is skipped, that key alone;
- a section with no `path` names no mount, and a url is keyed by its section's
  `path` alone;
- a key with no value at all (a bare `path`, `url`, `ignore`, `update` or
  `branch`) makes git refuse the working tree's file whole, so no url is read
  from it; an index or HEAD copy is read up to that line, and the sections
  before it keep their urls;
- a value git dies on (an `update` other than `none`, `checkout`, `rebase` or
  `merge`, a `shallow` that is no boolean, a `fetchRecurseSubmodules` that is
  neither a boolean nor `on-demand`) refuses the file in any copy;
- an unmerged `.gitmodules` names no url.

The rules are in `submodule_keys`, `section_url`, `gitmodules_entry` and
`mounted_from` of `shape-doctor.py`, and in `submodule_urls` of
`templates/family-root/scripts/siblings.py`, where they sit in the
`git config -z --list` loop #805 gave it. Section names keep git's bytes, so
names that differ only in bytes that are not UTF-8 stay apart, as in git.
`tests/test_doctor_legs_members_next.py` and `tests/test_siblings_inputs.py`
check the doctor and `make siblings` against `git submodule init` over each
case and its controls. git 2.43 crashes on a bare `branch` (exit 139), and the
rows that ask it skip there; git 2.55 stops at it as at any other key.

Lane 3's independent reviewer RV-c (opus) found it READY in rounds 2 and 3,
and RV775r4 (opus) in rounds 4 and 5 and in its check of the last merge of
main (#775 issuecomment-6229804341, -6229900472, -6237014956, -6237376002 and
-6239488425). After #774 landed, it was restacked onto #774's squash by three
merges, and its changed lines equal the reviewed delta (#775
issuecomment-6244253158). It lands on Brett Heap's word "merge lane 3's v1.1.1
drafts after the v1.1.0 tag" (2026-10-17T13:00:57Z).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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