Skip to content

adopt-project.py: an org/repo --source too long for the disk is refused as a path is, naming no GitHub repository and offering no url, so --source <300 a's>/Thing is no longer offered https://github.com/<300 a's>/Thing, which can never clone (#1003) - #1122

Draft
brettheap wants to merge 2 commits into
mainfrom
fix/1003-adopt-unenterable-source

Conversation

@brettheap

@brettheap brettheap commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Lane 4's W1003 for #1003 (v1.2.0, #970's follow-up), with W1003b's fix round 1 for RV1122's review. Lane 3 places the issue; this is a draft, and a reviewer follows.

#1003 names two defects. Part (a) is already on main, so this PR changes only part (b).

What

adopt-project.py, _source_there: when the stat of a spelling that is also an org/repo is refused, the refusal names both readings and offers the url only where GitHub can hold the name. An owner over GITHUB_OWNER_MAX (39) or a repository over GITHUB_NAME_MAX (100) is refused as a path is, whatever reason the operating system gave. The file already imports both limits from shape_materialize.

        owner, _, repo = (name or "").partition("/")
        if (name is None or len(owner) > GITHUB_OWNER_MAX
                or len(repo) > GITHUB_NAME_MAX):

Before and after

Part (a), and the first head. W1003 probed as a non-root user (uid 1000), with Python 3.12.3 and with the same Python under a sitecustomize that answers Path.exists/is_file/is_dir as 3.14 does (proved to bite). The shim's output matched 3.12's on every tree. A stub gh recorded and refused each call; GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null.

case before #1072 (06cbd478) main this PR
H: plan --source <repo dir at 000> PermissionError traceback, exit 1 source-unreadable, exit 2, 0 gh calls same as main
C7: check, recorded local_path at 000, cloned: false traceback, exit 1 source-unreadable, exit 2 same as main
C6: the same, cloned: true "no longer whole (git finds no repository there)", cloning testorg/Thing, 1 gh call source-unreadable, 0 gh calls, nothing made same as main
C6e: execute --yes, the same as C6, 1 gh call source-unreadable, 0 gh calls same as main
G1: plan --source <300 a's>/Thing two readings, url offered two readings, url offered "whether a repository is there", no url; exit 2, 0 gh calls
G2b/G5b: [www.]github.com/<300 a's>/Thing beside ./[www.]github.com not measured two readings, url offered no url; no plan, no work directory
G3b: testorg/<300 a's> beside ./testorg not measured two readings, url offered no url; no plan, no work directory
G4: ./<300 a's>/Thing (a path) no url no url unchanged

That probe ran on main 2a119f28 and on the first head's diff as first committed there (5a8f09d8). At this head, the tests' rows below give the same answers for G1 to G3b.

Fix round 1. _source_spec in-process, with a real stat, as uid 1000 on Python 3.12.3. No process is started.

case stat main 823624d9 first head e75587e7 this head
A: <300 a's>/Thing File name too long two readings, url path wording, no url path wording, no url
B: testorg/<300 a's> beside ./testorg File name too long two readings, url path wording, no url path wording, no url
E: testorg/Thing, ./testorg a symlink to <300 a's> File name too long two readings, https://github.com/testorg/Thing path wording, no url two readings, https://github.com/testorg/Thing
C: testorg/<300 a's> beside ./testorg at 000 Permission denied two readings, url two readings, url path wording, no url
D: <50 b's>/Thing beside ./<50 b's> at 000 Permission denied two readings, url two readings, url path wording, no url
K39: <39 c's>/Thing beside ./<39 c's> at 000 Permission denied two readings, url two readings, url two readings, url
K: testorg/Thing beside ./testorg at 000 Permission denied two readings, url two readings, url two readings, url

Tests

Every run used /usr/bin/python3 -m pytest -p no:cacheprovider, with the landing gate's environment:

  • a stub gh first on PATH, GH_TOKEN=bogus and an empty GH_CONFIG_DIR;
  • GIT_CONFIG_GLOBAL set to an empty file, and GIT_CONFIG_NOSYSTEM=1;
  • the test author and committer variables, and EMAIL;
  • GIT_SSH_COMMAND and GIT_ALLOW_PROTOCOL unset.

The rows for #1003, in tests/test_adopt_unsearchable_source.py, #970's file. Each was run with this head's test file against three versions of adopt-project.py: main's, the first head's and this head's.

Row What it asserts main 823624d9 first head e75587e7 this head
test_a_spelling_too_long_for_the_disk_is_offered_no_url: [owner], [repository], [bare-github-url], [www-github-url] (changed) real stat, File name too long: path wording, no url. Its control now refuses, with an I/O error, a name GitHub can hold (an owner of 39, a repository of 100), and expects that name's url 4 fail at detail (two readings) 4 pass 4 pass
test_a_name_github_cannot_hold_is_offered_no_url_whatever_the_reason: an owner of 40 or 300, a repository of 101 or 300, and github.com/<40>/Thing, each refused with EIO and with EACCES (new, 10 rows) in-process: path wording, the cure for the reason, no url. The control is the same part at GitHub's limit, refused alike, which keeps both readings and its url 10 fail at detail 10 fail at detail (two readings, url) 10 pass
test_a_name_github_can_hold_keeps_its_url_through_a_link_too_long (new) row E: real stat, File name too long through the link; two readings and https://github.com/testorg/Thing passes fails at detail (path wording, no url) passes
test_plan_of_a_name_github_cannot_hold_in_an_unsearchable_place (new) row C: a child plan, ./testorg at 000, with the stat asserted to be Permission denied. Exit 2, the refusal line, the chmod cure, no "GitHub repository" and no https://; no plan, no work directory, gh never asked fails at the refusal line fails at the refusal line (url offered) passes
test_plan_of_a_spelling_too_long_for_the_disk_offers_no_url (unchanged) a child plan of <300 a's>/Thing fails passes passes

Totals for the 17 rows: main 16 failed and 1 passed, the first head 12 failed and 5 passed, and this head 17 passed. Each new row fails on the first head; each changed row fails on main, where the first head already passed it.

Skips follow the file's own:

  • the rows that need a real File name too long skip on Windows (needs_a_name_too_long), and the symlink row needs a symlink too (needs_a_symlink);
  • the real mode-000 row skips on Windows and as root (needs_a_mode);
  • the in-process EIO and EACCES rows refuse Path.stat themselves (refused_for), so they run on every platform and as root, as refused_stat's rows do.

Mutants, each applied to this head's adopt-project.py (sha256 b8bdbdbe...) by exact substitution and run against test_adopt_unsearchable_source.py and test_adopt_unenterable_source_and_locale.py (57 tests). After each one, the file was restored from its saved bytes and its sha256 checked. All seven were killed, and the file unmutated passes all 57.

Mutant Killed by
N1: the first head's guard, name is None or exc.errno == errno.ENAMETOOLONG 12: the 10 in-process rows, row E, row C
N2: the owner's limit only 6: [repository], the 4 repository-* rows, row C
N3: the repository's limit only 10: [owner], [bare-github-url], [www-github-url], the 6 owner-* and bare-github-url-* rows, and the child plan of <300 a's>/Thing
N4a: the owner's limit off by one (>=) 9: the 3 owner controls at 39, and the 6 owner-* and bare-github-url-* controls at 39
N4b: the repository's limit off by one (>=) 5: the [repository] control at 100, and the 4 repository-* controls at 100
N5: the url withheld for every error 20: 5 of #970's rows, the 4 changed rows, the 10 in-process rows, row E
N6: the errno, or GitHub's limits 1: row E

Gate, at this head c01c4d73, each file alone:

File Result
tests/test_adopt_unsearchable_source.py 44 passed (32 before, and 12 new)
tests/test_adopt_unenterable_source_and_locale.py 13 passed
tests/test_adopt_source_clone_record.py 100 passed
tests/test_adopt_project_is_the_source.py 23 passed
tests/test_adopt_temp_work_roots.py (the test file imports from it) 9 passed
tests/test_repo_hygiene.py 100 passed
tests/test_scaffold_leg_path_collisions.py 104 passed
tests/test_cli_reference.py 23 passed
tests/test_agents_md_codes.py 47 passed
tests/test_shape_doctor.py, in 8 shards of 56 445 passed, 3 skipped (:10510, the PowerShell rows, Windows only)

Total: 908 passed and 3 skipped, in 10 files, with no failures. Only the two source-unreadable files reach the changed branch: git grep over tests/ finds _source_spec, _source_there and adopt's source-unreadable in no other file.

pyflakes on both changed files: no warning.

Merges

git merge-tree --write-tree, at this head c01c4d73:

Merged with Result
main 823624d9 clean, tree aaed1f57; its diff from main is byte-identical to the PR's own diff (6da757cb..HEAD), in the same 2 files
#1125 2a53a8e6 clean
#1123 5c3908f8 clean
#1121 c04bb710 clean
#1120 c55e55e3 clean
#1119 bdcf9952 clean
#1118 c60066cb clean
#1106 504a1dd6 clean
#1105 5a4de7f7 clean
#1091 551452be clean

Out of scope

Closes #1003

🤖 Generated with Claude Code
Lane: openreposhape-4 (openRepoShape-4)

Summary by Sourcery

Refuse uncloneable GitHub-style source names without suggesting URLs while retaining repository guidance for valid names.

Bug Fixes:

  • Prevent adopt-project.py from offering GitHub clone URLs for org/repo source names that exceed GitHub’s owner or repository name limits.
  • Preserve GitHub URL suggestions for valid repository names when local path lookup fails for unrelated reasons, including permission errors or long symlink targets.

Enhancements:

  • Expand coverage of unsearchable source handling across GitHub URL spellings, operating-system errors, boundary lengths, and plan execution.

Tests:

  • Add regression tests verifying refusal wording, remediation, side-effect avoidance, URL handling, and boundary behavior for unsearchable or uncloneable source names.

…efused as a path is, naming no GitHub repository and offering no url, so `--source <300 a's>/Thing` is no longer offered `https://github.com/<300 a's>/Thing`, which can never clone (#1003)

`plan --source <300 a's>/Thing` is refused `source-unreadable`, "cannot
be read (File name too long)" (#970). It also said "whether it names the
path `./<300 a's>/Thing` here, or only the GitHub repository
`<300 a's>/Thing`, cannot be told", and offered
`https://github.com/<300 a's>/Thing`. Neither can be meant: a part longer
than a file name may be is longer than GitHub lets an owner (39) or a
repository (100) be named, so that url can never clone.

`_source_there` now refuses an `ENAMETOOLONG` as it refuses a path: "so
whether a repository is there cannot be told", with the cure for a cause
other than permission, and no url. The same holds for the `github.com/`
and `www.github.com/` spellings, and for a long repository part beside a
`./<org>` directory. Any other refused `stat` of a name (a permission, an
I/O error) still names both readings and offers the url.

Part (a) of #1003, a source or recorded clone directory this user cannot
enter, already landed on main in #1072 (see #918). It is measured again
here and not changed.

Closes #1003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes --source handling for names that trigger ENAMETOOLONG: they are refused as filesystem paths without offering a GitHub URL that cannot clone, while other refused-stat cases retain their existing ambiguous-source behavior.

Sequence diagram for handling an overlong source name

sequenceDiagram
    participant User
    participant AdoptProject
    participant Filesystem
    participant GitHub

    User->>AdoptProject: --source spec
    AdoptProject->>Filesystem: _is_there(candidate)
    Filesystem-->>AdoptProject: OSError ENAMETOOLONG
    AdoptProject->>AdoptProject: _source_unreadable(--source spec, exc, whether a repository is there)
    AdoptProject-->>User: source-unreadable, no GitHub URL
    Note over AdoptProject,GitHub: GitHub is not queried and no unclonable URL is offered
Loading

Flow diagram for refused source-stat errors

flowchart TD
    A[Source stat fails] --> B{errno is ENAMETOOLONG?}
    B -->|Yes| C[Refuse as unreadable path]
    C --> D[Say whether a repository is there]
    D --> E[Do not offer GitHub URL]
    B -->|No| F{name is absent?}
    F -->|Yes| C
    F -->|No| G[Retain ambiguous-source behavior]
    G --> H[Offer GitHub repository URL]
Loading

File-Level Changes

Change Details Files
Treat filesystem name-length failures as path-only source refusals, preventing impossible GitHub clone URLs.
  • Extend _source_there to classify ENAMETOOLONG like an unresolvable path, while preserving repository ambiguity and URL suggestions for other I/O errors.
  • Update the refusal explanation to state that neither a local path nor a GitHub repository can be identified when the name exceeds filesystem limits.
  • Cover long owner, repository, and GitHub-host spellings with real stat checks and Windows skips.
  • Verify that normal non-length I/O failures retain the existing GitHub URL recommendation.
  • Verify end-to-end plan behavior: exit 2, no URL or GitHub wording, no plan/work directory, and no gh invocation.
adopt-project.py
tests/test_adopt_unsearchable_source.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1003 When a user cannot enter a --source directory or a recorded clone directory, refuse with source-unreadable instead of retrying the clone or producing a traceback. ✅
#1003 For a --source spelling whose stat fails with ENAMETOOLONG, do not describe an impossible GitHub repository or offer a URL that cannot be cloned. ✅

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

RV1122: independent review of #1122 at e75587e7: NOT READY

I reviewed head e75587e7 on a scratch merge with main 823624d9: merge commit b214e6be, tree 9a20747c.

How the probes ran:

  • as uid 1000, with Python 3.12.3, on a file system with NAME_MAX 255 and PATH_MAX 4096;
  • with a stub gh first on PATH that records each call and refuses it, GH_TOKEN=bogus and an empty GH_CONFIG_DIR;
  • with GIT_SSH_COMMAND=false, and the gate's empty global git config.

The writer's claims, checked

  • The change. It is the one guard line in _source_there and its docstring, and nothing else in adopt-project.py. No refusal code, help text or docs/cli.md text changes. Confirmed by git diff 6da757cb e75587e7.
  • The new rows. All 5 fail on main and pass at the head. Confirmed: with main's adopt-project.py put back, all 5 fail. The 4 in-process rows fail at refusal.detail ==, and the child row fails at its refusal line.
  • Real stat, and the controls. The in-process rows use a real stat (too_long_here asserts ENAMETOOLONG), and each has an EIO control. Confirmed.
  • Merge-trees. All clean. Confirmed; see the table below.

Probes

In-process _source_spec, with a real stat:

case stat main 823624d9 head
A: <300 a's>/Thing ENAMETOOLONG two readings, url path wording, no url
B: testorg/<300 a's> beside ./testorg ENAMETOOLONG two readings, url path wording, no url
H: github.com/<300 a's>/Thing beside ./github.com ENAMETOOLONG two readings, url path wording, no url
E: testorg/Thing, with ./testorg a symlink to <300 a's> ENAMETOOLONG two readings, https://github.com/testorg/Thing path wording, no url
C: testorg/<300 a's> beside ./testorg at mode 000 EACCES two readings, https://github.com/testorg/<300 a's> the same
D: <50 b's>/Thing beside ./<50 b's> at mode 000 EACCES two readings, https://github.com/<50 b's>/Thing the same
F: testorg/ + 4,100 slashes + Thing the raw string gives ENAMETOOLONG Path folds the slashes, so it reads as testorg/Thing the same
G: testorg/Thing from a cwd of 5,144 bytes (over PATH_MAX), nothing there ENOENT GitHub's name the same
G: the same, beside ./testorg at mode 000 EACCES two readings, url the same

plan run as a child gives the same answers. Row E exits 2 on both, with no url at the head. From the 5,144-byte cwd, both main and head read testorg/Thing as GitHub's name and ask gh once.

A deep working directory cannot refuse a short org/repo. _source_spec stats Path(os.path.expanduser(spec)) as a relative name, which the kernel resolves from the cwd itself. Row G shows a short name is never ENAMETOOLONG there.

Windows: no worse than main.

  • CPython's PC/errmap.h (3.12 and main) maps ERROR_FILENAME_EXCED_RANGE (206) and ERROR_PATH_NOT_FOUND (3) to ENOENT, and ERROR_INVALID_NAME (123) to EINVAL. No Windows error maps to ENAMETOOLONG.
  • _absent reads ENOENT, and winerror 123, as nothing there. So on Windows the new clause is never reached.
  • I simulated it in-process, with stat raising each of the three. On main and the head alike:
    • _source_spec('<300 a's>/Thing') returns the spelling as GitHub's name;
    • plan refuses it adopt-not-the-source, and refuses --org <300 a's> as unsafe-value;
    • gh is never called.
  • The tests' Windows skip is right.

macOS reports ENAMETOOLONG for a part over 255 bytes, so the guard works there as it does on Linux. I read this and did not measure it.

Findings

SHOULD-1: the guard asks the errno, not whether GitHub can hold the name, and it goes wrong both ways.

  • A valid org/repo now loses its GitHub reading (row E).
    • With ./testorg a symlink to a name over 255 bytes, plan --source testorg/Thing at the head says only "cannot be read (File name too long), so whether a repository is there cannot be told". It offers no url.
    • Main named both readings and offered https://github.com/testorg/Thing, a repository that can exist.
    • The ENAMETOOLONG comes from the link's target, not from a part of the spelling. So the new docstring's "A part of it is longer than a file system lets a file name be" does not always hold.
    • The trigger is rare: a link that can never resolve, named like the owner. But it is the "valid org/repo refused as a path" case, which this review's brief grades at least SHOULD.
  • A spelling too long to name is still offered a url that can never clone (row C).
  • Fix: decide by the name, using the limits the file already imports.
    • Write the guard as if name is None or len(owner) > GITHUB_OWNER_MAX or len(repo) > GITHUB_NAME_MAX:, with owner and repo split from name, and drop the errno clause.
    • Any ENAMETOOLONG that comes from the spelling itself has a part over 255 bytes, so rows A, B and H keep their answer. Row E gets main's url back. Rows C and D lose theirs.
    • I measured it as mutant R7. Every new row's own asserts pass, and only the 4 controls fail, at the url they pin.
    • So also:
      • give the controls a name GitHub can hold (testorg/Thing under EIO keeps the url);
      • add a row for E (the symlink, url kept) and one for C (mode 000, no url);
      • reword the docstring to say "a name GitHub cannot hold".

NIT-1 (follow-up, outside #1003's words): an owner of 40 to 255 characters, or a repository of 101 to 255.

Mutants

My own mutants, each applied by exact substitution and run against test_adopt_unsearchable_source.py and test_adopt_unenterable_source_and_locale.py (45 tests). After each one, the file was restored and its sha256 (e8a087f5...) checked.

Mutant Result Killed by
R1: url withheld for EIO too killed the 4 in-process controls
R2: name is None or dropped killed 2 of #970's rows
R3: path wording, url still offered killed all 5 new rows
R4: guard on len(spec) > 255 instead of the errno killed the 4 controls
R5: owner part only killed [repository]
R6: not for a github.com/ spelling killed [bare-github-url], [www-github-url]
R7: GitHub's limits on the name, no errno (SHOULD-1's fix) killed only the 4 controls, at the url they pin
R8: the errno, or GitHub's limits killed the same 4 controls

Merge-trees

git merge-tree --write-tree of the head:

Merged with Result
main 823624d9 (#1101, #1102 since the base) clean, tree 9a20747c; it differs from main only in the PR's 2 files, and its diff from main is byte-identical to the PR's own diff
#1123 5c3908f8 clean
#1121 c04bb710 clean
#1120 c55e55e3 clean
#1119 bdcf9952 clean
#1118 c60066cb clean
#1106 504a1dd6 clean (it uses _is_there in a new _recorded_path; that does not overlap _source_there)
#1105 5a4de7f7 clean
#1091 160de118 clean

Gate

The gate ran on the scratch merge b214e6be, in the landing gate's environment, each file alone:

File Result
tests/test_adopt_unsearchable_source.py 32 passed
tests/test_adopt_unenterable_source_and_locale.py 13 passed
tests/test_adopt_source_clone_record.py 100 passed
tests/test_adopt_project_is_the_source.py 23 passed
tests/test_repo_hygiene.py 100 passed
tests/test_scaffold_leg_path_collisions.py 104 passed
tests/test_shape_doctor.py, in 8 shards of 56 445 passed, 3 skipped (:10510, the PowerShell rows, Windows only)

That is 817 passed and 3 skipped, with no failures. pyflakes reports nothing on either changed file, and the diff is ASCII only.

Lane: openreposhape-4 (openRepoShape-4)

… offered its url only where GitHub can hold the name, so `testorg/<300 a's>` beside an unsearchable `./testorg` is offered no url, and `testorg/Thing` through a link too long keeps `https://github.com/testorg/Thing` (#1003)

RV1122's SHOULD-1. #1122's first head refused a name as a path, with no
url, where its `stat` was `File name too long`. It asked the errno, not
the name, and that went wrong both ways:

- `testorg/<300 a's>` beside a `./testorg` at mode 000 stops at
  `Permission denied` before the long part, and was still offered
  `https://github.com/testorg/<300 a's>`, which can never clone.
- `testorg/Thing`, with `./testorg` a symlink to a name too long, is
  `File name too long` because of the link's destination. It lost the
  url of a repository GitHub can hold, which main offered.

`_source_there` now decides by GitHub's limits, `GITHUB_OWNER_MAX` (39)
and `GITHUB_NAME_MAX` (100), which the file already imports. An owner or
a repository over them is refused as a path is, whatever reason the
operating system gave, with the cure for that reason and no url. Any
other name keeps both readings and its url. A part too long for the disk
is over both limits, so `<300 a's>/Thing` keeps the first head's answer.

The four controls now refuse a name as long as GitHub allows (an owner
of 39, a repository of 100) with an I/O error, and expect its url. New
rows: a name one over each limit, or too long for the disk, refused with
an I/O error or a permission, offers no url (10 rows, in-process); the
symlink keeps its url; and `plan` beside a mode-000 `./testorg` offers
none.

An owner of 40 to 255 characters, or a repository of 101 to 255, with
nothing on disk is still handed to `gh`; see #1129.

Lane: openreposhape-4 (openRepoShape-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

RV1122: independent review of #1122 at c01c4d73: READY

This re-check covers fix round 1 (c01c4d73, one commit on e75587e7), which answers my SHOULD-1 from the first round. It ran on a scratch merge with the current main 4ed7c7bf (#1091 has landed since): merge commit 1ed5efaa, tree 37c8aefd.

How the probes ran: as uid 1000 on Python 3.12.3, with a stub gh that records and refuses each call, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, and the gate's empty global git config.

The fix, in both directions

_source_there now offers the url only where GitHub can hold the name: an owner of at most GITHUB_OWNER_MAX (39) characters, and a repository of at most GITHUB_NAME_MAX (100). It no longer asks the errno.

I probed _source_spec in-process with a real stat, at the merge:

case stat answer at c01c4d73
E: testorg/Thing, ./testorg a symlink to <300 a's> ENAMETOOLONG two readings, https://github.com/testorg/Thing (main's answer again)
E3: testorg/Thing, ./testorg/Thing a symlink to <300 a's> ENAMETOOLONG two readings, https://github.com/testorg/Thing
C: testorg/<300 a's> beside ./testorg at mode 000 EACCES path wording, chmod cure, no url
D: <50 b's>/Thing beside ./<50 b's> at mode 000 EACCES path wording, no url
A, B, H: <300 a's>/Thing, testorg/<300 a's>, github.com/<300 a's>/Thing ENAMETOOLONG path wording, no url
owner of 39 / 40 beside its own directory at mode 000 EACCES url https://github.com/<39 c's>/Thing / no url
repository of 100 / 101 beside ./testorg at mode 000 EACCES url / no url
testorg/<100 r's>.git / testorg/<101 r's>.git, the same EACCES url https://github.com/testorg/<100 r's>, without .git / no url
github.com/<39 c's>/Thing / www.github.com/<40 c's>/Thing beside its directory at mode 000 EACCES url / no url
testorg/Thing beside ./testorg at mode 000 EACCES two readings, url (unchanged)
testorg/Thing from a cwd of 5,145 bytes, nothing there, and beside ./testorg at 000 ENOENT, EACCES GitHub's name, gh asked once; two readings and url (unchanged)

plan as a child of row E exits 2, names both readings and offers https://github.com/testorg/Thing, with 0 gh calls, no plan and no work directory.

The new docstring says the name decides, never the reason, and the probes bear it out.

The rows, and why each fails where it should

I ran the section's 17 rows against three versions of adopt-project.py:

  • this head: 17 passed;
  • main 4ed7c7bf: 16 failed and 1 passed. The symlink row passes there, since main already offered its url;
  • the first head's guard, put back on the merge: 12 failed and 5 passed.

These match W1003b's figures. Where each row fails, from --tb=line:

  • On main:
    • the 4 changed rows fail at refusal.detail == (:430);
    • the 10 in-process rows fail at refusal.detail == (:477);
    • the 2 child plan rows fail at their refusal line (:505, :567).
  • On the first head:
    • the 10 in-process rows fail at :477;
    • the symlink row fails at its detail (:534), getting the path wording and not two readings;
    • the mode-000 plan row fails at its refusal line (:567).

Every failure is at the refusal's words, not at a setup step.

The controls are right now:

  • each refuses, alike, a name at GitHub's limit (an owner of 39, a repository of 100) and expects that name's url;
  • the boundary rows (40 and 101) pin the limits from the other side.

refused_for with EACCES builds a PermissionError, which the chmod cure assert proves.

Mutants

These are inverses of the new guard, which is my R7 from the first round. Each was applied by exact substitution and run against test_adopt_unsearchable_source.py and test_adopt_unenterable_source_and_locale.py (57 tests). After each one, the file was restored and its sha256 (44987265...) checked. All 10 were killed.

Mutant Killed by
V1: the first head's guard (the errno only) 12: the 10 in-process rows, the symlink row, the mode-000 plan row
V2: the errno, or GitHub's limits 1: the symlink row
V3: the owner's limit one too high (> MAX + 1) 4: the owner-one-over and bare-github-url-owner-one-over rows
V4: the repository's limit one too high 2: the repository-one-over rows
V5: the two limits swapped 9: [repository] and 8 in-process rows
V6: owner and repository split from the spelling, not the name 2: the bare-github-url-owner-one-over rows
V7: one cap on the whole name (> 39 + 100 + 1) 6: the one-over rows
V8: name is None or dropped 2 of #970's rows
V9: the limits only where the reason is not a permission 6: the 5 EACCES rows and the mode-000 plan row
V10: the limits only where the reason is the length 11: the 10 in-process rows and the mode-000 plan row

The writer's N1 to N6 overlap with V1 to V5. V2 (their N6) dies only on the symlink row, which skips on Windows. CI is Linux, so that is enough.

Merge-trees

git merge-tree --write-tree of c01c4d73:

Merged with Result
main 4ed7c7bf clean, tree 37c8aefd. It differs from main only in the PR's 2 files, and its change has the same git patch-id --stable as the PR's own diff (6da757cb..c01c4d73). #1091 touches none of _source_there, _source_spec or _github_name
#1125 2a53a8e6 clean
#1123 5c3908f8 clean
#1121 1c517375 clean
#1120 c55e55e3 clean
#1119 9b998936 clean
#1118 c60066cb clean
#1106 504a1dd6 clean
#1105 5a4de7f7 clean

Gate

The gate ran on the scratch merge 1ed5efaa, in the landing gate's environment, each file alone:

File Result
tests/test_adopt_unsearchable_source.py 44 passed (32 before, and 12 new)
tests/test_adopt_unenterable_source_and_locale.py 13 passed
tests/test_adopt_source_clone_record.py 100 passed
tests/test_adopt_project_is_the_source.py 23 passed
tests/test_adopt_temp_work_roots.py (the test file imports from it) 9 passed
tests/test_repo_hygiene.py 100 passed
tests/test_scaffold_leg_path_collisions.py 104 passed
tests/test_shape_doctor.py, in 8 shards of 56 445 passed, 3 skipped (:10510, the PowerShell rows, Windows only)

That is 838 passed and 3 skipped, with no failures.

  • pyflakes reports nothing on either changed file, and the diff is ASCII only.
  • Only the two source-unreadable test files name _source_spec, _source_there or adopt's bare source-unreadable (git grep over tests/).

Findings

There is no MUST and no SHOULD. SHOULD-1 is resolved in both directions.

Lane: openreposhape-4 (openRepoShape-4)

This was referenced Oct 21, 2026
@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
brettheap added a commit that referenced this pull request Nov 8, 2026
…eeps a `GNUmakefile` or a `makefile`, which GNU make reads first, the Makefile collision follow-up no longer says the shape's make targets arrive with the merge, and the pinned file says `make` reads the first of the three and the block reaches it only once that file holds it or includes a Makefile that does (#1048) (#1105)

Two sentences said a kept `Makefile` is what holds the shape's block back. GNU make reads the first of `GNUmakefile`, `makefile` and `Makefile` it finds, and only that one, so where the root also keeps `GNUmakefile` or `makefile`, `make` reads that file, and reaches the merged `Makefile` only where that file includes it.

**Measured** (a real `execute`, then `make -n` in the clone, for each kept name; r1046's NIT-B, repeated; the include rows are RV1105's SHOULD-1):

| the source keeps | file `make` reads | after `cat shape/Makefile >> Makefile`, `make -n bootstrap` |
|---|---|---|
| `Makefile` | `Makefile` | exit 0 |
| `Makefile` + `makefile` | `makefile` | exit 2, "No rule to make target 'bootstrap'" |
| `Makefile` + `GNUmakefile` | `GNUmakefile` | exit 2, same |
| all three | `GNUmakefile` | exit 2, same |
| `Makefile` + a `makefile` or `GNUmakefile` that ends in `include Makefile` | that file | exit 0, runs `python3 scripts/bootstrap.py` (exit 2 before the merge) |
| `makefile` or `GNUmakefile` alone | that file | no merge to make: the shape's `Makefile` is the root's, and `make contracts-dir` has no rule until that file holds the block or includes `Makefile` |

**What changed**
- `scripts/shape_materialize.py`, `collision_follow_up`: takes `reads_first`, the file GNU make reads before the shape's `Makefile`. Where it is set, the Makefile's follow-up says GNU make reads that file, the first of the three it finds, and that the shape's make targets reach `make` there only once that file has them, or includes a Makefile that does; until then `python3 scripts/bootstrap.py` is what the shape's `make bootstrap` runs. Where `scripts/bootstrap.py` went aside too, it says neither command runs the shape's bootstrap until the script is merged, and then `python3 scripts/bootstrap.py` runs it. Where the root keeps only the source's `Makefile`, the sentence is what it was, byte for byte. `GNU_MAKE_READS` joins `SHAPE_MAKE_TARGETS`.
- `adopt-project.py`, `_predict_collisions`: reads the file from the plan's root paths as `_makefile_follow_up` does, so the `CONTRACTS_DIR` line and the collision line of one plan name the same file.
- `templates/assembly-root/AGENTS-shape.md`: says `make contracts-dir` prints the block wherever `make` reads the shape's Makefile, names the three files in the order the code reads them, and says that where a `GNUmakefile` or `makefile` is kept, beside a `Makefile` or instead of one, `make` reads it, and not the shape's Makefile unless that file includes it. It is one verbatim copy: the digest in every project's `contracts/shape-pin.yaml` is rendered from its bytes at adopt and scaffold time, and no digest or `tree_sha256` of it is stored in this repository, so there is no pin to move here; each project re-pins through `update-shape.py` as for any template change.
- `tests/test_pinned_copy_text.py`: `ADOPTED_SENTENCES` holds the new text.
- `tests/test_adopt_collision_make_reads.py` (new, 31 rows): the follow-up per kept set, the two lines of one plan naming one file, no collision line where no `Makefile` is kept, the two name tuples equal, the pinned file naming every file the code reads first, the script-aside tail where `make` reads another file, and, in a real adoption's clone, `make -n` held to both sentences for each kept name and for a kept `GNUmakefile` or `makefile` that ends in `include Makefile`.

**Fix round 1** (RV1105's review, `b28f40de0` on top of `5a4de7f76`)
- **SHOULD-1:** the follow-up said the targets reach `make` "only once `X` holds them, or includes a Makefile that does, and not with that merge alone". The last clause is untrue where `X` already ends in `include Makefile`: the merge alone brings them. The clause is gone, and the docstring paragraph, the `SHAPE_MAKE_TARGETS` comment and the `ADOPTED_SENTENCES` comment now say the merge alone brings the targets only where that file includes `Makefile`.
- **NIT-1 (and RV1121's NIT-3):** the follow-up now uses the words of #1121's NOTE: "has them", and "is what the shape's `make bootstrap` runs". `_next_steps` is not touched.
- **NIT-2:** the script-aside tail, where `reads_first` is set, said "neither `make bootstrap` nor `python3 scripts/bootstrap.py` runs the shape's bootstrap before both merges are made", which reads as if `make bootstrap` runs it after them. It now says neither runs it until the script is merged, and then `python3 scripts/bootstrap.py` runs it. I did not keep "before both merges": where `X` is given the targets itself, as the sentence before invites, `make bootstrap` runs the shape's bootstrap once the script is merged, with no `Makefile` merge at all.
- **NIT-3:** the template's "and never the shape's Makefile" is now ", and not the shape's Makefile unless that file includes it". `ADOPTED_SENTENCES` changes with it. No other copy or regex in `tests/` pins that sentence (`git grep` for "never the shape" and "reads the first of them").
- **NIT-4:** the `collision_follow_up` docstring no longer says `execute` reports through it. It says `plan` predicts the follow-ups and stores them in `follow_ups:`, the split commit carries those, `execute` names its collisions by path only (its `beside` lines and the "WRITTEN BESIDE" list), and `Materialized.follow_ups()` has no caller. The method stays.
- NITs 5 to 8 are left to the lane.

**Rows of this round:** `tests/test_adopt_collision_make_reads.py` and `tests/test_pinned_copy_text.py`, 118 rows. With this round's test files on `5a4de7f76`'s code, 17 fail and 101 pass; on `b28f40de0` all 118 pass. The 17 fail for the right reason:

| rows | why they fail on `5a4de7f76` |
|---|---|
| 4 collision rows (`Makefile+makefile`, `Makefile+GNUmakefile`, all three, `Makefile+GNUmakefile+scripts`) | the line holds "not with that merge alone" |
| 2 new script-tail rows (`GNUmakefile`, `makefile`) | the line holds "before both merges are made" |
| 2 new include rows in a real clone (`GNUmakefile`, `makefile`) | `make -n bootstrap` exits 2 before the merge and 0 after it alone (both asserted first, and passing), then the split message holds "not with that merge alone" |
| 7 clone rows, the pinned-file row and `test_a_scaffolded_root_is_told_its_makefile_exports_none` | the pinned file still says "and never the shape's Makefile" |

**Mutants of this round**, each run against the 118 rows on `b28f40de0`, each file restored and its sha256 checked:

| id | mutant | rows failing | result |
|---|---|---|---|
| MA | ", and not with that merge alone" put back | 6 | killed |
| MB | the script-aside tail put back ("before both merges are made") | 3 | killed |
| MC | the template's "and never the shape's Makefile" put back | 9 | killed |
| MD1 | include rows: `not in message` inverted to `in message` | 2 | killed |
| MD2 | include rows: after the merge, `returncode == 0` inverted | 2 | killed |
| ME | "holds them" put back | 6 | killed |
| MF | "the shape's `bootstrap` target runs" put back | 5 | killed |

**Gate** on `b28f40de0`:

| files | passed | failed | skipped |
|---|---|---|---|
| `test_adopt_collision_make_reads`, `test_pinned_copy_text`, `test_adopt_make_reads_first`, `test_adopt_contracts_dir`, `test_adopt_printed_sentences`, `test_adopt_e2e`, `test_repo_hygiene`, `test_scaffold_leg_path_collisions` | 470 | 0 | 0 |
| `test_shape_doctor`, 8 shards (62 + 56 + 54 + 56 + 60 + 57 + 58 + 45 collected) | 445 | 0 | 3 (PowerShell rows, Windows host only) |
| **total** | **915** | **0** | **3** |

**Merge-tree:** `b28f40de0` against main `4ed7c7bf5` is clean, and so is it against each open PR that touches these files: #1104, #1106, #1118, #1119, #1120, #1121 (`1c5173758`), #1122, #1123 and #1125.

**Round 0** (`5a4de7f76`, main `6da757cbe` merged in): on main's code (plus the constant alone), 12 of the then 27 rows failed and 15 passed; the 30 test files that run the changed code or read the template gave 2109 passed, 0 failed and 4 skipped (3 PowerShell rows in `test_shape_doctor.py`, 1 MedxEHR row in `test_adopt_plan.py` that needs a clone `SHAPE_MEDXEHR_CLONE` names); nine mutants each failed 1 to 11 of the then 114 rows.

**Not here**
- The third box of #1048, the `_next_steps` NOTE in `adopt-project.py`, is covered by the change #1047 makes to it: see #1047 and #1121.
- `templates/assembly-root/AGENTS-shape.md` line 19 and the five-commands table say `make bootstrap` without this caveat. They say nothing about a kept file, so they are outside #1048's two sentences.

Closes #1048

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

This branch has not been deployed

No deployments
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