Repository navigation
adopt-project.py: a --source, or a plan's source.local_path, with a part of its repository git reads at mode 000 (the .git, its HEAD, objects, refs or config, the git directory a gitdir: line names, or a directory above it) is refused source-unreadable naming that part, never git-failed and never cloned again (#1151) - #1186
brettheap wants to merge 2 commits into
Conversation
… a part of its repository git reads at mode 000 (the `.git`, its `HEAD`, `objects`, `refs` or `config`, the git directory a `gitdir:` line names, or a directory above it) is refused `source-unreadable` naming that part, never `git-failed` and never cloned again (#1151) Measured on main 961640c as uid 1000, git 2.43.0, Python 3.12.3, with a stub `gh` that records and refuses every call and local remotes: - `plan --source` and `check --plan --source` of a repository whose `.git` is at mode 000: `REFUSED git-failed: git rev-parse --is-inside-git-dir ... fatal: not a git repository`, or "fatal: error opening '.../.git': Permission denied" for a `.git` file, naming no `.git`. - a plan's `source.local_path` recorded `cloned: true` whose `.git/HEAD`, `.git/objects`, `.git/refs` or `.git/config`, whose `gitdir:` target, a linked worktree's git directory or the common `.git` above it, a submodule's `.git/modules/<m>`, or a directory above a `.git` link's target is at mode 000: "NOTE ... is a clone `plan` made that is no longer whole (git finds no repository there), so its `source.repository`, testorg/Thing, is cloned again from GitHub", and one `gh repo clone`. - the same with `cloned: false`: `git-failed`, in git's words. - a clone inside another repository's work tree with its `.git/HEAD` at mode 000: `source-not-top-level` for `plan --source` and `cloned: false`; "no longer whole (git reads it as part of the repository at ...)" and cloned again for `cloned: true`. Now each is `REFUSED source-unreadable: <the part> <the source>, <its path>: cannot be read (Permission denied), so <what cannot be told> cannot be told`, exit 2, nothing cloned, `gh` never asked. The part is named as "the `.git/HEAD` in --source '<src>'", "the git directory of the plan's `source.local_path`" or "a directory above the git directory of ...", and a file's cure (`chmod u+r` on the file) or a directory's follows. - `_refused_git_path` asks the parts git 2.43 asks, in its order (`read_gitfile_gently`, `is_git_directory`): the `.git`, followed where it is a link; a `.git` file opened and its `gitdir:` line read; `HEAD`, `lstat`ed and opened unless it is a link; `commondir`; `objects` and `refs` searched; `config` opened. A refused `stat` names the directory this user cannot search (`_where_refused`); a refused open names the file. A part that is not there, or one read but not as git reads it, is left to git, as before. - It is asked ONLY where git found no repository whose top level is the path: in `_top_level` after `--is-inside-git-dir` fails and before `source-not-top-level`, and in `_not_a_whole_clone` before "git finds no repository there" and "git reads it as part of the repository at". A repository git reads is never refused here. - `Plan._git_part_unreadable` words it for a recorded source, with `RECORDED_SOURCE_INSTEAD`; `_source_spec` for `--source`. - #1141's two shared hunks (`SOURCE_UNREADABLE_FILE_PERMISSION`, and `_source_unreadable(..., permission=)`) are copied byte for byte, and a recorded `.git` at mode 000 is worded as #1141 words it, so the two agree whichever lands first. `git merge-tree` with #1141 is clean. Tests: `tests/test_adopt_unreadable_git_parts.py`, 79 rows; 19 mutants, each killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent is 79 columns wide, like its neighbours (#1151) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Reviewer's GuideThe PR introduces a Git-behavior-compatible preflight that identifies unreadable repository components and reports them as precise Sequence diagram for unreadable Git-part source validationsequenceDiagram
participant Caller
participant SourceSpec as _source_spec
participant TopLevel as _top_level
participant GitProbe as _refused_git_path
participant Git as Git
participant Refusal as source-unreadable
Caller->>SourceSpec: validate --source
SourceSpec->>TopLevel: _top_level(path)
TopLevel->>Git: rev-parse
Git-->>TopLevel: no repository or nested repository
TopLevel->>GitProbe: _refused_git_path(path)
GitProbe->>GitProbe: inspect .git, HEAD, commondir, objects, refs, config
alt unreadable Git part
GitProbe-->>TopLevel: _RefusedGitPath
TopLevel->>Refusal: _source_unreadable(...)
Refusal-->>Caller: source-unreadable naming part and cure
else missing or malformed part
GitProbe-->>TopLevel: None
TopLevel-->>Caller: preserve Git-derived refusal
end
Flow diagram for Git-part refusal decisionflowchart TD
A[Source or recorded local path] --> B[Run Git repository query]
B -->|Repository readable| C[Continue normal processing]
B -->|No repository or nested repository| D[_refused_git_path]
D --> E{Git-read part exists and is unreadable?}
E -->|No| F[Preserve Git question or existing refusal]
E -->|Yes| G[Name exact part and permission cure]
G --> H[source-unreadable]
H --> I{Recorded clone?}
I -->|Yes| J[Do not reclone]
I -->|No| K[Return refusal]
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
|
(lane openRepoShape-3, 2026-10-23T00:20:09Z) Independent review (RV1186, opus, ruling 8) of #1186 at head Verdict: READY. A draft outside v1.2.0; it lands only after the v1.2.0 tag, on a later merge word. No MUST, no SHOULD; NITs and follow-ups below. Measured with my own probe (uid 1000, git 2.43.0, Python 3.12.3, stub gh, local remotes): 75 cases on main, this head, this head merged with #1141, and #1141 alone.
Tests at the head, one at a time, stub env:
Mutants: 28 of my own, on the whole new file with Root: the 29 rows without Merges: 19 open PRs touch
Sonar: gate OK, 0 new issues, duplication 0.0. CI: Body and commits: a draft with no milestone and the follow-up sentence first. It ends NITs (none blocks):
Follow-ups:
Skipped: Lane: openreposhape-3 (openRepoShape-3) |



What changed
A follow-up outside v1.2.0 (Brett Heap, 2026-10-21T10:32:04Z). It lands only after the v1.2.0 tag, on a later merge word.
adopt-project.pynow refuses a part of a repository that git reads and this user cannot read assource-unreadable, naming that part. This holds for--sourceand for a plan'ssource.local_path. Such a part wasgit-failed, or "no longer whole" and cloned again from GitHub.Before and after
I measured each case on main
961640c68as uid 1000, with git 2.43.0, Python 3.12.3, a stubghthat logs and refuses every call, and local remotes. Each case puts one path at mode 000 afterplanhas run..git(a directory or a file),plan --sourceandcheck --plan --sourcegit-failed ... not a git repositoryorerror opening '.../.git', rc 2.gitin --source '',<src>/.git", rc 2.git/HEAD,cloned: truecloning testorg/Thing, 1ghcall.git/HEADin the plan'ssource.local_path,<lp>/.git/HEAD", file cure, 0ghcallsgitdir:target (--separate-git-dir)source.local_path,<root>/store.git".git/objects,.git/refs,.git/config.git/objectsin" / "the.git/refsin" (directory cure) / "the.git/configin" (file cure).gitabove it<main>/.git/worktrees/WT"; "a directory above the git directory of ...,<main>/.git".git/modules/sub<Super>/.git/modules/sub".gitlink's target<root>/far/locked".git/HEADof a clone that sits inside another repository's work tree (git climbs to that one)source-not-top-level, "isThing/inside the repository at<outer>"; cloned: NOTE "no longer whole (git reads it as part of the repository at<outer>)",cloning testorg/Thing, 1ghcall.git/HEADin ...", file cure, 0ghcallsWith
cloned: false, main saidgit-failedin each of cases 2 to 7. Now it says the samesource-unreadableline, with "whether a repository is there" as what cannot be told. Every refusal exits 2 and asksgh0 times. A recorded source's refusal ends "; or pass the source again with --source, which is held to the plan'scommit:." All five readable controls (directory, file, worktree, submodule, symlink) still sayplan ok, with 0ghcalls, before and after.How it works
_refused_git_pathasks the parts git 2.43 asks, in git's order (read_gitfile_gently,is_git_directory):.git, followed where it is a link;.gitfile, opened, and itsgitdir:line read;HEAD,lstated, and opened unless it is a link;commondir;objectsandrefs, each searched (access(X_OK));config, opened.statnames the directory this user cannot search, found by_where_refused. A refused open names the file and gives the file's cure.HEADthat is a directory, or a NUL in agitdir:line._top_level, after--is-inside-git-dirfails, and beforesource-not-top-level;_not_a_whole_clone, before "git finds no repository there" and before "git reads it as part of the repository at".So a repository git can read is never refused here, and a readable source costs nothing extra.
Plan._git_part_unreadablewords the refusal for a recorded source._source_specwords it for--source.How it divides with #1141
.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141 (for adopt-project.py: a recorded clone whose.gitthis user cannot read is called no longer whole and cloned again, where the run should refuse it by name as #1072 refuses a directory it cannot enter #1099) asks the recorded.gititself, before git runs, in_recorded_source.--source, and every part git reads inside or behind the.git. It asks only after git has found no whole top level..gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141:SOURCE_UNREADABLE_FILE_PERMISSION, and_source_unreadable(..., *, permission=)..gitat mode 000 gets the same words from both PRs, so they agree whichever lands first.git merge-treewith adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141 is clean. The merged tree holds each shared hunk once, and this PR's tests pass on it (see Tests).Decisions the issue left open
.gitis named from the source ("the.git/HEADin"). A part elsewhere is named by its whole real path, with its role: "the git directory of", "the<part>in the git directory of", "theobjectsin the common git directory of", or "a directory above the git directory of". Case 5 asks for this: the refused directory is not always the one thegitdir:line names.cloned: trueit is "whetherplan's clone is still whole, to be read, or no longer whole, to be cloned again,". Otherwise it is "whether a repository is there". Both are adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141's words.HEADthat is a symlink. It islstated and never opened, because git reads a link as text. A link to a ref this user cannot read is left to git.REPOSITORY_UNTOLD. It replaces the literal "whether a repository is there" that main has twice and adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141 adds a third time (Sonar S1192).Out of scope
SOURCE_OVERRIDE_HELPanddocs/cli.mdare not touched. adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141 rewrites that sentence, and this PR adds no flag.HEAD,objects/pack, and deeper object reads (cat-file -e) are not covered. Git reads those after the top level is known, which is a different question..gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141 lands: a.gitthat is a SYMLINK whose target store is at mode 000. adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141's_git_refusedanswers it first in_recorded_sourceand names<lp>/.git. Without adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141, this PR names the store as "the git directory of". Both aresource-unreadable, and neither clones again. The case is in neither adopt-project.py:plan --sourceof a repository whose.gitis at mode 000 is refusedgit-failedwith no name for the.git, and a clone whose.git/HEADor whosegitdir:target is at mode 000 is called no longer whole and cloned again, where #1141 refuses a recorded clone's unreadable.gitby name (see #1099) #1151 nor the measurement, so no test row asserts it.Tests
Each file ran alone, one at a time, under
timeout 2400, with a stubghfirst on PATH (exit 97),GH_TOKEN=bogus, an emptyGH_CONFIG_DIR,GIT_SSH_COMMAND=false, an emptyGIT_CONFIG_GLOBALandEMAILset:/usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-<name> -o tmp_path_retention_policy=failed <file>(Python 3.12.3, git 2.43.0).tests/test_adopt_unreadable_git_parts.py(new):79 passed in 70.86s (0:01:10)tests/test_adopt_unenterable_source_and_locale.py:13 passed in 8.64stests/test_adopt_source_clone_record.py:100 passed in 31.18stests/test_adopt_unsearchable_source.py:27 passed in 9.04stests/test_pin_source_unreadable.py:81 passed in 23.76stests/test_scaffold_leg_path_collisions.py:104 passed in 4.78stests/test_path_guard_hygiene.py:49 passed in 16.71stests/test_repo_hygiene.py:101 passed in 2.79stests/test_agents_md_codes.py:47 passed in 2.15stests/test_cli_reference.py:23 passed in 33.20stests/test_update_add_codes_documented.py:6 passed in 0.63stests/test_no_auto_maintenance.py:58 passed, 2 skipped in 11.33stests/test_adopt_plan.py:66 passed, 1 skipped in 27.64stests/test_shape_doctor.py(anadopt-project.pychange always runs it, once):445 passed, 3 skipped in 731.29s (0:12:11)tests/test_adopt_unreadable_git_parts.py(on this head merged with adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #11417a2711d03):79 passed in 53.30stests/test_adopt_unreadable_clone_git.py(adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141's own test file, on this head merged with adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141):18 passed in 12.60stests/test_adopt_unreadable_clone_git.py(adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141's test file, copied onto this head WITHOUT adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141's code):6 failed, 12 passed in 13.10sThe head is two commits. The second changes one comment line in the new test file (a section rule 80 columns wide, now 79). The new test file and
tests/test_shape_doctor.pyran on the head itself; every other row above ran on its parent, whose only difference from the head is that one comment line.#1141's test file on this head WITHOUT #1141's code (wt-cand): all 12 of its child-process rows pass, with #1141's wording, because this PR words a recorded
.gitat mode 000 as #1141 does. Six rows fail, as expected, because they test #1141's own code:test_a_recorded_clone_whose_git_is_refused_is_not_cloned_againrefusesos.statof.git/HEADin-process while git still reads the clone. adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141 asks the.gitbefore git runs, while this PR asks only where git finds no whole top level.test_a_git_that_can_be_read_or_is_not_there_is_gits_questionrows call_git_refused, adopt-project.py: a recorded source whose.gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #1141's helper, which this branch does not have.On the tree merged with #1141, all 18 of #1141's rows pass, and so do this PR's 79.
Mutants
Each mutant was applied to
adopt-project.pyand run withtests/test_adopt_unreadable_git_parts.py -x -k <selection>. It counts as KILLED only where pytest exits 1. The file was then restored from a saved copy of the edited file and checked withcmp, because the change was not yet committed._where_refusednames the part whosestatis refused, not the directory above it: KILLED by-k 'where_a_stat or worktree-common or symlink-above'(1 failed, 66 deselected in 0.70s); restored,cmpclean._where_refusednames the directory above a part refused for another reason: KILLED by-k 'where_a_stat'(1 failed, 78 deselected in 0.40s); restored,cmpclean..gitfile'sgitdir:line is not followed: KILLED by-k 'file-gitdir'(1 failed, 72 deselected in 0.59s); restored,cmpclean.HEADislstated but never opened: KILLED by-k 'directory-HEAD or gitdir-HEAD or inside_another'(1 failed, 69 deselected in 0.66s); restored,cmpclean.HEADisstated through a link (os.stat, notos.lstat), so a link is opened: KILLED by-k 'head_that_is_a_link'(1 failed, 78 deselected in 0.65s); restored,cmpclean.objectsandrefsare not askedaccess(X_OK): KILLED by-k 'objects or refs'(1 failed, 68 deselected in 0.67s); restored,cmpclean.configis not opened: KILLED by-k 'config'(1 failed, 75 deselected in 0.63s); restored,cmpclean.commondiris ignored: KILLED by-k 'common-objects or commondir'(1 failed, 73 deselected in 0.61s); restored,cmpclean.-k 'file-gitdir or submodule or worktree-gitdir'(1 failed, 63 deselected in 0.62s); restored,cmpclean.-k 'HEAD or config or gitdir'(1 failed, 54 deselected in 0.58s); restored,cmpclean._top_leveldoes not ask after--is-inside-git-dirfails: KILLED by-k 'source_part or given or source_whose_head'(1 failed, 40 deselected in 0.67s); restored,cmpclean._top_leveldoes not ask beforesource-not-top-level: KILLED by-k 'inside_another'(1 failed, 76 deselected in 0.76s); restored,cmpclean._not_a_whole_clonedoes not ask where git refuses (git-failed): KILLED by-k 'recorded_part and cloned'(1 failed, 65 deselected in 1.20s); restored,cmpclean._not_a_whole_clonedoes not ask where git cannot be started: KILLED by-k 'objects_cannot_be_searched'(1 failed, 1 passed, 77 deselected in 1.61s); restored,cmpclean._not_a_whole_clonedoes not ask before "git reads it as part of the repository at": KILLED by-k 'inside_another'(1 failed, 2 passed, 76 deselected in 2.68s); restored,cmpclean.config) is refused, not left to git: KILLED by-k 'gits_question'(1 failed, 3 passed, 65 deselected in 0.62s); restored,cmpclean.HEADnot there is refused, not left to git: KILLED by-k 'gits_question'(1 failed, 2 passed, 65 deselected in 0.45s); restored,cmpclean.cloned: trueandfalse: KILLED by-k 'recorded_part'(1 failed, 51 deselected in 0.98s); restored,cmpclean.OSErrorfrom a part read but not as git reads it is raised, not left to git: KILLED by-k 'gits_question'(1 failed, 4 passed, 65 deselected in 1.21s); restored,cmpclean.Merges
git merge-tree --write-treeof this head with origin/main, and with every open PR that touchesadopt-project.py(fromgh pr list --state open --json number,headRefOid,files), #1141 first:961640c68: clean..gitthis user cannot read is refusedsource-unreadable, naming that.git, socheckandexecuteno longer call a cloneplanmade with its.gitat mode 000 no longer whole and clone it again (#1099) #11417a2711d03: clean.checkandexecuterun withGIT_NO_LAZY_FETCH=1and refuse a partial-clone source that lacks an object its branches, tags or HEAD reach assource-partial-clone, so neither fetches into the source any more (#1145) #11802c8082a2a: clean.plan's table pads each name by the columns a terminal gives it, not the code points it holds, so a wide or combining name'slegcolumn lines up underlegwith every other row's (#1034) #1178fa56168ab: clean.--planpath incheckandexecute, and the--local-remote-dirpath inexecute'sbare, leg andsplitlines, are named with_shown, as #1154 names--outand--work-dir, so a bidi control, an escape, a line feed or a byte that is not UTF-8 in either no longer reaches the terminal as it was typed (#1152) #11775208e874e: clean.plan's lines and in theunsafe-valuerefusal, so a branchma<U+034F>inno longer prints asmain(#1128) #1171159815d75: clean.checknames each case twin at the shape's copy, above it and at the manifest, before a link there or after it, and at a pin's file whose exact spelling holds nothing, as_KeptTreekeeps every row, not the first (#1138) #11704df784ac4: clean.check's rehearsal and_stored_as_writtentake a parent's-csettings,attr.treetoo, and the source's objects,GIT_ATTR_SOURCEis refused by name before any question, and each throwaway reads its environment first (#1136) #1169b8e63bcf0: clean.plan written toandcloning <repository> (read only) intoname the caller's--outand--work-dirwith_shown, as the other lines of the report name a path, so a bidi control, a line feed or a tab in either no longer reaches the terminal as it was typed (#1115) #11548a7828332: clean.checkmakes again for a plan's clone record that neither spelling names is compared with both, so it is kept and the next run reads it rather than cloning again (#1112) #1153ff33ba12a: clean.--work-dirlink into a loop that Python 3.12 reads past a link that does not loop names the loop, Windows' winerror 1921 counts as one, and a path read as the loop's link says so, with tests for r1065's mutants (#1074) #11437af5fabc3: clean.checkandexecuteask a plan'sorg:andid:right after its three repository names, so a bad one beside an unanswered entry, a moved source, a refused name or another repository is that one refusal in both, and apins:entry that is no pin is said as_read_assays it (#1020) #11259549006fe: clean.makefile/is tested, README saysmakeat the root reads a keptGNUmakefileormakefileinstead, and a docstring says the shape's Makefile alone holdsCONTRACTS_DIR(#1061) #11235c3908f8d: clean.org/repo--sourcetoo long for the disk is refused as a path is, naming no GitHub repository and offering no url, so--source <300 a's>/Thingis no longer offeredhttps://github.com/<300 a's>/Thing, which can never clone (#1003) #1122c01c4d73d: clean.execute's NEXT printspython3 scripts/bootstrap.pywhere GNU make at the root reads a keptGNUmakefileormakefile, and its NOTE names that file and what it takes formake bootstrapto run there (#1047) #11211c5173758: clean.plan'ssourceline names the source's branch as_shownnames a path, so a bidi control, a line boundary or a byte that is not UTF-8 in it is spelled and no longer reaches the terminal raw (#1035) #1120c55e55e33: clean.bba168bdd: clean.2307036ab: clean.checkreads a plan written in UTF-8 whose source is there, asking the plan's UTF-8 bytes where the spelling as written names nothing, rather than refusingplan-source-gone(#1078) #1106504a1dd65: clean.GNUmakefileor amakefile, 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 saysmakereads the first of the three and the block reaches it only once that file holds it (#1048) #1105b28f40de0: clean.Closes #1151
Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code