Skip to content

scaffold-project.py and family.py: a --local-remote-dir whose clone URL is not text a README can hold is refused before any remote or folder exists, --dry-run alike, so a directory named in a legacy code page no longer ends in a UnicodeEncodeError traceback after the bare remotes are made (#433) - #640

Merged
brettheap merged 5 commits into
mainfrom
fix/433-clone-url-bytes
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)

Claim: #433 (comment)

What changed and why

Under --local-remote-dir the clone URL of a new repository is the bare repository's own path. scaffold-project.py renders it as CLONE_URL and ASSEMBLY_CLONE_URL into the READMEs of the assembly root and of each leg, and family.py init renders the holder's into the holder's README, all through write_lf, which encodes strictly. A directory whose name is not UTF-8 reaches sys.argv as a string with a lone surrogate, so on main (re-measured at 8f453d0e, LANG unset):

  • the scaffold exited 1 with UnicodeEncodeError in write_lf after Atlas.git, Atlas-spec.git and Atlas-code.git existed and the work directory held Atlas-spec;
  • family.py init exited 1 the same way after Contoso.git existed and the family folder was made;
  • --dry-run exited 0 for both and said nothing;
  • a line break, a control character or a bidi override in the path was written into the README raw.

adopt-project.py asks the question before any leg exists (#255, _refuse_an_unwritable_clone_url). Both tools now ask it of the same value with the same unwritable_reason, and refuse in adopt's words, before any remote or folder exists and under --dry-run alike:

The sentence, scaffold: under --local-remote-dir the assembly root's clone URL is the bare repository's own path, '<url>', which the scaffold writes into the README.md of the assembly root and of each leg, and it holds a byte that is not UTF-8. The family's: ... the holder's clone URL is the bare repository's own path, '<url>', which initwrites into the holder'sREADME.md, and .... The Remediation: says nothing was created and --dry-run refuses the same, to pass a --local-remote-dir whose path is one line of UTF-8 text with no control character (renamed or made anew), or to run against GitHub, where the clone URL is https://github.com/<org>/<name>.git, and why each kind of byte cannot be written.

The issue offered reusing _refuse_unwritable_values with the URL among its fields. I did not: its remediation tells a person to retype a value, and a directory is renamed or replaced, not retyped. The code, the question and the sentence's skeleton are the same.

Tests

tests/test_clone_url_bytes.py, 30 tests, on the #282 file's harness (scaffold and no_process imported from tests/test_scaffold_free_text_values.py) and tests/test_family_init_values.py's (FAMILY, family_module):

Where the disk cannot hold the name (macOS, Windows) the test skips with a reason; none skipped here. New file alone: 30 passed. Targeted run on the committed bytes, 76 files (every test_scaffold_*, test_family*, test_setup_*, and each file naming the scaffold, family.py or setup-project.py three or more times, plus test_repo_hygiene.py, test_cli_reference.py, test_agents_md_codes.py): 4620 passed, 31 skipped (template-quoting, Windows-only and PowerShell skips), exit 0, 48 min. After main moved to 4cf5fb07, which touches none of the three files, the new file and its two harness files on a scratch merge with main: 249 passed, 19 skipped.

Mutants, each run on the new file alone:

mutant result
M1 delete the scaffold's call killed, 12 failed
M2 delete init's call killed, 12 failed
M3 scaffold asks only on the real run killed, 6 failed (every dry-run case)
M4 both functions ask only for a byte that is not UTF-8 killed, 12 failed (line feed, escape, bidi)
M5 both ask the flag as typed, not the resolved URL killed, 24 failed (the relative cases fail on the exit code, the others on the sentence)

Fix round 1 (ac404626, after review R433 found SonarCloud's gate in ERROR, from the test file only; no behaviour changed):

Main merge (541f3aec, after #641 landed as 42f64111): tests/test_clone_url_bytes.py, tests/test_non_utf8_git_reads.py (#641's), tests/test_family_refuse_up_front.py (#626's), tests/test_scaffold_free_text_values.py, tests/test_family_init_values.py, tests/test_family_dry_run.py, tests/test_family_init_landing.py, tests/test_repo_hygiene.py, tests/test_cli_reference.py and tests/test_agents_md_codes.py, on the committed merge, GIT_ALLOW_PROTOCOL unset: 589 passed, 19 skipped (the same 16 and 3 template-quoting skips in test_family_init_values.py), exit 0, 2:58.

What it cannot tell

Shared files

scaffold-project.py and scripts/family.py, and the new test file tests/test_clone_url_bytes.py; the diff against main is those three files and no others. No --help text, so docs/cli.md is unchanged, and no README or handbook sentence became false. No pinned shape copy is touched.

Main is merged in. Head 541f3aec, a --no-ff merge of main 42f64111 (which holds #641, #626, #633, #642 and #590, among others, since this branch's base 8f453d0e) into fix round 1 ac404626. Exactly one hunk conflicted, in scripts/family.py: the comment above the one shared from shape_materialize import unwritable_reason line, which #433 and #641 each gave its own comment. scaffold-project.py auto-merged. The hunk, markers included, is resolved to these five lines (reviewer R423 tested them byte for byte), as committed:

# Whether a value `init` writes into the holder is one line of text a file
# can hold: the creator read from `git config user.name` (#426), and the
# holder's clone URL under `--local-remote-dir` (#255, #433). It is the
# question the scaffold and `adopt-project.py` ask of theirs.
from shape_materialize import unwritable_reason  # noqa: E402

py_compile passes on scripts/family.py, which holds both CREATOR_UNWRITABLE_REMEDIATION (#426) and CLONE_URL_REMEDIATION (#433), one unwritable_reason import and no duplicate definition.

git merge-tree --write-tree of this head 541f3aec, each head read with ls-remote just before:

against head exit tree
main 42f64111592d 0 3611c189c62e (= this head's own tree: main is already in it)
#643 fix/421-push-branch-refspec 1fe37647d930 0 8d90f0ba0caf
#622 fix/609-dry-run-parity 227bc3b01c08 0 ef44f058f3f1
#621 fix/602-free-plan-hint-root 10dda171dd77 0 38e99bd5767f

#626 and #641 are in main now, so they are not listed.

Closes #433

🤖 Generated with Claude Code

Summary by Sourcery

Refuse unwritable local clone URLs before creating repositories or project folders in scaffold and family initialization.

Bug Fixes:

  • Reject scaffold and family initialization when a local clone URL cannot be safely represented in README text, preventing partial repository or folder creation and UnicodeEncodeError failures.

Enhancements:

  • Apply the same upfront validation to real and dry-run flows, with actionable refusal messages for invalid encoding, control, and bidirectional characters while preserving valid UTF-8 paths.

Tests:

  • Add coverage for invalid and valid local remote directory names, relative paths, dry runs, subprocess behavior, side-effect prevention, and refusal-message parity.

… URL is not text a README can hold is refused before any remote or folder exists, `--dry-run` alike, so a directory named in a legacy code page no longer ends in a `UnicodeEncodeError` traceback after the bare remotes are made (#433)

Under `--local-remote-dir` the clone URL of a new repository is the bare
repository's own path. The scaffold renders it as `CLONE_URL` and
`ASSEMBLY_CLONE_URL` into the READMEs of the assembly root and of each leg,
and `family.py init` renders the holder's into the holder's README, all
through `write_lf`, which encodes strictly. A directory whose name is not
UTF-8 reaches `sys.argv` as a string with a lone surrogate, so both tools
ended in `UnicodeEncodeError`, exit 1, after the bare remotes existed (three
for the scaffold, `<Family>.git` and the family folder for `init`), while
`--dry-run` exited 0 and said nothing. A line break, a control character or a
bidi override in the path was written into the README raw.

`adopt-project.py` asked the question before any leg existed (#255). Both
tools now ask it of the same value with the same `unwritable_reason`, and
refuse `scaffold-unwritable-value` and `family-unwritable-value` in adopt's
words: under --local-remote-dir the clone URL is the bare repository's own
path, `<url>`, which the tool writes into the README, and why it cannot be
written; nothing was created; pass a directory whose name is text, or use
GitHub.

The scaffold hoists `_repository_urls` to just after the name-length check and
asks the assembly root's URL there, before `_declaration` runs `gh api` and
before a work directory or a remote exists. Only the assembly root's URL is
asked: it is the one a file holds, and the legs' sit beside it in the same
directory. `family.py init` asks the holder's URL right after `_holder_remote`.
`_refuse_unwritable_values` is not reused, because its remediation tells a
person to retype a value and a directory is renamed or replaced.

tests/test_clone_url_bytes.py: not UTF-8, a line feed, an escape and a bidi
override in the directory's name, absolute and relative, each tool, real and
`--dry-run`, in this process under a guard and as a child process; a UTF-8
directory still plans and still creates. Five mutants, each killed.

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

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR prevents scaffold-project.py and family.py init from creating partial repositories or folders when a local clone URL cannot be safely represented in README text, by validating the resolved URL up front and applying equivalent behavior during --dry-run; extensive tests cover invalid bytes, unsafe controls, path resolution, exact diagnostics, side-effect absence, and valid UTF-8 paths.

Sequence diagram for preflight validation of local clone URLs

sequenceDiagram
    participant CLI
    participant Resolver
    participant Validator
    participant Creator

    alt scaffold-project.py
        CLI->>Resolver: _repository_urls(args, names)
        Resolver-->>CLI: urls[assembly]
        CLI->>Validator: _refuse_an_unwritable_clone_url(local, urls[assembly])
        alt URL is unwritable
            Validator-->>CLI: Refusal scaffold-unwritable-value
            CLI-->>CLI: Exit 2, no remote or work directory
        else URL is writable
            Validator-->>CLI: Continue
            CLI->>Creator: _declaration(...)
            Creator-->>CLI: Create scaffold resources
        end
    else family.py init
        CLI->>Resolver: _holder_remote(args, family)
        Resolver-->>CLI: local, repository, url
        CLI->>Validator: _refuse_an_unwritable_clone_url(local, url)
        alt URL is unwritable
            Validator-->>CLI: Refusal family-unwritable-value
            CLI-->>CLI: Exit 2, no remote or family folder
        else URL is writable
            Validator-->>CLI: Continue
            CLI->>Creator: materialize_family_root(...)
            Creator-->>CLI: Create family resources
        end
    end
Loading

File-Level Changes

Change Details Files
Add early validation for local clone URLs before any remote, work directory, or family folder is created.
  • Resolve repository URLs earlier in scaffold setup and validate the assembly URL before GitHub or filesystem side effects.
  • Validate the family holder URL immediately after deriving it during init.
  • Reuse the shared unwritable-reason classification while preserving tool-specific refusal codes and remediation text.
  • Leave GitHub URLs unaffected because they are ASCII by construction.
scaffold-project.py
scripts/family.py
Add comprehensive coverage for invalid local path bytes and control characters, including dry-run behavior and side-effect prevention.
  • Test non-UTF-8 bytes, line feeds, escape controls, and bidi controls for scaffold and family init.
  • Verify real and dry-run invocations return refusal code 2 with exact stderr and no stdout.
  • Cover resolved relative paths, child-process execution without tracebacks, and guards against premature process starts.
  • Confirm UTF-8 paths, including accented names, continue to plan and create successfully.
tests/test_clone_url_bytes.py

Assessment against linked issues

Issue Objective Addressed Explanation
#433 Refuse scaffold runs whose local clone URL cannot safely be written into the generated READMEs, before creating any work directory or remote, including during --dry-run. ✅
#433 Refuse family init runs whose local holder clone URL cannot safely be written into its README, before creating the family folder or remote, including during --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

…w test file holds no raw bidi character and no composite assertion, and the holder's clone-URL remediation comment says which `family-unwritable-value` it is for (#433)

Review R433 measured f25917e and found SonarCloud's quality gate in ERROR,
from the test file only.

- `tests/test_clone_url_bytes.py:71` held a RAW U+202E RIGHT-TO-LEFT OVERRIDE
  in the `bidi-override` row, which is S6389 (new-code security rating C), and
  contradicted the file's own docstring: every invisible character in it is
  spelled as an escape, because a raw bidi control reorders the line it is on.
  It is now `"‮evil".encode("utf-8")`, the same bytes, the way
  `tests/test_scaffold_free_text_values.py` spells its own. No Cc, Cf, Zl or
  Zp character is left in the file; the only non-ASCII characters are the
  visible `é` of the issue's own `<dir>/café` control.
- `directory_named` asserted `path.is_dir() and os.listdir(path) == []` in one
  statement, which is S9073. It is two assertions, so a failure names which
  half failed.
- `scripts/family.py`, the `#:` comment above `CLONE_URL_REMEDIATION`, now says
  it is what a `family-unwritable-value` FOR THE HOLDER'S CLONE URL is told to
  do, as the scaffold's twin already does. A second remediation for the same
  code, a creator read from `git config user.name`, arrives with #641, and an
  unqualified comment would claim to be the only one. Comment only.

No behaviour changes. The new file still gives its 30 passed.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brett Heap and others added 3 commits October 16, 2026 10:49
Main gained #626 (`family.py add`, `bump` and `remove` refuse before their
first write), #633, #642, #590 and #641 (`git status --porcelain` and
`git config user.name` read as bytes) since this branch's base 8f453d0.

One conflict, in scripts/family.py: the comment above the single shared
`from shape_materialize import unwritable_reason` line. #433 and #641 each
added a use of that import and each wrote its own comment above it. The
resolution keeps both uses and all three issue numbers (#426 for the creator
read from `git config user.name`, #255 and #433 for the holder's clone URL):

    # Whether a value `init` writes into the holder is one line of text a file
    # can hold: the creator read from `git config user.name` (#426), and the
    # holder's clone URL under `--local-remote-dir` (#255, #433). It is the
    # question the scaffold and `adopt-project.py` ask of theirs.

scaffold-project.py auto-merged. No other hunk conflicted.

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

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 16, 2026 11:32
@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 10 hours and 19 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit a524542 into main Oct 16, 2026
5 checks passed
@brettheap
brettheap deleted the fix/433-clone-url-bytes branch October 16, 2026 11:32
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…ies keyed by pin_product_key, the key the chain readers use (#444 x #610)

#638 keys every reader of a chain by `pin_product_key` (`lower()`), and
`_chain_link_findings` now finds a holder's remedy by it too, as
`_link_remedies` keys it. Keyed by `casefold()`, the chain
`openß, openss, openInk` wrote one entry for its two links, so the first
link, `openß`, was offered the later link's words (that the scaffold
reads no source for it) instead of its own `--pin-source`. New test:
test_two_pin_files_one_casefold_are_each_offered_their_own. The one
conflict, in `scripts/repo_shape.py`, keeps both `_chain_link_findings`
docstring paragraphs and #638's key with this branch's `LinkPins` return.
R444's N2: the naming gate's `LinkSources` docstring cited
`validate-pins.py` by line numbers that no longer held the lines, and now
names `PinSources.__init__` and `resolve_neutral_pin_source`.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
main gained #563's own #590 (fe237ec) and #599 (32c22b0), whose trees
this branch already held through #599's 750366b (#599's final head,
eaa0cda, is 750366b plus a merge of main), and #626, #633, #642, #641,
#643, #644, #632, #636, #631, #638, #635, #640, #623, #637 and #622. No
conflict: the merged tree is 9be0d0d, the tree `git merge-tree
--write-tree` predicted. AGENTS.md, docs/cli.md and
tests/test_repo_hygiene.py merged as text; README.md and
docs/handbook.html are unchanged by the merge. README.md is 1461 lines,
its cap (1449 + 12, tests/readme-cap.d/563.txt), and 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 16, 2026
… tab shows the one-line install for Git Bash, held to the other copies by a test, and the notes are current to main d4a7fab with the fifteen pull requests since 4cf5fb0, the environments, the tag ruleset and the rehearsal (#563, #461)

The handbook (#461's line "The handbook's step 3 has no Git Bash path",
which lane 3 told lane 5 on #473 that #630 settles): step 3's install
block was `data-platform="linux mac wsl"`, so a reader on the Windows tab
never saw the one line the Windows panel says Git Bash runs. The Windows
tab now has a lead, "In Git Bash, the bash that Git for Windows installs,
the one-line install works as on Linux:", the Linux tab's block copied
byte for byte as `data-lang="sh"`, and the panel's caveat: the preflight
it ends with cannot take a typed `yes` in Git Bash. README.md is
unchanged: no test asked for it, and it is at its cap.

tests/test_repo_hygiene.py: the test above it finds one copy of the one
line per file; `test_every_handbook_copy_of_the_one_line_install_is_the_same`
holds every handbook copy (the masthead's and step 3's two) to
ONE_LINE_INSTALL, so a copy edited on one tab alone fails. A drifted
Windows copy fails it; the page as committed passes.

The release notes:
- "What changed since v1.0.1" gains one entry per pull request that
  landed after 4cf5fb0 and is not #563's own, from `git log
  --first-parent 4cf5fb0..origin/main` at d4a7fab: under update-shape.py
  and bump-leg.py, #644, #633 and #631; under scaffold, family and the
  materializer, #622, #643, #640, #641, #623, #638, #635, #626, #637 and
  #642; under shape-doctor.py, #636 and #632. Each from its squash
  commit and the issues GitHub says it closes.
- The list's placeholder says it is current to main d4a7fab and stays a
  placeholder for what lands after it; the counts are refreshed inside
  their placeholder (66 pull requests, 75 of 97 milestone issues closed).
- #590 and #599 are cited beside #563, and "Before tagging" says they
  landed (fe237ec, 32c22b0) and lists the 22 open milestone issues.
- "How this release is cut": Brett Heap's word at 2026-10-16T12:06:40Z,
  the environments testpypi (12:07:31Z) and pypi (12:07:39Z), the tag
  ruleset 25237399 (12:08:14Z), and the rehearsal, run 38898735926
  (12:10:53Z-12:12:00Z, success, both uploads skipped), each read back
  from the API. The trusted publishers' date stays a placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…-text: remedies keyed by pin_product_key, the key the chain readers use (#444 x #610)

The remedies keying fix is in the merge below, b7472df: `_link_remedies`
keys a link's remedy by `pin_product_key`, and `_chain_link_findings`
finds it by that key, as #638's chain readers do. Keyed by `casefold()`,
the chain `openß, openss, openInk` wrote one entry for its two links, so
the first link was offered the later link's words. This merge adds #623
and #637, and git merged them clean. One more change rides with it
(#607 x #610): `_lockstep`'s remedy now ends with #637's BRANCH_LEFT
sentence, as each remedy that names `_switch_line` does. Without it,
#637's guard, test_every_remedy_that_names_a_switch_line_ends_with_the_sentence,
and its partner test fail on the merge.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
… member clone URL `.gitmodules` cannot hold as text, `--dry-run` alike, each clone-URL remedy names a link's target and a lone surrogate, and the READMEs quote the clone URL, so the line under `a b/` pastes as one command (#693) (#812)

#640 (#433) asked `unwritable_reason` of the clone URL the scaffold and
`family.py init` write into their READMEs. Four things were left, each
measured on main 8062810:

- `family.py add --local-remote-dir <dir>/caf<e9>` exited 0, dry run and
  real, and committed the raw byte into the holder's `.gitmodules`, where
  the holder's `make siblings` (`siblings.submodule_urls`, `git config
  --list` read with `errors="replace"`) read the URL back as `caf\ufffd`;
  a line break was escaped as `\n` by git and read back cut short at it,
  and an escape and a bidi override were committed raw. `cmd_add` now asks
  `_refuse_an_unwritable_member_url` of `_member_url`'s result, after the
  branch question and before `_probe_member`'s throwaway clone, so the
  real run and `--dry-run` refuse alike, `family-unwritable-value`, with
  nothing read, mounted or written.
- `--local-remote-dir link`, a UTF-8 name linked to a `caf<e9>` directory,
  was refused naming the RESOLVED URL by a remedy that said to pass a path
  that is UTF-8 text, which it was. Every clone-URL remedy now says the URL
  is the path the flag resolves to, and that where the path given is text
  and the URL is not, a link's target (or, for a relative path, the
  directory it was given from) is the one to rename.
- `unwritable_reason` names a lone surrogate, which a Windows name can hold,
  and no clone-URL remedy did; both now say "or a lone surrogate", as #282's
  `UNWRITABLE_REMEDIATION` does.
- `git clone --recurse-submodules {{CLONE_URL}}` (holder) and
  `{{CLONE_URL}}`/`{{ASSEMBLY_CLONE_URL}}` (assembly root, both legs) took a
  local path raw, so under `a b/` or `it's/` the line split when pasted.
  Both tools now render the value through `shell_quoted(url, "posix")`,
  POSIX whatever the host as #532 rules for pasted commands: a GitHub URL
  and an ordinary ASCII POSIX path stay bare, so the templates and their
  pins are unchanged, but a path holding `é`, and every Windows path (it is
  spelled with `\`), is quoted too, as `shell_quoted`'s conservative set
  has it, and `test_clone_url_bytes.py`'s two `café` README asserts now
  expect the quotes. The README remedies say what that does: every
  character but an apostrophe is written as itself.

The link sentence and the surrogate are static text in the remedies, not a
branch on what was typed: it is true of every refusal (including #640's
relative path under such a directory), and it leaves `cmd_init`'s and
`_scaffold`'s call sites untouched. `add`'s remedy shares `init`'s words
(`LOCAL_REMOTE_DIR_REMEDY`) with its own verb, GitHub URL and reasons:
`.gitmodules` holds what `README.md` cannot, so its reasons say how the
holder reads it back, not that it cannot be written. And it says what the
directory must hold, which is the caller's (`{holding}`): `init` and the
scaffold make the bare repository there, so a directory made anew is a way
out, but `add` reads the member's `<Project>.git` from it, so a new, empty
directory is refused again with `does not exist`; `add`'s remedy says the
path must hold that repository, its directory renamed or the repository
moved.

Closes #693

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…efusals and `family-operation-in-progress` name a path as `shown_source` names it, so an ESC or a line break in a path no longer reaches the terminal (#692) (#731)

#622's folder refusals printed a path as it was. `family.py init --into <a file named wd-ESC[31mRED> --dry-run` gave `REFUSED family-folder-cannot-be-made: the family folder .../wd-^[[31mRED/Orbit cannot be made: ...` through `cat -v`. A name with a line break and then `WARNING forged-line: pwned` printed that line as a line of its own. R612's `family-operation-in-progress` printed the mark's path across two lines for a holder whose path holds a newline.

Each path is now spelled as `shape_materialize.shown_source` spells a `--pin-source`: as it is, and as Python's `repr` spells it where a character in it is not printable.

- `scripts/shape_materialize.py`: both answers of `folder_cannot_be_made` (a file or a bad stat in the way, and a folder this user cannot write in), the folder in `refuse_unmakeable_folder`, and the folder in `make_folder`.
- `scaffold-project.py`: `scaffold-target-exists`.
- `scripts/family.py`: `family-target-exists`, and the mark's path in `family-operation-in-progress` (`where.as_posix()`).

A printable path is named byte for byte, so no existing row changes and `why.endswith(CANNOT_WRITE_IN)` in `refuse_unmakeable_folder` still holds. No help text changed, so `docs/cli.md` is untouched. No README, handbook or template sentence says how a refusal names a path (`git grep` of `shown_source`, `target-exists` and `folder-cannot-be-made` in AGENTS.md, README.md, `docs/handbook.html` and the templates found none), so no doc changes.

- **`shown_source`, not `shown_path`.** Since #634, `shape_materialize.shown_source` returns `repo_shape.shown_path(source)` (`scripts/shape_materialize.py`: `return shown_path(source)`), so the seven call sites this change adds (four in `shape_materialize.py`, one in `scaffold-project.py` and two in `family.py`) spell a path as `shown_path` does. Renaming them to `shown_path` stays out of this PR: it would only move imports in `scaffold-project.py` and `family.py` and reword docstrings in `family.py`, `shape_materialize.py` and the test file, and the review's N1 left it optional. I did not take it. The predicted merge with main works with the alias: `family.py --help` and `scaffold-project.py --help` exit 0 on it, and `shown_source` and `shown_path` return the same string for an ESC name, a line-break name and a plain path.
- **`shown_source` is imported by name into each file's existing `from shape_materialize import (...)` block.** The first commit put it on a line of its own after the block; lane 4's #660, #688 and #693 add an import at that same place, and `merge-tree` reported add/add conflicts in `scripts/family.py` and `scaffold-project.py`. The second commit moves the name into the block, and those conflicts are gone.
- **"The holder in the other refusals of the same block" is one refusal.** The `# What the holder is in the middle of, and what its sparse checkout leaves out` block holds `family-operation-in-progress`, `family-sparse-excluded` and `member-path-unaddable`. Only the first prints a path of the holder's. `family-sparse-excluded` names `members/<Project>` and `.gitmodules`, and `member-path-unaddable` names `members/<Project>`.
- **No scaffold row for `--local-remote-dir`.** A control character there is refused earlier as `scaffold-unwritable-value`, which already names the path with `repr`. The unit test covers `make_folder`, which only a race reaches by the command line.

`tests/test_dry_run_parity.py`:
- `SCAFFOLD_CASES` gains five rows: ESC and line break in the `--work-dir` name, ESC in a folder name above it, and ESC and line break in a work dir that holds a file named `Atlas` (`scaffold-target-exists`).
- `FAMILY_CASES` gains five rows: ESC and line break in `--work-dir`, ESC in `--into` (`the family folder`), and ESC and line break in a work dir that holds a file named `Orbit` (`family-target-exists`).
- Two rows join `UNWRITABLE_FOLDER_AT` (ESC in the scaffold's and in `init`'s `--work-dir`, a folder this user cannot write in).
- Rows named `ESC-...` and `line-break-...` are skipped on Windows, whose file names cannot hold them (`POSIX_NAMES`, `case_params`). Every expectation spells the path with `repr`, and not with `shown_source`.
- `assert_refused` now fails any stderr line that starts with `WARNING forged-line`.
- Three unit tests: `test_each_folder_question_spells_a_name_that_is_not_printable` (ESC, line break; `folder_cannot_be_made`, `refuse_unmakeable_folder` and `make_folder` by themselves), `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`, and `test_a_printable_name_is_named_byte_for_byte` (a name with a space and an accented letter stays unquoted).

`tests/test_family_refuse_up_front.py`: `test_a_holder_path_with_a_newline_still_shows_its_merge` asserted the raw newline in the refusal (`"Ink\nRouter/.git/worktrees/" in ran.stderr`). It is now `test_a_holder_path_with_a_control_character_still_shows_its_merge`, over a line break, an ESC and a forged line, and asserts the one-line `repr` form (`refusal()` fails unless the stderr is two lines). It is the one existing test this change had to edit.

Results at head `ab55c29f` (Python 3.12.3, Linux):
- `tests/test_dry_run_parity.py tests/test_dry_run_asks_remotes.py tests/test_family_dry_run.py tests/test_family_init_landing.py tests/test_repo_hygiene.py tests/test_pin_source_unreadable.py tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_reuse_seeded_legs.py`: **494 passed**.
- `tests/test_family.py tests/test_family_refuse_up_front.py tests/test_family_init_values.py tests/test_family_add_refusals.py`: **356 passed, 19 skipped** (the skips are `test_family_init_values.py:219` and `:291`, template-quoting premises).
- Not run: the full suite (the coordinator's gate), and macOS and Windows (CI is Linux-only since #597). The new hostile-name rows are POSIX only by design.

On the base code: the review overlaid this head's two test files on main `2fac9790` and ran `tests/test_dry_run_parity.py` and `tests/test_family_refuse_up_front.py` with `-k "dry_run_parity or control_character"`. The result is **20 failed, 87 passed** (76 deselected, 43.5 s). The 20 are exactly the new and edited tests: the 5 scaffold and 5 `init` rows, the 4 unwritable-folder rows, the 3 unit tests (`test_each_folder_question_spells_a_name_that_is_not_printable` in two cases, and `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`), and the 3 cases of the edited `test_a_holder_path_with_a_control_character_still_shows_its_merge`. The one new test that passes on main's code is `test_a_printable_name_is_named_byte_for_byte`, by design: a printable name is named byte for byte on both. An earlier draft of this body said 21 failed, 2 passed. That run was on `8062810d` with a wider selection, and it held a scaffold row for ESC in the local remote dir's name (`ESC-in-the-local-remote-dir-name`) that this head's tests no longer have; its second pass was the existing `test_the_refusal_quotes_gh_s_first_line_and_escapes_what_is_unprintable`, which also passes in the new run, one of the 87.

`family.py init` and `scaffold-project.py --dry-run` against a file named `wd-ESC[31mRED`, a file named `wd-\nWARNING forged-line: pwned`, and a work dir with a file `Atlas` or `Orbit` in it, run with a stub `gh` and no real `gh`:
- On main, through `cat -v`: `... the family folder /.../wd-^[[31mRED/Orbit cannot be made: /.../wd-^[[31mRED is a file, not a folder`, and a stderr line `WARNING forged-line: pwned cannot be made: ...` of its own.
- On this head: `REFUSED family-folder-cannot-be-made: the family folder '.../wd-\x1b[31mRED/Orbit' cannot be made: '.../wd-\x1b[31mRED' is a file, not a folder`, and one line for the forged name: `--work-dir '.../wd-\nWARNING forged-line: pwned' cannot be made: '...' is a file, not a folder`. `scaffold-target-exists` and `family-target-exists` are one line each in the same form.

Each spelled site reverted alone in a throwaway clone at `19d126cd` (the call sites are the same at `ab55c29f`); the new tests that fail for each:
- `folder_cannot_be_made`, the `_not_a_folder` answer: 8 failed (six CLI rows and the two unit tests).
- `folder_cannot_be_made`, the `CANNOT_WRITE_IN` answer: 5 failed (the four unwritable rows and the unit test).
- `refuse_unmakeable_folder`: 12 failed.
- `make_folder`: 2 failed (the unit test only).
- `scaffold-target-exists`: 2 failed (its two rows).
- `family-target-exists`: 2 failed (its two rows).
- `family-operation-in-progress`: 3 failed (line break, ESC, forged line).

`git merge-tree --write-tree` of this head `ab55c29f` against origin/main `20c8873b` and against every ref on origin that could meet it, fetched 2026-10-16: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. (The same list was run for #730's head `7f2bb36b`.)
- **origin/main `20c8873b`: clean** (the predicted tree `00b5b24c`, which differs from main in these five files only: 216 lines added, 25 removed).
- **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change. This change touches no README or handbook.
- **The refs that edit one of this change's five files**, and so could meet it, all merge clean with this head: `fix/646`, `fix/651`, `fix/654`, `fix/660`, `fix/661`, `fix/684`, `fix/686`, `fix/688`, `fix/689` and `fix/693`, the heads of #670, #725, #728, #743 and #744, and the heads of the landed #634, #638, #639, #640, #643 and #671. #634 has landed (`30676c90`), so the `fix/660` note about a conflict with it is gone.
- **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change. Every other ref, 92 of the 96 in all, is clean with this head, including the refs that conflict with main in `templates/family-root/scripts/validate-family.py` (#682, #745, #747, `fix/648`, `fix/681`, `fix/704`) or in `shape-doctor.py` (#742, `l3-683`) on their own. So do the old heads of the landed #638 and #639, and lane 3's `l3-677-pins-hand-step-wip` branch, and each of them merges clean with this head too.
- **Tests on the predicted merge**, run by the review on the merge with main `2fac9790`: `test_dry_run_parity`, `test_family_refuse_up_front`, `test_pinned_copy_text`, `test_pin_source_unreadable`, `test_dry_run_asks_remotes`, `test_family_dry_run` and `test_family_init_landing`: 445 passed in 215 s. `test_family`, `test_family_init_values`, `test_family_add_refusals`, `test_derived_id_refusals`, `test_repo_hygiene`, `test_naming_policy`, `test_scaffold_leg_path_collisions` and `test_scaffold_reuse_seeded_legs`: 758 passed, 19 skipped in 243 s. I did not re-run them on `20c8873b`; the merge is clean, and the two commits since `2fac9790` (#700 and #702) change only `shape-doctor.py`, `templates/assembly-root/scripts/validate-pins.py` and four test files, none of them a file of this change.
- Before the second commit, `fix/660`, `fix/688` and `fix/693` each conflicted with this head in the import region of `scripts/family.py` or `scaffold-project.py` (a separate import line each side added at the same place). That is why the import joins the existing block.

#634 is on main, and this change does not edit `shown_source` or its docstring in `scripts/shape_materialize.py`.

The review approved with nits. Its S1, the stale base-run numbers and merge lines, is taken here, in this body: no code changed, and the head is still `ab55c29f`. Its N1, renaming the call sites to `shown_path`, is not taken (see "Decisions and their evidence"). It also re-ran 5 of the 7 mutation probes on `ab55c29f` and got the same failure counts as above (12, 2, 3, 2 and 2), and ran 144 hostile-name dry runs (16 names in 9 scenarios): on main 69 rows put a control character on stderr and 6 print a forged line of their own; on this head all 144 exit 2 with no traceback, no control character and no forged line.

- `scripts/family.py` `init`'s plan prints `folder` and `landing` lines with the path raw (`print(f"landing      {holder}   ...")`, `print(f"folder       {folder}   ...")`). On a dry run or a refused run the plan is on stdout before the refusal, so `WARNING forged-line: pwned/Orbit   (--work-dir: no family folder)` still appears there. It is the plan, not a refusal, and not one of the issue's lines.
- Other refusals that name a holder or folder path raw, none of them in the issue's lines: `family-holder-detached` and `family-on-tracking-branch` (`{family.root}`), `family-index-locked` (`lock.as_posix()` in the detail), `family-landing-in-standard` (`{holder}`, `{checkout}`, `{beside / family / family}`), `family-holder-already-here` (`{into}`), and `family-remote-exists` and `scaffold-remote-exists` (`{target}`, a local remote path).

1. **family.py `init`'s plan prints the holder and folder paths raw.** `scripts/family.py` `landing` and `folder` rows (about :1756-1759) print `holder` and `folder` as they are, so a forged line or an ESC reaches stdout in a `--dry-run` and in a run refused after the plan. Direction: spell them with `shown_source`.
2. **The same raw spelling in the refusals listed above.** `family-holder-detached`, `family-on-tracking-branch`, `family-index-locked`, `family-landing-in-standard`, `family-holder-already-here`, `family-remote-exists` and `scaffold-remote-exists` each print a path the person supplied or git keeps. Direction: the spelling this change gives its sites.

Review: rv692, lane 5's independent review, approved ab55c29 with nits, and its body fix (S1) is published. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 ab55c29 passed tests, tests-git-bash and SonarCloud; the head 13f643d is a clean update-branch merge of main 431c8cf, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4327947 over main 431c8cf: the dry_run_parity, family_refuse_up_front, repo_hygiene, agents_md_codes, cli_reference sets, 330 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #692

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