Skip to content

scaffold-project.py, adopt-project.py and family.py: the Free-plan secrets hint goes by what GitHub says of the root or holder and of its organisation's plan, and names the answer GitHub did not give, so init --reuse-empty-repo no longer calls a public holder private, an unread root is not taken as the run's visibility, and an unread plan is not silent - #728

Merged
brettheap merged 4 commits into
mainfrom
fix/660-free-plan-hint-reads
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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

Lands after #621 (on main as 4ce9045). This branch is cut from it.

The Free-plan secrets hint (FREE_PLAN_HINT) holds when the repository whose validate workflow reads SHAPE_LEGS_APP_ID and SHAPE_LEGS_APP_PRIVATE_KEY (the assembly root, or the family holder) is private or internal AND its organisation is on the GitHub Free plan. That is two questions for GitHub, and either can go unanswered. Since #621 adopt and the scaffold ask the first one of the root. This PR asks both questions in all three tools, in one place, and says which answer GitHub did not give instead of guessing.

What changed

scripts/shape_materialize.py (new, beside refuse_unless_not_found). This file is not a shape copy, so nothing re-pins.

  • visibility_in(answer) reads a gh repo view --json visibility answer. It returns one of GitHub's three visibilities, in any case, or None.
  • repository_visibility(repository) is the one visibility reader for the scaffold and family.py. A failed gh repo view is None. A gh that cannot be started is left to the caller, because the scaffold still refuses that as gh-not-found (family.py init --dry-run and scaffold-project.py --dry-run exit 0 without asking whether the remotes already exist #537).
  • organisation_plan(org) returns "free", "other", "not-an-organisation", or None (unread).
    • "not-an-organisation" is gh's not-found answer to gh api orgs/<owner>. GitHub answers 404 there when the owner is a personal account, which has no organisation secrets (review round 1, F1).
    • None covers every other failure (a 403, a 502, a gh that cannot be started) and an answer that names no plan.
    • The command goes through subprocess.run, because the not-found reading is of the command's own stderr, which run folds into stdout.
  • gh_said_not_found(probe, words) is scaffold-project.py and family.py init: the dry runs skip the target-exists and folder checks, and any gh repo view failure reads as "does not exist" #609's reading of gh's not-found answer (exit 1, gh's words on stderr), taken out of refuse_unless_not_found, which now asks it. It takes gh's words for each API:
    • GH_NOT_FOUND_WORDS for gh repo view (GraphQL), as before;
    • the new GH_API_NOT_FOUND_WORDS, Not Found (HTTP 404), for gh api (REST).
  • free_plan_secrets_note(org, root, visibility) is the decision all three tools now share:
    • unread root: ROOT_VISIBILITY_UNREAD (moved here from adopt, word for word), and the plan is not asked;
    • public root: nothing;
    • private or internal root on free: FREE_PLAN_HINT, with the root alone as its subject;
    • private or internal root whose plan is unread: the new FREE_PLAN_UNREAD;
    • any other plan, or an owner that is not an organisation: nothing.

scripts/family.py (L2-58, L2-71). _finish_holder_remote now takes reuse.

scaffold-project.py (L2-57).

  • remote_visibility is now repository_visibility, plus the same gh-not-found refusal, in the same words, for the same argv.
  • _create_remotes no longer falls back to or args.visibility for a reused root, so an unread root stands as None.
  • _private_leg_note prints free_plan_secrets_note.

adopt-project.py (L2-59).

  • _free_plan_hint_by_the_root prints free_plan_secrets_note.
  • _root_visibility keeps its own gh call, through adopt's run, and reads the answer with visibility_in.
  • ROOT_VISIBILITY_UNREAD now lives in shape_materialize.py, and import json is gone (its only user was the parse).
  • Adopt's messages are byte for byte as before. The probe below compares stdout and the gh call log for four cases.

scripts/repo_shape.py is untouched. FREE_PLAN_HINT's text stays. free_plan_secret_hint stays too: the three tools no longer call it, and removing it from the pinned copy would re-pin every project.

New messages, as a probe printed them (stub gh, testorg):

NOTE this run could not read testorg's plan from GitHub, so it cannot say whether the Free-plan secrets hint applies. testorg/Atlas is private, so it applies when testorg is on the GitHub Free plan: organisation secrets are then not delivered to testorg/Atlas, so org-level SHAPE_LEGS_APP_ID / SHAPE_LEGS_APP_PRIVATE_KEY resolve to EMPTY in its `validate` workflow and must be set as REPOSITORY secrets on testorg/Atlas instead. `gh api orgs/testorg --jq .plan.name`, run with an owner's login, says which plan it is on.

init's NOTE, before:

NOTE testorg/Orbit is private and its members will be too: give it a way to read them — a GitHub App (SHAPE_LEGS_APP_ID + SHAPE_LEGS_APP_PRIVATE_KEY, preferred) or a SHAPE_LEGS_TOKEN PAT with contents:read on the members AND their legs — or the `validate` check cannot check them out.

and after:

NOTE testorg/Orbit is private: give it a way to read the members you add that are private or internal — a GitHub App (SHAPE_LEGS_APP_ID + SHAPE_LEGS_APP_PRIVATE_KEY, preferred) or a SHAPE_LEGS_TOKEN PAT with contents:read on the members AND their legs — or the `validate` check cannot check them out.

init's hint: "testorg/Orbit and its members are private, so org-level ..." is now "testorg/Orbit is private, so org-level ...". The scaffold over a reused root whose visibility read fails (HTTP 502) printed the hint ("testorg/Atlas is private, so org-level ..."), whatever the root was. It now prints ROOT_VISIBILITY_UNREAD, adopt's NOTE: "NOTE this run could not read testorg/Atlas's visibility from GitHub, so it cannot say whether the Free-plan secrets hint applies. ...".

Decisions the issue left open

Tests

New file tests/test_free_plan_hint_reads.py, 50 tests (38, and 12 from review round 1):

file test what it pins
tests/test_free_plan_hint_reads.py test_each_tool_says_what_github_said_of_the_root_and_its_plan (scaffold, adopt, family × public, private, visibility-502, visibility-403, plan-502, not-an-organisation, plan-unnamed, paid; 24) in each tool: the hint only for a root GitHub calls private on a plan it calls free, naming the root alone; ROOT_VISIBILITY_UNREAD for a root whose --json visibility read fails (HTTP 502, or a SAML 403), with the plan not asked; FREE_PLAN_UNREAD for a plan read that fails or names no plan; nothing for a public root, a paid plan, or an owner gh says is no organisation (gh: Not Found (HTTP 404)); never "its members"; the root's visibility is asked
tests/test_free_plan_hint_reads.py test_init_notes_a_reused_holder_at_its_own_visibility (public, private, internal, visibility-502, visibility-404) init's NOTE goes by GitHub's answer, in the new words; no NOTE for an unread holder; family.yaml keeps the run's visibility
tests/test_free_plan_hint_reads.py test_init_asks_nothing_of_a_holder_it_creates a holder init creates gets the NOTE and the hint at the run's visibility, and GitHub is not asked
tests/test_free_plan_hint_reads.py test_init_takes_a_holder_gh_cannot_be_asked_about_as_unread in process, every platform: a reused holder whose visibility read cannot start gh (OSError) gets ROOT_VISIBILITY_UNREAD, and no NOTE or hint at the run's --visibility
tests/test_free_plan_hint_reads.py test_the_plan_is_free_other_or_unread (11) organisation_plan, through a faked subprocess.run: free, free with whitespace, team, enterprise, empty, blank line, a REST 404 (not an organisation), a SAML 403, a 502, a 502 whose stdout carries a 404 body, and no gh
tests/test_free_plan_hint_reads.py test_a_visibility_read_is_one_of_three_or_unread repository_visibility: lowercased, an unknown word unread, a failed gh unread, a missing gh raised to the caller
tests/test_free_plan_hint_reads.py test_the_note_goes_by_both_answers (7) free_plan_secrets_note: each answer pair, not-an-organisation included, and the plan asked only of a closed root
tests/test_scaffold_reuse_seeded_legs.py fake_gh two new stub knobs: visibility_errors (fails only repo view --json visibility) and plan_error (fails api orgs/<org>)
tests/test_adopt_free_plan_hint_root.py create_the_legs passes fake_gh's other keywords through
tests/test_free_plan_hint.py test_every_creating_tool_calls_the_hint, and the module docstring each tool calls free_plan_secrets_note with its own organisation; the docstring says the file's silence tests are free_plan_secret_hint's, which no tool calls since #660 (F7)

Review round 1 (rv660, CHANGES REQUIRED)

Not changed

Closes #660

🤖 Generated with Claude Code

Summary by Sourcery

Make Free-plan secrets guidance reflect GitHub’s reported repository visibility and organisation plan without guessing when either value is unavailable.

New Features:

  • Add shared handling for repository visibility and organisation-plan responses when deciding Free-plan secrets guidance.
  • Report explicit guidance when GitHub cannot determine a repository’s visibility or organisation plan.

Bug Fixes:

  • Prevent reused public roots or family holders from being incorrectly treated as private based on the run’s requested visibility.
  • Avoid silently treating unread organisation plans as paid and distinguish personal-account owners from unread organisation plans.
  • Stop describing family members as private when only the holder’s visibility is known.

Enhancements:

  • Centralize visibility parsing, organisation-plan classification, and Free-plan secrets notes across scaffold, adopt, and family creation flows.
  • Preserve the run’s recorded family visibility while using GitHub’s actual visibility for reused holders and roots.
  • Share GitHub not-found detection across repository and REST API queries.

Tests:

  • Add comprehensive coverage for visibility and plan outcomes across scaffold, adopt, and family workflows, including failures, personal accounts, and unstartable GitHub CLI scenarios.
  • Extend GitHub stubs to simulate visibility-specific and organisation-plan failures.

…crets hint goes by what GitHub says of the root or holder and of its organisation's plan, and names the answer GitHub did not give, so `init --reuse-empty-repo` no longer calls a public holder private, an unread root is not taken as the run's visibility, and an unread plan is not silent (#660)

The hint (FREE_PLAN_HINT) holds when the repository whose `validate`
workflow reads SHAPE_LEGS_APP_ID and SHAPE_LEGS_APP_PRIVATE_KEY, the
assembly root or the family holder, is private or internal and its
organisation is on the GitHub Free plan. Since #621 adopt and the scaffold
ask GitHub the root's visibility. This finishes both questions in all
three tools, in one place: scripts/shape_materialize.py, which is not a
shape copy.

- `repository_visibility` (and `visibility_in`, its reading of the answer)
  is the one visibility reader. The scaffold's `remote_visibility` is it,
  with a missing `gh` still `gh-not-found`; adopt's `_root_visibility`
  keeps its own `gh` call and reads the answer with `visibility_in`.
- `organisation_plan` answers free, other, or None (unread): a failed or
  unstartable `gh api orgs/<org>`, or no plan name.
- `free_plan_secrets_note` is the decision the three tools share: an
  unread root is ROOT_VISIBILITY_UNREAD (moved here from adopt, word for
  word); a public root nothing; a closed root on Free the hint, naming the
  root alone; a closed root whose plan is unread the new FREE_PLAN_UNREAD.

L2-58: `family.py init --reuse-empty-repo` asks GitHub the reused holder's
own visibility and gates its NOTE and the hint on it; an unread holder is
said to be unread. A holder this run creates still goes by `--visibility`.
family.yaml's `visibility:` keeps recording the run's value (L2-23's
question, a known limit on #459).

L2-57: the scaffold's reused root whose visibility GitHub does not give is
said to be unread, where it took the run's `--visibility`.

L2-59: a closed root whose plan GitHub does not name is said to be on a
plan this run could not read, where all three tools were silent as for a
paid plan. scripts/repo_shape.py, FREE_PLAN_HINT and free_plan_secret_hint
are untouched, so no project re-pins.

L2-71: `init`'s hint names the holder alone, and its NOTE no longer says
"and its members will be too": `init` makes no member and `add` mounts one
of any visibility. It names the members you add that are private or
internal.

Lands after #621.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 4 hours and 56 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR moves Free-plan secret guidance into shared materialization helpers that separately normalize repository visibility and organization-plan responses, then updates scaffold, adopt, and family flows to report confirmed, unread, or non-applicable states without guessing—especially for reused roots and holders—while retaining existing command/refusal semantics and adding broad cross-tool coverage.

Sequence diagram for reused root visibility and plan handling

sequenceDiagram
    participant Tool as CreatingTool
    participant Materialize as shape_materialize.py
    participant GitHub as GitHubCLI
    Tool->>Materialize: repository_visibility(root)
    Materialize->>GitHub: gh repo view root --json visibility
    GitHub-->>Materialize: visibility or command failure
    Materialize-->>Tool: public, private, internal, or None
    Tool->>Materialize: free_plan_secrets_note(org, root, visibility)
    alt visibility unread
        Materialize-->>Tool: ROOT_VISIBILITY_UNREAD
    else public
        Materialize-->>Tool: no note
    else private or internal
        Materialize->>GitHub: gh api orgs/org --jq .plan.name
        GitHub-->>Materialize: free, other, unread
        Materialize-->>Tool: FREE_PLAN_HINT, FREE_PLAN_UNREAD, or no note
    end
Loading

Flow diagram for shared Free-plan secrets decision

flowchart TD
    A["Creating tool receives root visibility"] --> B{"Visibility readable?"}
    B -- "No" --> C["Return ROOT_VISIBILITY_UNREAD"]
    B -- "Public" --> D["No secrets note"]
    B -- "Private or internal" --> E["organisation_plan(org)"]
    E --> F{"Plan readable?"}
    F -- "No" --> G["Return FREE_PLAN_UNREAD"]
    F -- "Free" --> H["Return FREE_PLAN_HINT for root"]
    F -- "Other" --> D
Loading

File-Level Changes

Change Details Files
Centralize repository visibility, organization-plan reads, and Free-plan secret-note decisions while preserving tool-specific command execution behavior.
  • Add normalized readers for GitHub repository visibility and organization plan, distinguishing unread answers from public or paid results.
  • Add shared messages for unread root visibility and unread organization plans, and emit the existing Free-plan hint only when both conditions are confirmed.
  • Keep adopt’s GitHub invocation path intact while reusing the shared response parsing and decision logic.
scripts/shape_materialize.py
adopt-project.py
scaffold-project.py
scripts/family.py
Make reused roots and family holders use GitHub-reported visibility instead of the run’s requested visibility.
  • Query reused assembly roots and family holders for their actual visibility; do not fall back to --visibility when the query is unreadable.
  • Continue recording family.yaml visibility from the run’s requested value, and avoid querying holders created during the current run.
  • Preserve scaffold gh-not-found refusal behavior while treating failed visibility reads as unread for existing repositories.
scaffold-project.py
scripts/family.py
Correct credential guidance to describe only repositories and members whose status the tools actually establish.
  • Remove the claim that a holder’s members will share the holder’s visibility.
  • Name only the root or holder in the Free-plan hint and describe later private or internal members in the init NOTE.
  • Add coverage for public, private, internal, unread-visibility, unread-plan, paid-plan, and newly created-holder cases across all three tools.
scripts/family.py
tests/test_free_plan_hint_reads.py
Expand tests and GitHub stubs to validate both independent reads and their call ordering.
  • Add 38 tests covering shared readers, decision branching, all three creating tools, and preservation of existing adopt behavior.
  • Extend the fake gh executable to simulate visibility-specific failures and organization-plan failures.
  • Update source-level assertions to require calls to the shared decision function.
tests/test_free_plan_hint_reads.py
tests/test_scaffold_reuse_seeded_legs.py
tests/test_adopt_free_plan_hint_root.py
tests/test_free_plan_hint.py

Assessment against linked issues

Issue Objective Addressed Explanation
#660 For family.py init --reuse-empty-repo, determine the reused holder’s visibility from GitHub rather than from the run’s --visibility, report when it cannot be read, and base the private-holder NOTE and Free-plan hint on the holder alone. ✅
#660 For creating tools, do not infer a reused root’s visibility from the run’s --visibility when GitHub cannot be read; report unread visibility, and report an unread organization plan rather than silently treating it like a paid plan. ✅
#660 Correct family.py init’s private-visibility NOTE and Free-plan hint so they do not claim that the holder’s future members are private. ✅

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

… to read, so a personal account's private root gets no Free-plan NOTE, and the tests pin init's unstartable-gh read and a 403 or 404 visibility read (#660)

rv660's review of #728 (F1, F7, F8).

F1. `organisation_plan` took every failure of `gh api orgs/<owner>` as
an unread plan. GitHub answers that request 404 where the owner is not
an organisation, so a scaffold, `init` or adoption owned by a personal
account printed FREE_PLAN_UNREAD, whose remedy can never answer. A
personal account has no organisation secrets, so nothing applies.

- `gh_said_not_found(probe, words)` is #609's reading of gh's not-found
  answer (exit 1, gh's words on stderr), taken out of
  `refuse_unless_not_found`, which now asks it. It takes gh's words for
  each API: GH_NOT_FOUND_WORDS for `gh repo view` (GraphQL), and the new
  GH_API_NOT_FOUND_WORDS, "Not Found (HTTP 404)", for `gh api` (REST).
- `organisation_plan` answers "not-an-organisation" there, and
  `free_plan_secrets_note` says nothing for it. A 403, a 502, a blank
  answer and every other failure stay unread. The command runs through
  `subprocess.run`, because the reading is of its own stderr, which
  `run` folds into stdout.

F8. A test runs `_finish_holder_remote` for a reused holder whose
visibility read cannot start `gh`, so the `OSError` branch is pinned as
unread (rv660-R2 survived every test). Each tool's table gains a 403
visibility row, and `init`'s a 404 one.

F7. tests/test_free_plan_hint.py's docstring says its silence tests are
`free_plan_secret_hint`'s, which no tool calls since #660, and what the
tools now do instead.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-16T18:27:13Z)

Review round 1 for rv660's CHANGES REQUIRED, at 0b97f1a (was bc8c73b). Only F1, F7 and F8 change. F2-F6 are held for #459 until this lands.

Tests.

  • 10 files: 806 passed, 3 skipped. They are the 4 changed test files, the 2 doctor files, test_dry_run_asks_remotes.py, and the 3 that name refuse_unless_not_found or remote-unreadable.
  • render-cli-reference.py --check: ok.
  • Mutants, 4 of 4 killed: rv660-R2; F1-a, the reading reverted; F1-b, every exit-1 failure read as not an organisation; F1-c, GraphQL's words used for the REST read.
  • Probe with a stub gh: for someuser (404), the scaffold (reused and fresh), init (reused and created) and adopt printed FREE_PLAN_UNREAD at bc8c73b and nothing at 0b97f1a. A 502 still prints it.

…import beside #728's FREE_PLAN_HINT import

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit 3615b27 into main Oct 16, 2026
5 checks passed
@brettheap
brettheap deleted the fix/660-free-plan-hint-reads branch October 16, 2026 19:42
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained 25 pull requests since de92614: #634, #699, #671, #719,
#701, #717, #700, #702, #722, #720, #725, #721, #728, #705, #727, #718,
#744, #743, #749, #740, #737, #734, #742, #751 and #736. No conflict:
AGENTS.md, adopt-project.py and docs/cli.md merged as text, and
README.md and docs/handbook.html are unchanged by the merge. README.md
is 1473 lines, its cap; AGENTS.md 494, its cap; every handbook marker is
current; `render-cli-reference.py --check` is ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…es every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683)

- W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's
  shape current, :1208-1220) and its handbook twin (`doctor`) named only a
  pin whose commit this checkout does not carry. They now say, as
  shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial
  clone missing what is compared (#683), a read that fails
  (`update-upstream-unreadable`), a crashed validator of its own (#278,
  #410), or its own naming policy not loading (#678). +1 line, with
  tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 +
  12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers
  move: `doctor` text and marker, `current` marker only.
- The notes, "What changed since v1.0.1", one entry per pull request from
  `git log --first-parent de92614..bf1f099`, each from its squash commit and
  the issues GitHub says it closes:
  - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's
    hint), #743 + #720 + #718, #722, #721;
  - update-shape.py, bump-leg.py and a project's own scripts: #702 (with
    the pinned validators), #634;
  - scaffold, setup, family and the shared readers: #725, #671, #744;
  - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705,
    #740;
  - install, CI and the docs: the exit-3 sentence above.
- Counts (99 pull requests since bfed143; 109 of 135 milestone issues
  closed at 10:28Z), the "current to main" sha and the open list (26) are
  at main bf1f099, still placeholders for tag time.

Left for round B: lane 5's eight lines (#630 issuecomment-6227129879),
whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open
drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
Main's 28 new commits since 8062810 merged with no conflict. Four of them touch a file this branch changes: #744, #728, #671 and #634 edit scaffold-project.py, and #728 and #671 edit scripts/family.py too, each in other functions than the clone URL's remedy, its refusal and the README values. Nothing this branch changes is a pinned copy, so nothing was re-pinned.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
Main at bf1f099 (#736), 29 commits past the branch's base 4ce9045, among them #671 and #728 in scripts/family.py. No conflicts: git merged every file on its own.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 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

Labels

None yet

Projects

None yet

1 participant