Repository navigation
shape_advisory.py: a leg's AGENTS.md committed as a symlink is read through it inside the commit, and one out of the commit leaves whether it is a leg unknown, so adopt-project.py plan and check stop calling the leg a single repository (#1163) - #1270
brettheap wants to merge 23 commits into
Conversation
…he root that is a link into a folder this user cannot search is said once, `<name>: cannot be read (<reason>)` with the chmod cure, never absent, and no way in is offered over it (#1110) Slice A of #1110: the eight sites in `shape-doctor.py` and `scripts/shape_advisory.py` that asked `exists()` or `is_file()` of a name directly in the root now ask `is_a` / `_is_a`, which raise a refused `stat`. - `what is here` names each name it cannot read in validate-pins.py's words, with this doctor again as the NEXT and the gate's advice as the WHY: the one row that carries the cure. - `the way in` offers neither command while the `.git` cannot be read, and points at `what is here`; `the other way in` and the Triad row are silent. - `naming` classifies the folder's own name and says origin's was not read. - `origin_org`, `repo_local_origin_name` and `repository_names` raise the refused `stat` (`carries_git`, `_is_a`) where they answered as for a folder with no repository. - `shape_advisory.read()` answers `not-answered` (silent) with the `.git`'s line in `why`, where 3.14 said `not-a-repository`; no new state. - `agent files` says the line with the cure: a `note` for a rendered file nothing pins, a FINDING for the pinned `AGENTS-shape.md`. The eight entries leave `KNOWN_OPEN`; the three family sites stay, slice B. New rows: tests/test_doctor_unsearchable_links.py. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le is still a repository to the doctor's `.git` questions, and the advisory names a `.git` refused for another reason than permission (#1110) Two rows the first round of mutants showed missing: `carries_git` asked for a directory only (D8) and `_read` catching only `PermissionError` (A2) both passed the new rows. The `.git` questions now also answer for a `--separate-git-dir` checkout, whose `.git` is a file, and the advisory row runs for an I/O error as well as a permission. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he review found alive: a `.git` that loops or dangles is no repository, a pinned agent file beside an absent one is a FINDING, `naming` says origin was not read on every exit, and the advisory reads a gitdir file as a repository (#1110) The review of #1144 (r1144, READY on condition) ran 29 mutants of the new code against the 17 rows; R01, R07, R13, R14, R15, R27 and R28 survived. Eight rows and one control kill them, and all 47 mutants (the writer's 18 and the review's 29) are now killed. - `.git -> .git` (ELOOP) and `.git -> ../nowhere/.git` (ENOENT): controls that hold on main (`the way in` is the scaffold, `what is here` an ordinary directory, no `origin` asked, the advisory `not-a-repository`), and `carries_git` False (R01, and the hygiene scan's R29 as well). - A pinned `AGENTS-shape.md` it cannot read beside an absent `CLAUDE.md`: a FINDING, the verdict `INVALID (agent-files)` (R07). - `naming` with the `.git` refused, for a name that classifies, one the policy does not classify (exit 1) and a naming script that refuses (exit 2): the status is the policy's, `ok` for the first, and the clause that origin's name was not read is on every branch (R13, R14, R15). - The advisory row gets the `--separate-git-dir` control: `_read` and `repository_names` take a gitdir file for a repository (R27, R28). - Line 98 is cut to the file's 79 columns. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e split, one per condition, so SonarCloud's python:S9073 has nothing to say of the new rows (#1110) One is a new row's `not row["why"] or PERMISSION_WHY not in row["why"]`, now `PERMISSION_WHY not in (row["why"] or "")`; the other is the first round's `"AGENTS-shape.md" in pinned and "AGENTS.md" not in pinned`, now two asserts. No behaviour changes: the 25 rows and the 47 mutants give the same results. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…cannot read leaves whether the folder is a leg unknown, never no leg's file, so `the way in` offers neither command and points at `what is here`, which names it, and `the other way in` and the Triad row are silent (#1110) #1110's twelfth site, found by r1144: `shape_advisory._leg_by_agents_md` read any exception as "no leg's file" (`except Exception: return None`). Over a clone of acme/Orbit whose AGENTS.md is the spec or the code leg's, linked into a folder at 000 or itself at 000, #1144's head offered `adopt-project.py plan ... --project Orbit` in `the way in`, `setup.sh` in `the other way in`, and the Triad advice, on 3.12 and on a 3.14-like shim. - `_leg_by_agents_md` raises a refused `stat` or read (#430's rule: only ENOENT, ENOTDIR, EBADF and ELOOP, and Windows' 21, 123 and 1921, are nothing there). Bytes that are not UTF-8 still declare no leg; anything else ends in `read()`'s net. - `_read` answers `not-answered`, with `AGENTS.md: cannot be read (<reason>)` in `why` and the OSError in the new `Reading.refused`, unless the folder's name is a leg's, which still answers. The `.git` case carries `refused` too. - `leg_clone` raises `refused`; `the way in` (`way_in_unread_row`, shared with the `.git` row) offers neither command, and `the other way in` is silent. - `what is here` reads AGENTS.md through (`read_through`), so a file at 000 is named there with the cure. tests/test_doctor_unsearchable_links.py: 20 new rows (3 mode, 17 in process), and `leg_clone` over a refused `.git` now raises in #1144's row. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ere` to reading through a regular `AGENTS.md` alone: a directory or a named pipe of that name is listed and not read, the pipe's doctor run under a timeout, and a refused `Makefile` is listed like any other name (#1110) The review of #1159 (r1) found two mutants of `shape-doctor.py` alive against all 45 rows: - M3, `read_through` reading any kind of file (`is_a(path, lambda _m: True)`): over a repository whose `AGENTS.md` is a directory, `what is here` said `AGENTS.md: cannot be read (Is a directory)`, and over one whose `AGENTS.md` is a named pipe the doctor never returned (SHOULD-2); - M14, `what is here` reading through every name it lists (NIT-4). New rows, each a control that holds on #1144's head as here: - `test_an_agents_md_that_is_no_regular_file_is_listed_and_not_read` [directory, named-pipe]: the doctor run as a person runs it, given `DOCTOR_SECONDS` (120) by `doctor_within`, so a run that opens the pipe fails the row instead of hanging the suite. `what is here` says `a git repository, an AGENTS.md`, nothing says `cannot be read`, and the clone is offered both ways in. The pipe row skips where `os.mkfifo` is absent, and nothing opens the pipe in the test's own process. - `test_what_is_here_reads_through_agents_md_alone`: a leg's clone whose `Makefile` read is refused, in process: `what is here` says `a git repository, an AGENTS.md, a Makefile`, and no row says `cannot be read`. `offered_both_ways_in` now reads its rows through `offers_both_ways_in`, which a whole run's rows (with `machine`) can be given too. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lder named as a leg is still that leg, as it was before #1159 and as it is where the disk's file is refused, so `adopt-project.py plan` and `check` print the leg's note over it again (#1110) The review of #1159 (r1, NIT-2) measured that a `Refusal` raised while reading a commit's `AGENTS.md` (`Tree.text`, `cat-file blob`) no longer fell back to the leg's name. #1144's head read it as no leg's file and then asked the name, so a folder named `<P>-spec` was `leg-clone`; #1159's head sent the `Refusal` straight to `read()`'s net, `not-answered`, and `adopt-project.py` stopped printing the leg's note there. `_read` now catches a `Refusal` from `_leg_by_agents_md` as it catches a refused `OSError`: the folder's name answers where it is a leg's, and with none the `Refusal` goes on to `read()`'s net, `not-answered`, as on #1159's head. This is the review's mutant M17. `refused` stays None: no `OSError` was refused. New row, a control that holds on #1144's head as here: `test_a_leg_by_its_name_is_one_while_git_cannot_give_its_agents_md`, a committed clone named `Orbit-spec` whose `cat-file blob` of `AGENTS.md` raises `Refusal`: `leg-clone`, by name, the spec leg. On #1159's head `0f9aff05` it fails with `not-answered`. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot read keeps `origin_org` in its `--json` detail, as the leg's own row and the policy row keep it with no command, where it said None while `origin` was read (#1110) The review of #1159 (r1, NIT-5) found that the `AGENTS.md` row gave `origin_org: null` while the `naming` row of the same run read `origin`, and that the docstring's reason, "None because no command is offered with them", did not hold: the leg's own row keeps `origin_org` with no command, and so does the policy row (`way_in_policy_row`). `way_in_unread_row` now takes `org`: None for a `.git` that cannot be read, whose `origin` is not read either (unchanged), and `origin_org(root)` for the `AGENTS.md` row, read only where `carries_git` answered and `leg_clone` raised. `suggested_project` stays None, since no `--project` is offered, and the docstring says so. `no_leg_way_in` now expects `origin_org: "acme"` (ORIGIN is `acme/Orbit`), so the 7 rows that use it pin the value: with the doctor of `34ed0a95` they fail, 7 failed and 42 passed. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts name where git cannot give its `AGENTS.md` no longer asks `Reading.refused`, which #1144's `Reading` does not have, so it is the control on #1144's head its docstring says it is (#1110) Measured with the file laid over #1144's head `89486f69`, the row's state, `by` and role checks pass there, and only `assert reading.refused is None` failed, with `'Reading' object has no attribute 'refused'`. `refused` is set only together with `not-answered`, so the line asked nothing of a `leg-clone` reading. Without it the row passes on `89486f69` and on this head, and still fails on `0f9aff05`, whose `Refusal` skips the name (`not-answered`, at the state check). Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… row names its sibling row on one line, so a grep for the row's name finds the reference (#1110) Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
b1f45fa is #1144's squash on main. Its own files (scripts/shape_advisory.py, shape-doctor.py, tests/test_doctor_unsearchable_links.py and tests/test_path_guard_hygiene.py) take the branch's side: S's tree is #1144's final head 5b38c3a's tree, and merge 1 already brought that in. The merged tree equals HEAD's tree, c5d0bc7. This is the second of the three merges that restack #1159 after #1144 landed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… through it inside the commit `adopt-project.py plan` and `check` read, and one that leads out of the commit leaves whether it is a leg unknown, so neither calls the leg a single repository (#1163) `Tree.text(rel, follow=True)` follows a symlink at `rel` inside the commit as git follows one there (`git cat-file --batch-check --follow-symlinks`): to a file, which is read out of the commit's objects; to nothing that is a file (a link that dangles, loops or runs through a file, or leads to a directory or a submodule), which is no leg's file, as on the disk. A link that leads out of the commit raises `OutOfTheCommit`, and `_read` answers it as a file this user cannot read (#1110): by the leg's name, or `not-answered`, silent. Only `_leg_by_agents_md` asks it; the doctor reads the disk, which the operating system follows either way. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…` committed as a symlink, each failing on #1159's head `5370b018`: to its own file, beside it or through a linked folder, at 000 in or beside the repository, and out of the commit; with controls for a link to nothing, a regular leg and a single repository (#1163) Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a committed link through `Tree.text_through`, and `Tree.text` keeps its one argument, so #1159's row that stands in for `Tree.text` with a refusal reads `Refusal` again, not `TypeError` (#1163) `text(rel, follow=True)` made `_leg_by_agents_md` pass a keyword that the stand-in in `tests/test_doctor_unsearchable_links.py`'s `test_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice` (`def text(self, rel)`) does not take, so `read()`'s net named a `TypeError` where the row asks for `Refusal`. The decision is unchanged. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9e4aab8 is #1159's final head: update-branch's merge of main 1212295 into 5370b01, the commit this branch was built on. This is the first of the three merges that restack #1163 after #1159 landed as 46fe6ac. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
46fe6ac is #1159's squash on main. Its own files (scripts/shape_advisory.py, shape-doctor.py and tests/test_doctor_unsearchable_links.py) take the branch's side: S's tree is #1159's final head 9e4aab8's tree, 863f70c, and merge 1 already brought that in. The merged tree equals HEAD's tree. This is the second of the three merges that restack #1163 after #1159 landed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th a `.` segment (`./docs/AGENTS.md`, `docs/./AGENTS.md`, `.//docs/AGENTS.md`), which git 2.43's `--follow-symlinks` answers `dangling`, is asked again with those segments dropped, so `plan` and `check` name the leg there too (#1163) r1163's SHOULD-1. git 2.43 looks a `.` segment up as a name no tree holds, so the follow answered `dangling` and the commit reading advised over a leg the doctor names. Where git says `dangling`, `Tree.text_through` now reads the link's own target (`cat-file blob <commit>:AGENTS.md`), and one that holds a `.` segment before its last is asked again with those dropped (`_dot_segments_dropped`). A last `.` or `/` is kept, so a link through a file stays `notdir`. A target that then begins with `..` is out of the commit, as git answers `../x`; git is never asked a name beginning `../`, which it reads from the working directory and refuses (exit 128, measured). A target with a line break, a carriage return or a NUL is not asked again, and only a line that begins with an object's name is a blob to read, since git's line for a name it did not find begins with that name. `text_through`'s docstring no longer says git follows as `open()` does. Rows: `..._is_the_leg_in_the_commit[dot-slash]`, `[dot-in-the-middle]`, `[dot-and-a-double-slash]` and `..._out_of_the_commit_is_not_answered [relative-after-a-dot]` fail on 2abd817; four controls hold the second ask to the disk's answer. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…link to the leg's own text and one byte 0xff, advised by both readers, so the followed blob is held to "a file that is read and is not UTF-8 declares no leg", and decoding it with `errors="replace"` fails a row (#1163) r1163's SHOULD-2. Its mutant R6 (`.decode("utf-8", "replace")` on the blob `Tree.text_through` reads) passed every row, yet it named the leg in the commit where the disk advises. The control writes `docs/AGENTS.md` as bytes (`linked_clone` takes bytes since the previous commit) and asks both readers: each advises, on 2abd817 as here. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideImplementationAdd commit-aware symlink detection and reading for
TestsCover committed
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-1, 2026-11-08T17:48:11Z) Independent review r1163 of the #1163 fix at I'm r1163, reviewing #1163 at Verdict: NOT READYThere is no MUST. There are two SHOULDs and both are small: one link spelling the fix misses, and one row the tests lack. The fix itself is correct for every other case I measured, and the decision it takes is the right one. The rows, the merge, the counts and the body's tables all hold up. FindingsSHOULD-1: git 2.43 does not follow a
|
| tools | GIT_NO_LAZY_FETCH |
promisor packs | reading |
|---|---|---|---|
| base | unset | 1 -> 2 | advise |
| base | 1 |
1 -> 1 | advise |
| head | unset | 1 -> 3 | leg-clone |
| head | 1 |
1 -> 1 | not-answered (Refusal) |
- The new call inherits
os.environ, asgit_outdoes, socheckandexecute(_lazy_fetch_off) fetch nothing. planwas already a lazy fetcher, asadopt-project.py:13098documents; it now fetches the link's target as well.
What I checked and found right
1. The commit reading. Measured on git 2.43 by /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/catfile_map.py (30 cases, log at /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/map1.log). Each answer from cat-file --batch-check --follow-symlinks is handled as the decision says:
| git's answer | head reads it as | the OS (open()) |
|---|---|---|
<oid> blob |
reads the blob | same file |
<oid> tree |
None | EISDIR |
symlink <n> (../x, absolute, ../Atlas/..., docs/../../Atlas/...) |
OutOfTheCommit |
reads the target |
dangling |
None | ENOENT |
loop (self, A->B->A, 41 links) |
None | ELOOP |
notdir (README.md/AGENTS.md, trailing /) |
None | ENOTDIR |
missing (no AGENTS.md) |
None, and is_link is False |
ENOENT |
| a link into a submodule, or to the gitlink itself | dangling, so None |
leg / EISDIR (known limit) |
- A target with non-UTF-8 bytes or a newline is followed correctly, whether it reaches a file or dangles. Only the first line of git's output is parsed, and git echoes the input name there, never the target.
..segments that stay inside work:docs/../real.md,docs/sub/../../real.md. A..after a linked folder resolves physically, as the OS does (cases 19 and 20).- Chains: 40 links are followed, and the 41st gives
loop, the same as Linux's ELOOP. is_linkis False on the disk, which is right, since the OS follows the link there. Wherels-treefails it falls back to the base reading. That is safe: it never crashes, and a regular file's reading is unchanged.
2. Conventions.
- The new
subprocess.runruns git the waygit_outdoes (/workspace/projects/openRepoShape-worktrees/l1-r1163/scripts/repo_shape.py:1397): the same environment, which inheritsGIT_NO_LAZY_FETCH; no timeout and no locale, as there; stdout parsed as bytes. ls-treegoes throughgit_outwith--and the constantAGENTS.md, which has no glob characters, so--literal-pathspecsis not needed.- Its refusal text leaves out the folder and git's stderr, but only the exception's type name reaches
why. shape_advisory.pyis in none ofCOPIED_VERBATIM,FAMILY_COPIED_VERBATIM,COPIED_FROM_SHAPEorFAMILY_COPIED_FROM_SHAPE(checked in Python: 0 hits). No pin bump is needed.
3. The decision: (b), with (a) for a link out of the commit. It is right. /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/e2e.py covered 16 cases, on base and head, with the doctor measured for each:
- The doctor's Triad and
the way inrows are identical on base and head in all 16. - On head,
planandchecknever call a leg a single repository when the commit holds the leg's file. They never name a leg the commit does not hold. - Head differs from the doctor only in two ways: silence (
out-*) or the base's advice (submodule,dot-slash). not-answeredwithrefused=Nonematches shape_advisory.py and shape-doctor.py: a leg'sAGENTS.mdthis user cannot read leaves whether it is a leg unknown, sothe way inoffers neither command and points atwhat is here, andthe other way inand the Triad row are silent (#1110) #1159's meaning of "unknown", and nothing is refused.- Exit codes:
planexits 0 in all 13 of the body's cases.checkexits 1 in 10 and 0 in 3, on both commits. The body is right.
| case | base: plan / check | head: plan / check | head read(commit=) |
the doctor (same on base and head) |
|---|---|---|---|---|
-> docs/AGENTS.md (leg) |
advisory | leg NOTE | leg-clone | clone of the spec leg |
same, docs/ at 000 |
advisory | leg NOTE | leg-clone | no Triad row; way in: AGENTS.md cannot be read |
| same, the file at 000 | advisory | leg NOTE | leg-clone | no Triad row; cannot be read |
-> ../locked/AGENTS.md |
advisory | nothing | not-answered | clone of the spec leg |
| the same, absolute | advisory | nothing | not-answered | clone of the spec leg |
../locked at 000 |
advisory | nothing | not-answered | no Triad row; cannot be read |
-> ../nowhere (dangling) |
advisory | nothing | not-answered | advisory |
| dangling inside / loop / dir / another file | advisory | advisory | advise | advisory |
| a regular leg file (control) | leg NOTE | leg NOTE | leg-clone | clone of the spec leg |
| none (control) | advisory | advisory | advise | advisory |
-> ./docs/AGENTS.md |
advisory | advisory | advise | clone of the spec leg (SHOULD-1) |
-> sub/AGENTS.md (gitlink) |
advisory | advisory | advise | clone of the spec leg (known limit) |
-> ../Atlas/docs/AGENTS.md |
advisory | nothing | not-answered | clone of the spec leg (known limit) |
6. Known limits. Each of the five is true, measured, and acceptable:
- A link out of the commit is silent. Measured in five cases.
- A link into a submodule reads as nothing. git answers
dangling. - A link back in by the folder's name is out of the commit. git answers
symlink. core.symlinks=false. On head the commit saysleg-cloneand the disk saysadvise; on base both saidadvise. The commit's answer is the true one, so this is acceptable.- Only
AGENTS.mdis followed. Forsingle-repository.yaml -> docs/record.yaml, the commit saysadvisewith the noteit could not be read (YamlError), and the disk saysrecorded-single. This is the same on base and head.
The last one is a real gap and worth a low-priority issue outside v1.2.0. The note it prints is false (the record can be read; it is a link), and plan disagrees with the doctor about a project that recorded staying single. Changing it touches the record question in adopt-project.py (#528), so it should be its own issue, led by the record, with project.yaml and family.yaml named in it.
9. The body.
- The title is 249 characters.
- There is exactly one closing form,
Closes #1163on line 5; a grep for close/fix/resolve before#Nfinds nothing else. - It names the ruling (2026-10-21T10:32:04Z), and has the Lane line and the footer.
- The stacking statement is right:
60c41528's parent is5370b018, and shape_advisory.py and shape-doctor.py: a leg'sAGENTS.mdthis user cannot read leaves whether it is a leg unknown, sothe way inoffers neither command and points atwhat is here, andthe other way inand the Triad row are silent (#1110) #1159 is OPEN with head5370b018onfix/1110-leg-agents-md-unreadable. - Its 13-case table, rows table and gate counts all match what I measured.
The rows (base 5370b018 module, laid into a clone of head; head 2abd8176)
| row | base | head |
|---|---|---|
..._linked_to_its_own_file_is_the_leg_in_the_commit[beside], [through-a-linked-folder] |
2 FAILED (advise) |
2 passed |
..._linked_into_a_folder_nobody_may_search[in-the-repository], [beside-it] (they ran: uid 1000) |
2 FAILED | 2 passed |
test_the_commits_linked_leg_file_is_read_when_the_disks_is_refused |
FAILED | passed |
..._linked_out_of_the_commit_is_not_answered[relative], [absolute], [dangling] |
3 FAILED | 3 passed |
test_a_git_that_cannot_follow_the_link_is_silence_not_advice |
FAILED | passed |
CONTROL ..._by_its_name_is_one_while_its_agents_md_links_out_of_the_commit |
passed | passed |
CONTROLS ..._advised_by_both_readers[dangling, loop, a-directory, through-a-file, another-file] |
5 passed | 5 passed |
CONTROL test_a_regular_leg_agents_md_and_a_single_repository_are_as_before |
passed | passed |
| total | 9 failed, 7 passed in 18.12s | 16 passed in 29.90s |
Every failure on base is the defect: AssertionError: {'state': 'advise', ...} or assert 'advise' == 'leg-clone' / 'not-answered'. After the run the clone was restored with git checkout HEAD --, and its blob is a0b4ab7c, equal to HEAD's.
The mutants (my own, in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/clone, each run alone with -x over the new file)
| id | mutant | result | first row that failed |
|---|---|---|---|
| R1 | LINK_MODE = "100644" |
KILLED | [beside] |
| R2 | is_link says True with no commit (the disk) |
KILLED | [beside] |
| R3 | a failed follow raises nothing | KILLED | ..._git_that_cannot_follow_... |
| R4 | dangling taken as out of the commit, and symlink as nothing |
KILLED | [beside-it] |
| R5 | only an absolute link is out of the commit | KILLED | [beside-it] |
| R6 | the followed blob decoded with errors="replace" |
SURVIVED | SHOULD-2 |
| R7 | the two reads in _leg_by_agents_md swapped |
KILLED | [beside] |
| R8 | OutOfTheCommit(OSError) |
KILLED | [relative] |
| R9 | the except OutOfTheCommit clause removed, so read()'s net answers |
KILLED | [relative] |
| R10 | the out-of-commit reading names a refused |
KILLED | [relative] |
| R11 | a failed follow raises PermissionError, not Refusal |
KILLED | ..._git_that_cannot_follow_... |
| R12 | --follow-symlinks dropped |
KILLED | [beside] |
| R13 | any ls-tree entry counts as a link |
SURVIVED, equivalent: following something that is not a link gives its own blob, tree or gitlink, so every reading is the same | |
| R14 | a link to a tree read as a blob | KILLED | [a-directory] |
12 of 14 killed. After each mutant the file's blob was a0b4ab7c, equal to HEAD's. On the writer's two survivors:
- M11: the argument holds.
_readrequires<root>/.git, so git runs from the root and--full-treemakes no difference. - M10: equivalent through the tools, but the body's reason for it is wrong (NIT-1).
Every command's summary line
worktree add --detach .../l1-r1163 2abd8176: HEAD is now at2abd81765.catfile_map.py: exit 0, 30 cases.dot.sh: 8 spellings, quoted above.e2e.py, base and head: exit 0 and 0, 16 cases each. An earlier run used--project Thingand was void: everyplanwas refused withadopt-not-the-source, exit 2. It was set aside and re-run.- Rows at head:
16 passed in 29.90s. Rows at base:9 failed, 7 passed in 18.12s. mutants.py:killed 12/14.r6.pyandm10.py: quoted above.limits.py: quoted above (core.symlinks, the record, the partial clone).- Gate call 1 (the new file,
test_doctor_unsearchable_links.py,test_shape_advisory.py):112 passed in 125.46s. - Gate call 2 (the seven files):
285 passed in 353.45s. - Gate call 3 (
test_scaffold_leg_path_collisions.py,test_path_guard_hygiene.py,test_no_auto_maintenance.py):283 passed, 2 skipped in 40.66s. Both skips aretest_no_auto_maintenance.py:222: "the session began with GIT_CONFIG_GLOBAL set". - Gate call 4 (the doc trio):
171 passed in 47.00s. - The 3.14-like shim (
/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/shim314/sitecustomize.py), over the new file andtest_doctor_unsearchable_links.py:65 passed in 45.66s. - Merges, against current main:
merge-tree --write-tree 2abd8176 12122952(origin/main): clean, treefe694a20.- shape_advisory.py and shape-doctor.py: a leg's
AGENTS.mdthis user cannot read leaves whether it is a leg unknown, sothe way inoffers neither command and points atwhat is here, andthe other way inand the Triad row are silent (#1110) #1159 has not landed. Its predicted squash onto main (tree863f70c7) with this PR's changes since5370b018added on top gives the samefe694a20, clean. - A plain two-parent merge with that squash conflicts in
scripts/shape_advisory.py, as expected for a stacked branch. Restack it with the three-merge procedure.
- Read-only
gh:pr view 1159: OPEN, head5370b018.api commits/main:12122952.- Two
issue listsearches: no existing issue for a symlinkedsingle-repository.yaml.
What I did not cover
- A git newer than 2.43, so I cannot say whether newer git follows
.segments or what CI's git does. I also did not test Windows or macOS. tests/test_shape_doctor.pyand the full suite, which the brief excludes.- The writer's own M1 to M14, one by one; I ran my own 14 instead.
tree:0partial clones, and theGIT_*_PATHSPECSvariables.- An aside outside this PR:
checkgives noplan-root-symlinkfinding for anAGENTS.mdcommitted as an absolute link (exit 0), but gives one for../locked/AGENTS.md(exit 1). This is the same on base and head, and I did not investigate it.
All my checkouts are clean: /workspace/projects/openRepoShape-worktrees/l1-r1163 is at 2abd8176, and the scratch clone and base worktree under /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/ are clean too.
Lane: openreposhape-1 (openRepoShape-1)
|
(lane openRepoShape-1, 2026-11-08T17:48:57Z) #1270's round for r1163: the branch is restacked onto #1159's squash, and both SHOULDs and both NITs are answered, at head
Lane: openreposhape-1 (openRepoShape-1) |
|
(lane openRepoShape-1, 2026-11-08T18:19:00Z) Independent re-review r1163b of #1270 at r1163b re-review of #1270 (for #1163) at
|
| case | link (committed) | git, first ask | cleaned target, then git | base | head | open() |
disk read() |
doctor |
|---|---|---|---|---|---|---|---|---|
| plain | docs/AGENTS.md |
blob | - | leg-clone | leg-clone | LEG | leg-clone | the leg |
| dot-slash | ./docs/AGENTS.md |
dangling | docs/AGENTS.md: blob |
advise | leg-clone | LEG | leg-clone | the leg |
| dot-mid | docs/./AGENTS.md |
dangling | docs/AGENTS.md: blob |
advise | leg-clone | LEG | leg-clone | the leg |
| dot-dslash | .//docs/AGENTS.md |
dangling | /docs/AGENTS.md: blob |
advise | leg-clone | LEG | leg-clone | the leg |
| dot-dot-slash | ././docs/AGENTS.md |
dangling | docs/AGENTS.md: blob |
advise | leg-clone | LEG | leg-clone | the leg |
| docs-dot | docs/. |
dangling | - | advise | advise | EISDIR | advise | advisory |
| dot-slash-only | ./ |
dangling | (empty): tree | advise | advise | EISDIR | advise | advisory |
| dot-only | . |
dangling | - | advise | advise | EISDIR | advise | advisory |
| dot-slash-dot | ./. |
dangling | .: missing |
advise | advise | EISDIR | advise | advisory |
| dot-slash-dotdot | ./.. |
dangling | ..: symlink 2 |
advise | not-answered | EISDIR | advise | advisory |
| dot-up-locked | ./../locked/AGENTS.md |
dangling | ../locked/...: not asked (by hand: exit 128) |
advise | not-answered | LEG | leg-clone | the leg |
| up-locked (control) | ../locked/AGENTS.md |
symlink 19 | - | not-answered | not-answered | LEG | leg-clone | the leg |
| dot-dslash-up-locked | .//../locked/AGENTS.md |
dangling | /../locked/AGENTS.md: symlink 19 |
advise | not-answered | LEG | leg-clone | the leg |
| dot-docs-upup-locked | ./docs/../../locked/AGENTS.md |
dangling | docs/../../locked/AGENTS.md: symlink 19 |
advise | not-answered | LEG | leg-clone | the leg |
| dot-docs-up-self | ./docs/../AGENTS.md |
dangling | docs/../AGENTS.md: dangling |
advise | advise | ELOOP | advise | advisory |
| dot-docs-up-docs | ./docs/../docs/AGENTS.md |
dangling | blob | advise | leg-clone | LEG | leg-clone | the leg |
trailing /. after the leg's file |
./docs/AGENTS.md/. |
dangling | docs/AGENTS.md/.: notdir |
advise | advise | ENOTDIR | advise | advisory |
trailing / |
./docs/AGENTS.md/ |
dangling | notdir | advise | advise | ENOTDIR | advise | advisory |
trailing /./, //, /.. |
(three links) | dangling | notdir | advise | advise | ENOTDIR | advise | advisory |
| non-UTF-8, leg | ./docs/\xff.md |
dangling | docs/\xff.md: blob |
advise | leg-clone | LEG | leg-clone | the leg |
| non-UTF-8, none | ./docs/\xfe.md |
dangling | missing | advise | advise | ENOENT | advise | advisory |
| LF (control) | ./docs/AGENTS.md\nx |
dangling | - (not asked) | advise | advise | ENOENT | advise | advisory |
| LF in the leg's name | ./docs/a\nb |
dangling | - (not asked) | advise | advise | LEG | leg-clone | the leg (NIT-1) |
same without ./ |
docs/a\nb |
blob | - | leg-clone | leg-clone | LEG | leg-clone | the leg |
| trailing CR | ./docs/AGENTS.md\r |
dangling | - (not asked) | advise | advise | ENOENT | advise | advisory |
| CR in the leg's name | ./docs/a\rb |
dangling | - (not asked) | advise | advise | LEG | leg-clone | the leg (NIT-1) |
| trailing / leading space | ./docs/AGENTS.md / ./ docs/AGENTS.md |
dangling | missing | advise | advise | ENOENT | advise | advisory |
tab, colon, ..foo, ... in the leg's name |
(four links) | dangling | blob | advise | leg-clone | LEG | leg-clone | the leg |
| spelled like an answer, missing | ./docs/x blob |
dangling | <commit>:docs/x blob missing |
advise | advise | ENOENT | advise | advisory |
| spelled like an answer, present | ./docs/x blob |
dangling | blob | advise | leg-clone | LEG | leg-clone | the leg |
40-hex name missing / <hex> blob 9 present |
(two links) | dangling | missing / blob | advise | advise / leg-clone | ENOENT / LEG | same | same |
chain, . in the second link |
docs/AGENTS.md, docs -> ./.agents |
dangling | - | advise | advise | LEG | leg-clone | the leg (Known limit) |
chain, . in the first link only |
./docs/AGENTS.md, docs -> .agents |
dangling | blob | advise | leg-clone | LEG | leg-clone | the leg |
chain, . in both |
./docs/AGENTS.md, docs -> ./.agents |
dangling | dangling | advise | advise | LEG | leg-clone | the leg (Known limit) |
physical .. |
./docs/../AGENTS.md, docs -> .agents/sub |
dangling | blob | advise | leg-clone | LEG | leg-clone | the leg |
| through a link to a file | ./flink/./AGENTS.md, flink -> docs/AGENTS.md |
dangling | notdir | advise | advise | ENOTDIR | advise | advisory |
| through a link out of the commit | ./out/./AGENTS.md, out -> ../locked |
dangling | out/AGENTS.md: symlink 19 |
advise | not-answered | LEG | leg-clone | the leg |
| into a gitlink | ./sub/AGENTS.md / sub/AGENTS.md |
dangling | missing / - | advise | advise | LEG | leg-clone | the leg (Known limit) |
| to the gitlink itself | ./sub |
dangling | missing | advise | advise | EISDIR | advise | advisory |
absolute, with . |
<abs>/./locked/AGENTS.md |
symlink 102 | - | not-answered | not-answered | LEG | leg-clone | the leg |
| crafted: NUL in the blob (index only) | ./docs/AGENTS.md\0x |
dangling | - | advise | advise | ENOENT | advise | advisory |
| crafted: empty link blob | (empty) | tree | - | advise | advise | ENOENT | advise | advisory |
End to end (plan --project Thing --org testorg, then check --plan; scripts/probe.py ... e2e):
| case | base: plan / check | head: plan / check | doctor |
|---|---|---|---|
| plain (control) | 0 leg NOTE / 1 leg NOTE | 0 leg NOTE / 1 leg NOTE | the leg |
./docs/AGENTS.md, docs/./AGENTS.md, .//docs/AGENTS.md, ././docs/AGENTS.md, ./docs/../docs/AGENTS.md |
0 advisory / 1 advisory | 0 leg NOTE / 1 leg NOTE | the leg |
./../locked/AGENTS.md, .//../locked/AGENTS.md |
0 advisory / 1 advisory | 0 nothing / 1 nothing | the leg |
./docs/AGENTS.md/. |
0 advisory / 1 advisory | 0 advisory / 1 advisory | advisory |
./docs/a\nb (the leg) |
0 advisory / 1 advisory | 0 advisory / 1 advisory | the leg (NIT-1) |
chain, . in the second link |
0 advisory / 1 advisory | 0 advisory / 1 advisory | the leg (Known limit) |
No run printed a traceback.
The rows (base: 2abd8176's module a0b4ab7c laid into a scratch clone at head; head 0f8dae6e)
| row | base | head |
|---|---|---|
..._is_the_leg_in_the_commit[dot-slash], [dot-in-the-middle], [dot-and-a-double-slash] |
3 FAILED (assert 'advise' == 'leg-clone') |
3 passed |
..._out_of_the_commit_is_not_answered[relative-after-a-dot] |
FAILED (assert 'advise' == 'not-answered') |
passed |
CONTROL ..._advised_by_both_readers[not-utf-8] |
passed | passed |
CONTROLS [dot-then-through-the-legs-file], [dot-then-a-slash-after-the-legs-file], [dot-and-a-line-break], [dot-to-nothing-spelled-like-an-answer] |
4 passed | 4 passed |
| total | 4 failed, 5 passed in 1.01s | 9 passed in 13.23s |
Each base failure also prints AssertionError: {'state': 'advise', ...}. Afterwards the clone's file was written back from HEAD's blob: 8edad0c8, equal to HEAD's.
The mutants
They ran in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/clone, each alone with -x over the new file, then read(commit=) over the 50 fixtures was compared with head. After each one the file was written back from HEAD's blob and checked to be 8edad0c8.
| id | mutant (head line) | result | first row failed | probe answers changed |
|---|---|---|---|---|
| K1 | CR check dropped (431) | SURVIVED | - | ./docs/AGENTS.md\r advise -> leg-clone (disk ENOENT); ./docs/a\rb advise -> leg-clone (NIT-1) |
| K2 | NUL check dropped (431) | SURVIVED | - | only the crafted NUL blob (no checkout can hold one): advise -> leg-clone |
| K3 | LF check dropped (431) | KILLED | [dot-and-a-line-break] |
LF control |
| K4 | .. check by prefix (560) |
SURVIVED | - | ./..foo/AGENTS.md and ./.../AGENTS.md leg-clone -> not-answered (contrived names) |
| K5 | the second ask's symlink taken as nothing (563) |
SURVIVED | - | 3 cases not-answered -> advise (SHOULD-1) |
| K6 | fullmatch -> match (567) |
KILLED | [dot-to-nothing-spelled-like-an-answer] |
./docs/x blob advise -> leg-clone |
| K7 | the second ask asks rel again (563) |
KILLED | [dot-slash] |
17 |
| K8 | only a leading ./ dropped (434-437) |
KILLED | [dot-in-the-middle] |
dot-mid |
| K9 | the .. check on the raw target (557-560) |
KILLED | [relative-after-a-dot] (why is the Refusal's) |
dot-up-locked's why |
| K10 | _follow asks HEAD:, not <commit>: (524) |
SURVIVED | - | none: no row or probe has HEAD differ from the commit read (check reads plan.source_commit). The first round's lines share this gap. Low priority |
| K11 | a cleaned target that starts with / not asked again (562) |
KILLED | [dot-and-a-double-slash] |
2 |
| K12 | the second answer kept only when it is a blob (563) | SURVIVED | - | same 3 as K5 (SHOULD-1) |
| R6 | r1163's decode("utf-8", "replace") (570) |
KILLED | [not-utf-8] |
- |
| X8 | the writer's: every dangling asked again |
SURVIVED, equivalent | - | none of 50 (NIT-2 on its cost) |
| X9 | the writer's: no absolute check | SURVIVED, equivalent | - | none of 50 |
Mutants of my own (K1-K12): 6 of 12 killed. All 15 together: 7 of 15 killed. The rows proposed in SHOULD-1 and NIT-1 kill K5, K12 and K1. That would leave K2, K4 and K10 as accepted survivors, with X8 and X9 equivalent.
Every command's summary line
worktree add --detach .../l1-r1163b 0f8dae6e: HEAD is now at0f8dae6ec.- Read-only
gh:pr view 1270: OPEN, draft, head0f8dae6e, 2 files, closing[1163], title 249 characters, MERGEABLE.pr view 1159: MERGED, merge commit46fe6ac1, head9e4aab8c.api commits/main:9729315f, which equals the localorigin/main.pr checks 1270: SonarCloud pass, package pass, tests-git-bash pass, tests pending.
- Restack: trees, delta sha256 and patch-id as quoted.
merge-tree 2abd8176 9e4aab8cgivesfe694a20;merge-tree 6c4c8131 46fe6ac1conflicts inscripts/shape_advisory.pyonly. - Merge with current main:
merge-tree --write-tree 0f8dae6e origin/main(9729315f) is clean, tree7e3bbee5. It differs from main only in the two files. probe.py build: 50 fixtures.measurehead and base: exit 0, 50 cases each.measure ... e2e: 11 cases each, exit 0.- Raw git:
-zreads an LF name as one request; a trailing CR is removed; an inner CR is kept; a NUL cuts the name;../xexits 128;..givessymlink 2;docs/AGENTS.md/.givesnotdir 57. partial.py,m10.py,count.pyandaside.py: quoted above. SHA-256 probe: headleg-clone, baseadvise, diskleg-clone.- Rows: head
9 passed in 13.23s, base4 failed, 5 passed in 1.01s. mutants.py:killed 7/15.fixrows.py: quoted under SHOULD-1.- Gate call 1 (the new file,
test_doctor_unsearchable_links.py,test_shape_advisory.py):121 passed in 100.84s. - Gate call 2 (the seven files):
285 passed in 242.72s. - Gate call 3 (
test_scaffold_leg_path_collisions.py,test_path_guard_hygiene.py,test_no_auto_maintenance.py):283 passed, 2 skipped in 32.29s. Both skips aretest_no_auto_maintenance.py:222: "the session began with GIT_CONFIG_GLOBAL set". - Gate call 4 (the doc trio):
171 passed in 40.56s. - 3.14-like shim (
r1163b/shim314/sitecustomize.py), over the new file andtest_doctor_unsearchable_links.py:74 passed in 50.27s. - Gate call 1 on the predicted merge with
9729315f(tree7e3bbee5read into therowsclone, then reset):121 passed in 91.31s.
What I did not cover
tests/test_shape_doctor.pyand the full suite, which the brief excludes; I did not re-measure the body's four shard counts.- Any git newer than 2.43, Windows and macOS. I also did not check whether the oldest supported git has
cat-file -z. - The writer's X1-X7 and X10 one by one. I ran my own K1-K12, which overlap X1, X4 and X10 in spirit, and re-ran R6, X8 and X9.
tree:0partial clones, and a fixture where HEAD differs from the planned commit (K10).
All four checkouts are clean: /workspace/projects/openRepoShape-worktrees/l1-r1163b at 0f8dae6ec, and clone, rows (both 0f8dae6ec) and base (7029934c2) under /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/. The evidence (scripts, logs and probe JSON) is in that scratch folder.
Lane: openreposhape-1 (openRepoShape-1)
…k out of the commit spelled `.//../locked/AGENTS.md` and a leg link that ends in a carriage return get a row each, and `_dot_segments_dropped` says what git 2.43 does with an LF, a CR and a NUL in a target (#1163) r1163b's re-review of #1270: one SHOULD and the first NIT. SHOULD-1: no row pinned a cleaned target that leaves the commit on the second ask, where git answers `symlink`. `[relative-after-a-dot]` is caught by the `..` check before git is asked again. The row `[relative-after-a-dot-and-a-double-slash]` (`.//../locked/AGENTS.md`, cleaned to `/../locked/AGENTS.md`) is. It fails on the base module (`a0b4ab7c`, `advise`), and it kills r1163b's K5 (the second answer's `symlink` read as nothing there) and K12 (the second answer kept only when it is a blob). NIT-1: the control `[dot-and-a-carriage-return]` (`./docs/AGENTS.md\r`, advised by both readers) kills K1, the CR check dropped: git removes a trailing CR from the name it is asked, so it would answer for `docs/AGENTS.md` where the disk has no file of that name. The docstring of `_dot_segments_dropped` now says what git 2.43 does: an LF splits the batch input into two requests, git removes a trailing CR, and cuts the name at a NUL. No code changes; `cat-file -z` is not used. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
|
(lane openRepoShape-1, 2026-11-08T18:33:22Z) Lane 1's round on r1163b's review (issuecomment-6725254247):
Lane: openreposhape-1 (openRepoShape-1) |
|
(lane openRepoShape-1, 2026-11-08T18:43:15Z) Independent delta re-check r1163c (r1163b, resumed) of #1270 at I'm r1163b. This is my delta re-check of #1270 (for #1163) at Verdict: READYThere is no MUST and no SHOULD left.
The checks1. The rows, base against headBase is
Afterwards the module was written back from HEAD's blob and checked: 2. The mutantsEach ran alone in
The result is 3. The docstring on an LF, a CR and a NUL (
|
|
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 6 days and 19 hours by commenting |



Was stacked on #1159. This branch started at #1159's head
5370b018, since both PRs change_leg_by_agents_mdand_readinscripts/shape_advisory.py. #1159 has landed as squash46fe6ac1, and the branch is restacked onto main by merges, with no rebase (see Restacked after #1159 landed). The diff againstmainis now this PR's change alone.Closes #1163
Outside v1.2.0 by Brett Heap's ruling of 2026-10-21T10:32:04Z ("leave the follow-ups out of v1.2.0").
What
adopt-project.py planandcheckask the Triad advisory about the commit their plan is written against. In a commit, a symlink is a blob that holds the PATH it points to._leg_by_agents_mdread that path as the file's text and found no leg's opening in it. So both commands said "This is a single repository" over a leg whoseAGENTS.mdis a link, whether or not the target could be read. The doctor reads the disk, where the operating system follows the link, so it called the same checkout the leg.Measured on base
5370b018and head2abd8176, with Python 3.12.3 and git 2.43.0, in the probe environment. Each fixture is a clone ofacme/Orbitin a folderAtlas, with the link committed. Each fixture gotplan --source <Atlas> --project Orbit --org acme ..., thencheck --plan, thenshape-doctor.py --root <Atlas> --json. The "leg's file" istemplates/spec-root/AGENTS.md, rendered as the scaffold renders it.AGENTS.md(committed)planandcheckplanandcheckread(commit=)-> docs/AGENTS.md, the leg's filetriadadvisoryleg-clonedocs/at 000triadadvisoryleg-clonethe way inoffers nothing (#1159)triadadvisoryleg-clonethe way inoffers nothing-> ../locked/AGENTS.md, the leg's file beside the repositorytriadadvisorynot-answeredtriadadvisorynot-answeredlocked/at 000triadadvisorynot-answeredthe way inoffers nothing-> ../nowhere/AGENTS.md(dangling, out of the commit)triadadvisorynot-answered-> docs/AGENTS.md, not committed (dangling)triadadvisorytriadadvisoryadvise-> AGENTS.md(loop)triadadvisorytriadadvisoryadvise-> docs, a directorytriadadvisorytriadadvisoryadvise-> docs/AGENTS.md, a file that is no leg'striadadvisorytriadadvisoryadviseleg-clonetriadadvisorytriadadvisoryadviseplanexits 0 in all 13 cases, on base and head.checkexits 1 in 10 (its ownplan-root-symlinkfinding over the link) and 0 in 3, the same on both.the way in,the other way inandwhat is here(compared on status, the first 110 characters of the reason, and the first 60 of the NEXT). The doctor reads the disk, and this PR changes no disk reading.triadline and no NOTE:not-answeredis silent in_print_triad_advisory..segment is the leg too.-> ./docs/AGENTS.md,docs/./AGENTS.mdand.//docs/AGENTS.mdgave thetriadadvisory at2abd8176, and give the leg's NOTE at0f8dae6e(see Review round).Why
The decision: option (b). The commit's link is followed inside the commit, and option (a) applies only to a link that leads out of it.
docs/at mode 000,planandchecknow print the leg's NOTE while the doctor offers no way in. Neither calls the clone a single repository. shape_advisory.py and shape-doctor.py: a leg'sAGENTS.mdthis user cannot read leaves whether it is a leg unknown, sothe way inoffers neither command and points atwhat is here, andthe other way inand the Triad row are silent (#1110) #1159's rowtest_adopts_reader_reads_the_commits_agents_md_and_never_the_disksalready holds this split for a regular file.Path.is_file()/is_dir()/exists()guard in the shipped tools answers False to any OSError on Python 3.14 (3.12 and 3.13 raise PermissionError), so a directory this user cannot search reads as ABSENT: validate-pins.py skips its workflow@<sha>check, update-shape.py sayscopy-missingandupdate-pin-missing("adopt the project") for files that are there, the doctor says DRIFTED (5 copy-missing), and bump-leg.py's remaining guards, and every other tool's, skip or misreport instead of refusing by name #430's rule (ENOENT, ELOOP, ENOTDIR), and both readers advise there._readtreats it the way shape_advisory.py and shape-doctor.py: a leg'sAGENTS.mdthis user cannot read leaves whether it is a leg unknown, sothe way inoffers neither command and points atwhat is here, andthe other way inand the Triad row are silent (#1110) #1159 treats a file this user cannot read: a folder named as a leg is still that leg, and otherwise the answer isnot-answered, which is silent.refusedstays None, since nothing was refused. ThewhyisAGENTS.md: a symlink out of the commit, so whether this is a leg clone is not known here, and the advisory says nothing.Rejected:
AGENTS.mdlinks to its own committed file would get silence where the commit plainly says "leg". The doctor names that leg, andplanwould not.single-repository.yamlsilences nothing, for the same reason. It would also make the advisory read a file anywhere on the machine because a committed link names it. Silence is the one answer that is false in neither case: the doctor finds the leg (readable), or nothing at all (dangling).Treereads in a commit. That would coverproject.yaml,family.yamlandsingle-repository.yamltoo. But the record's reading also decidesadopt-project.py's record question (ask_about_a_record_only, adopt-project.pyplan: ask aboutsingle-repository.yamlwith its own drop-or-root rule, not the genericambiguous-top-level-yamlquestion #528), so following links there changes whatplanwrites. That is shape_advisory.py: asingle-repository.yamlcommitted as a symlink is read as its path, soplanandcheckadvise with a false(YamlError)NOTE where the doctor saysrecorded-single(#1163) #1272 (see Known limits).How:
Tree.is_link(rel)checks whethergit ls-tree --full-tree <commit> -- <rel>gives mode120000(LINK_MODE). It is never asked of the disk, where the operating system follows a link anyway. A git that cannot answer makes it no link, so a regular file's reading stays byte for byte what it was.Tree.text_through(rel)uses git's own resolution:git cat-file --batch-check --follow-symlinks, fed<commit>:<rel>on stdin. That resolution follows each link from the directory it is in, through a linked folder too (docs -> .agents), up to 40 links. But git 2.43 looks a.segment up as a name, which no tree holds, so it answersdanglingfor./docs/AGENTS.md,docs/./AGENTS.mdand.//docs/AGENTS.md, whereopen()reads the file (r1163). Where git saysdangling, the link's own target is read (cat-file blob <commit>:AGENTS.md). If it is relative, has no line break, carriage return or NUL, and holds a.segment before its last, it is asked again with those segments dropped (_dot_segments_dropped). A last.or/is kept, so a link through a file staysnotdir. A cleaned target that begins with..is out of the commit, as git answers../x; git is never asked a name that begins../, which it reads from the working directory and refuses (exit 128, measured). Its answers map as follows:<oid> blobis read withcat-file blob <oid>, and only a line that begins with an object's name counts: git's line for a name it did not find begins with that name (<commit>:docs/x blob missing);dangling,loop,notdir, a tree, or anything else is None;symlink(out of the tree) raisesOutOfTheCommit;Refusal, which_readanswers by the name, or elseread()'s net givesnot-answered, as for a blob git cannot give (shape_advisory.py and shape-doctor.py: a leg'sAGENTS.mdthis user cannot read leaves whether it is a leg unknown, sothe way inoffers neither command and points atwhat is here, andthe other way inand the Triad row are silent (#1110) #1159).It goes through
subprocess.runwithinput=becauserepo_shape.git_outtakes no stdin, andrepo_shape.pyis a pinned shape copy that must not grow a helper._leg_by_agents_mdasksis_linkfirst. On a link it readstext_through. Otherwise it readstree.text(AGENTS_MD), exactly as before.Tree.textkeeps its one argument. The first cut passedtext(..., follow=True), and shape_advisory.py and shape-doctor.py: a leg'sAGENTS.mdthis user cannot read leaves whether it is a leg unknown, sothe way inoffers neither command and points atwhat is here, andthe other way inand the Triad row are silent (#1110) #1159's rowtest_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice, which stands in forTree.textwithdef text(self, rel), then readTypeErrorwhere it asks forRefusal. Commit2abd8176moves the choice to the caller and changes no behaviour (the 13-case probe is identical at both commits).In a partial clone,
plannow fetches the link's target as well.checkandexecutefetch nothing (_lazy_fetch_off): the new git calls inherit the environment, asgit_outdoes. Measured in ablob:noneclone at0f8dae6e: promisor packs 1 -> 3 withGIT_NO_LAZY_FETCHunset, the readingleg-clone; 1 -> 1 with it set,not-answered(Refusal). A link spelled./docs/AGENTS.mdmeasures the same.Only the commit reading changes.
shape-doctor.pyandadopt-project.pyare untouched.Tests
tests/test_advisory_agents_md_symlink.pyis new, with 27 rows: the first round's 16, below, r1163's 9 (see Review round) and r1163b's 2 (see Review round 2). It imports its helpers from the files that own them: #1159'sno_workspace,symlinkandNO_LEG_WAY_IN, andtest_shape_advisory.py'sadopt_plan,doctor_json,commit_allandrendered_leg_agents.The first round's rows:
5370b0182abd8176..._linked_to_its_own_file_is_the_leg_in_the_commit[beside],[through-a-linked-folder]..._linked_into_a_folder_nobody_may_search[in-the-repository],[beside-it](mode,CANNOT_RUN_AS_ROOT)test_the_commits_linked_leg_file_is_read_when_the_disks_is_refused(stand-in)..._linked_out_of_the_commit_is_not_answered[relative],[absolute],[dangling]test_a_git_that_cannot_follow_the_link_is_silence_not_advice(stand-in)..._by_its_name_is_one_while_its_agents_md_links_out_of_the_commit..._linked_to_no_legs_file_is_advised_by_both_readers[dangling],[loop],[a-directory],[through-a-file],[another-file]test_a_regular_leg_agents_md_and_a_single_repository_are_as_beforeTo get the base numbers, the base's
scripts/shape_advisory.py(blobae535edc) was laid into a scratch clone. Every base failure is the defect itself: the commit reading saidadvise(AssertionError: {'state': 'advise', ...}). The mode rows' doctor assertions, which come before the reading's, pass on base. The clone was then restored, and its file's blob equals HEAD's.Mutants, first round: 14, 12 killed. Each ran alone in a scratch clone at
2abd8176, over the new file (-x). After each one, the file was restored withgit checkout HEAD --and its blob proved equal to HEAD's (a0b4ab7c)._leg_by_agents_mdreadstree.text(AGENTS_MD)alone (no link branch)..._own_file_..._in_the_commit[beside]is_linkdrops its "no commit, no link" guard[beside]is_linkinverted[beside]text_throughtakes a failed follow for success[beside]return None)..._nobody_may_search[beside-it][beside]..._by_its_name_..._links_out_of_the_commitnot-answeredover a link out of the commit (falls through to the advice)[beside-it]whyreworded..._out_of_the_commit_is_not_answered[relative]is_linksays "link" where git cannot answer--full-treedroppedsymlinkanswer check inverted[beside]danglingand the rest are read as blob ids..._advised_by_both_readers[dangling]<commit>:<rel>)[beside]planandcheck, though not throughread()itself (r1163's NIT-1). Measured again at0f8dae6ewith r1163'sm10.py, it differs from the head in four readings: where every git call fails, whateverAGENTS.mdis (a regular leg file, a link, or none; headadvise, M10not-answered), and where onlyls-treefails, over a link to the leg (headadvise, M10leg-clone). None is reachable through the tools. Both build their source withgit show-ref(Source._at) before they print the advisory, so a git that fails every call stops them first, over a link as over a regular file or none;planalso lists the commit withgit ls-tree -r(Source.tree) before it; and the last needs a git that failsls-treeand then answerscat-filein the same repository. The fallback is kept on purpose: a git that cannot say leaves the reading exactly as before.read()._readanswersnot-a-repositoryunless<root>/.gitexists, socwdis always the root, andls-tree's paths are then root-relative with or without the flag. The flag keepsis_linknaming the same entry as<commit>:<rel>for a caller that builds aTreeelsewhere. No caller in this repository does.The gate, call by call, Python 3.12.3, in the probe environment, one basetemp per call, at most 2 at once. The branch is restacked onto main
46fe6ac1. Calls 1, 3 and 4, and the rows and mutants of Review round 2, ran on8e54c32d's files (r1163b's fixes). Calls 2 and 5 to 8 ran at0f8dae6eand were not re-run:8e54c32dchanges the new test file, and inscripts/shape_advisory.pyone docstring and no code.tests/test_advisory_agents_md_symlink.py,tests/test_doctor_unsearchable_links.py,tests/test_shape_advisory.py8e54c32d(121 in 125.80 s at0f8dae6e)tests/test_adopt_single_repository_record.py,test_bom_reads.py,test_bootstrap_non_utf8_branch.py,test_doctor_gitmodules_not_utf8.py,test_doctor_unreadable_paths.py,test_doctor_validator_verdicts.py,test_doctor_next_is_a_command.pytests/test_scaffold_leg_path_collisions.py,tests/test_path_guard_hygiene.py,tests/test_no_auto_maintenance.py8e54c32d(34.55 s at0f8dae6e)tests/test_agents_md_codes.py,tests/test_cli_reference.py,tests/test_repo_hygiene.py8e54c32d(43.43 s at0f8dae6e)tests/test_shape_doctor.py, shard 1 of 4 (113 ids)tests/test_shape_doctor.py, shard 2 of 4 (113 ids)tests/test_shape_doctor.py, shard 3 of 4 (113 ids)tests/test_shape_doctor.py, shard 4 of 4 (110 ids)Shard 4's 3 skips are
tests/test_shape_doctor.py:10516: "the PowerShell line is printed, and powershell.exe exists, only on a Windows host". The 2 in call 3 aretests/test_no_auto_maintenance.py:222: "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one". The probe environment setsGIT_CONFIG_GLOBAL=/dev/null.The first round's own gate, at
2abd8176before the restack, gave the same counts but for the new file's 16 rows (112 in the first call). At its first cut,60c41528, the first call gave 1 failed, 111 passed. The failure was #1159'stest_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice(TypeErrorforRefusal), which2abd8176mends (see Why). The 3.14-like shim was not run in any of the three rounds (r1163b ran it at0f8dae6e: 74 passed over the new file andtest_doctor_unsearchable_links.py). The change asks nois_file,existsorstat: the disk path is unchanged, and the commit path is git alone.Restacked after #1159 landed
#1159 landed as squash
46fe6ac1(S). Its final head was9e4aab8c(F), update-branch's merge of main12122952into5370b018, the commit this branch was built on. The branch is restacked by merges, with no rebase.mainwas46fe6ac1at the fetch, S itself.6c4c8131fe694a20, the treegit merge-tree --write-tree 2abd8176 9e4aab8cpredicted7029934c--no-commit. It conflicted inscripts/shape_advisory.pyonly, as a stacked branch does. The branch's side was taken for S's three files (scripts/shape_advisory.py,shape-doctor.py,tests/test_doctor_unsearchable_links.py) by writing HEAD's blobs (a0b4ab7c,f7dd1ba3,10a15eb5). Thengit ls-files -uwas empty, andgit write-treegavefe694a20, equal toHEAD^{tree}, since S's tree is F's (863f70c7)mainis S, sogit merge origin/mainsaid "Already up to date";7029934cis the restacked headThe delta is unchanged. The
+/-lines ofgit diff -U0 5370b018 2abd8176(this PR as first reviewed) and ofgit diff -U0 46fe6ac1 7029934care the same: 468 lines each, sha25650dc1c81e9c549327bbf88368ccd7f09a9db5438cc08f7cbb16f03bbf3aaef10for both. Each is2 files changed, 467 insertions(+), 1 deletion(-).Review round (r1163: two SHOULDs, two NITs)
SHOULD-1: a link spelled with a
.segment got the advisory (commit7121e222). git 2.43's--follow-symlinkslooks a.segment up as a name, which no tree holds. Measured, each a committedAGENTS.mdlink (git's answer, thencat AGENTS.md):./docs/AGENTS.md,docs/./AGENTS.md,.//docs/AGENTS.mddangling./docs/AGENTS.md/.,./docs/AGENTS.md/danglingdocs/AGENTS.md/.notdir./../xdanglingThe fix is r1163's preferred one, described under How. Asked directly:
<commit>:docs/AGENTS.md/and<commit>:docs/AGENTS.md/.givenotdir;<commit>:../xexits 128 ("outside repository");<commit>:docs/../../xgivessymlink. A name with a line break is two requests. Measured from end to end at0f8dae6e,AGENTS.md -> ./docs/AGENTS.md:planprints the leg's NOTE (exit 0), and so doescheck(exit 1, its ownplan-root-symlink); the doctor says "this is a clone of the spec leg". At2abd8176both printed the advisory.One case goes beyond r1163's words, on purpose.
./../locked/AGENTS.mdwithout its.begins with.., which git answerssymlink(out of the commit), so it is now out of the commit too:not-answered, as../locked/AGENTS.mdis. It is not asked again, since git would refuse that name. Left asdangling, it would have advised, against this PR's decision for a link out of the commit.SHOULD-2: mutant R6 survived (commit
0f8dae6e). The control[not-utf-8]links to the leg's text plus one byte0xff, written as bytes. Both readers advise, on2abd8176as here, and R6 (.decode("utf-8", "replace")) now fails it.NIT-1 is answered under Tests (M10), and NIT-2 under How (the partial clone).
The reviewer's aside, measured once at
0f8dae6e. Nothing is changed for it.checkoverAGENTS.mdcommitted as an absolute link gives:plan-root-symlink(exit 0) where the target exists on this machine;../locked/AGENTS.mdgives one (exit 1): "it climbs above the assembly root, to<TMPDIR>/openreposhape-adopt-…/locked/AGENTS.md, where nothing is". This isadopt-project.py's documented walk.checkasks "an absolute target as it stands, and a climb above the root from the work directory, whereexecutemakes the assembly's clone". Where something is at the path,placewrites the shape's copy beside it and never writes through the link.Rows, base
2abd8176(itsscripts/shape_advisory.py, bloba0b4ab7c, laid into a scratch clone at0f8dae6e) against head0f8dae6e:2abd81760f8dae6e..._is_the_leg_in_the_commit[dot-slash],[dot-in-the-middle],[dot-and-a-double-slash]assert 'advise' == 'leg-clone')..._out_of_the_commit_is_not_answered[relative-after-a-dot]assert 'advise' == 'not-answered')..._advised_by_both_readers[not-utf-8]..._advised_by_both_readers[dot-then-through-the-legs-file],[dot-then-a-slash-after-the-legs-file],[dot-and-a-line-break],[dot-to-nothing-spelled-like-an-answer]Afterwards the clone's file was written back from HEAD's blob, and its blob,
8edad0c8, equals HEAD's.linked_clonelinks astrtarget as spelled, and checks this withos.readlink, since aPathfolds./and//away.Mutants, r1163's round: 11, 9 killed. Each ran alone in a scratch clone at
0f8dae6e, over the new file (-x). After each one, the file was written back from HEAD's blob and proved equal to it (8edad0c8).danglingbranch never runs)[dot-slash].dropped too[dot-then-through-the-legs-file]/dropped[dot-then-a-slash-after-the-legs-file]..check, so git is asked../locked/AGENTS.md[relative-after-a-dot](aRefusal'swhy)[relative-after-a-dot]header[1:2] != [b"blob"])[dot-to-nothing-spelled-like-an-answer][dot-and-a-line-break]danglingtarget asked again,.segment or notblobkept)[dot-to-nothing-spelled-like-an-answer]errors="replace"[not-utf-8].segment, asked from the root, takes the same steps the first ask took after its first link. Only the answer for a name missing before any link changes, fromdanglingtomissing, and both are None. The check saves one git call for every dangling link that has no.segment: the link's own blob is read on everydanglingeither way, and only the secondcat-file --batch-checkis added (measured by r1163b: 2 calls at head, 3 under X8).symlinkbefore it reads a segment, so an absolute target never reaches the second ask. The check is kept as r1163's "it is relative" condition.Review round 2 (r1163b: one SHOULD, three NITs)
Commit
8e54c32d. It adds two rows and rewords a docstring; it changes no code.SHOULD-1: a cleaned target that leaves the commit on the second ask was pinned by no row. For
AGENTS.md -> .//../locked/AGENTS.md, git 2.43 answers the first askdangling. The target with its.segment dropped is/../locked/AGENTS.md. That does not begin with.., so git is asked it, and answerssymlink.text_throughthen reachesif header[:1] == [b"symlink"]: raise OutOfTheCommiton that second answer.[relative-after-a-dot]never gets there: the..check stops it before git is asked again. So r1163b's K5 (the second answer'ssymlinkread as nothing there) and K12 (the second answer kept only when it is a blob) survived all 25 rows. Each turnsnot-answeredinto the advisory over a leg the doctor reads, in three of r1163b's 50 probe cases (.//../locked/AGENTS.md,./docs/../../locked/AGENTS.md, and./out/./AGENTS.mdwithout -> ../locked); the base advised in all three.The row
[relative-after-a-dot-and-a-double-slash]is added to thetargetdict and thehowlist of..._linked_out_of_the_commit_is_not_answered, with the docstring's reason. It needs no extra files. It holds what the other out-of-the-commit rows hold:not-answeredwith thewhyof the PR's decision (a), nothingrefused,planandchecksilent, and the doctor's Triad row "this is a clone of the spec leg"../docs/../../locked/AGENTS.md, which r1163b offered as a second row, is not added: it needs a committeddocs/file, which this test's fixtures do not have, and the row above is killed by the same two mutants.NIT-1: the LF/CR/NUL exclusion. The control
[dot-and-a-carriage-return](./docs/AGENTS.md\r, advised by both readers) is added. git removes a trailing CR from the name it is asked, so without the CR check git would answer fordocs/AGENTS.md, and the commit would name the leg where the disk has no file of that name (ENOENT). It passes on the base and at head, and kills K1 (the CR check dropped)._dot_segments_dropped's docstring now says what git 2.43 does with the three characters, measured by r1163b: an LF splits the batch input into two requests; git removes a trailing CR (an inner CR it keeps, and the check leaves it out too); git cuts the name at a NUL. The old wording said only that git's batch input "would cut" the name into another. The case it leaves is a Known limit (below).cat-file -zis not used.NIT-2 is fixed in the X8 bullet: "one git call", not two. NIT-3 is fixed in the M10 bullet, which now names the reading that also differs: where every git call fails, whatever
AGENTS.mdis (a regular leg file, a link, or none). Its reachability argument stands.Rows, base
2abd8176's module (scripts/shape_advisory.py, bloba0b4ab7c, laid into a scratch clone at8e54c32d) against head8e54c32d:a0b4ab7c8e54c32d..._linked_out_of_the_commit_is_not_answered[relative-after-a-dot-and-a-double-slash]assert 'advise' == 'not-answered')..._advised_by_both_readers[dot-and-a-carriage-return]Afterwards the clone's module and test file were written back from HEAD's blobs and proved equal to them (
9122f59band62bf0a22, the blobs of8e54c32d).Mutants, this round: 3, 3 killed (r1163b's K1, K5 and K12, from the descriptions in its
mutants.log). Each ran alone in a scratch clone at8e54c32d, over the new file (-x), and the module was written back from HEAD's blob and proved equal to it (9122f59b) after each.[dot-and-a-carriage-return]symlinkread as nothing there[relative-after-a-dot-and-a-double-slash][relative-after-a-dot-and-a-double-slash]On r1163b's list, K2 (the NUL check dropped: only a crafted blob holds a NUL, since no checkout can), K4 (the
..check by prefix: only the names..fooand...differ) and K10 (HEAD:for the commit: no fixture has them differ) stay as r1163b accepted them. X8 and X9 are equivalent.The gate at
8e54c32dis calls 1, 3 and 4 of the table under Tests.Known limits
planandcheck, even where the doctor reads more. The doctor finds the leg where the link reaches its file (rows[relative]and[absolute]), and the advisory where it reaches nothing (row[dangling]). The commit cannot tell these apart, and adopt reads no file outside the repository.AGENTS.md -> sub/AGENTS.mdwithsuba gitlink, git 2.43.0 answersdangling(measured, no row), so the commit reading advises. Where the submodule is checked out and holds a leg's file, the doctor reads the leg.symlinkfor../<folder>/AGENTS.md(measured), since the commit cannot know what the checkout's folder is called.core.symlinks=false, the working-treeAGENTS.mdis a plain file holdingdocs/AGENTS.md. The doctor reads that text and advises; the commit reading now gives the leg (measured in process: diskadvise, commitleg-clone; on base both saidadvise). CI is Linux only..segment in a link further along is stilldangling. OnlyAGENTS.md's own target is asked again. Withdocs -> ./.agentsandAGENTS.md -> docs/AGENTS.md, git answersdangling, the commitadviseand the diskleg-clone(measured); withdocs -> .agentsboth sayleg-clone..segment is not asked again, so it is advised while the disk reads the leg. WithAGENTS.md -> ./docs/a<LF>bor./docs/a<CR>b, and that file the leg's, the commit reading saysadvise; the disk reads the leg (leg-clone) and so does the doctor (measured by r1163b with git 2.43.0). The same targets without the./giveleg-clonein both, since git is asked them at the first ask. An LF would be two requests and a trailing CR is removed by git, so_dot_segments_droppedleaves all three out.cat-file -zreads such a name whole in git 2.43, and is not used here..segment answers the first ask, and the second is never made, since it waits ondangling.AGENTS.mdis followed.project.yaml,family.yamlandsingle-repository.yamlcommitted as links are still read as the path their blob holds. Measured for the record (single-repository.yaml -> docs/record.yaml, this repository's own record): the commit reading saysadvisewith the NOTEit could not be read (YamlError), while the disk reading saysrecorded-single. Unchanged by this PR; shape_advisory.py: asingle-repository.yamlcommitted as a symlink is read as its path, soplanandcheckadvise with a false(YamlError)NOTE where the doctor saysrecorded-single(#1163) #1272 measures it and weighs the fix.Not in this PR
project.yaml,family.yamlandsingle-repository.yaml. For the record, this changesadopt-project.py's record question (adopt-project.pyplan: ask aboutsingle-repository.yamlwith its own drop-or-root rule, not the genericambiguous-top-level-yamlquestion #528). That is shape_advisory.py: asingle-repository.yamlcommitted as a symlink is read as its path, soplanandcheckadvise with a false(YamlError)NOTE where the doctor saysrecorded-single(#1163) #1272, which measures that question.shape-doctor.py,adopt-project.pyor the docs. No doc describes how the commit reading treats a link.Lane: openreposhape-1 (openRepoShape-1)
🤖 Generated with Claude Code