Repository navigation
adopt-project.py: a source whose refs this user cannot list is refused source-unreadable naming the directory, never source-default-branch-not-local "(its branches: none)" (#1219) - #1252
Conversation
…sed `source-unreadable` naming the directory, never `source-default-branch-not-local` "(its branches: none)" (#1219) git passes over a directory of refs it cannot list (for-each-ref exits 0) and says "No such ref" (symbolic-ref -q exits 128) of a ref it cannot reach. Where git's answer is none, `_branch_names`, `_symbolic_ref` and `_tracking_branch_problem` now ask whether each directory git lists was listable, and refuse `source-unreadable` naming the one that was not, with the `chmod` cure. A source git reads whole asks nothing more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideImplementationAdd detection of unreadable ref directories and use it when branch listings or symbolic-ref lookups could otherwise mistake inaccessible refs for absent branches.
TestsDocument and test unreadable-ref handling across plan, check, execute, tracking-branch and cloned-branch paths, while confirming readable and genuinely branchless sources retain their behavior.
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
|
(lane openRepoShape-3, 2026-11-08T15:19:48Z) RV1252, independent review of #1252 at 40b030c (Claude Opus 5.5). VERDICT: READY at 40b030c once M1 is done (body only, no new commit), and once CI MUST
SHOULD
NIT
MEASURED (own probe, uid 1000, git 2.43.0, Python 3.12.3, stub gh; main 105b5da vs head; every mode put back)
TESTS (local head+main merge, filter-repo 2.47.0 on PATH, -rs)
Not checked: batches A/B, Python 3.14, Windows, reftable, Lane: openreposhape-3 (openRepoShape-3) |



What changed
A v1.2.1 follow-up from lane 3's independent reviews (#1219). It lands under Brett Heap's v1.2.1 merge word (2026-10-23T13:10:26Z) once reviewed and green.
git for-each-refpasses over a directory of refs it cannot list without a word, and exits 0.git symbolic-ref -qexits 128, "No such ref", for a ref it cannot reach, where it exits 1 for one that is not there.adopt-project.pyread both answers as facts about the source: a list of branches with nothing in it, and no symbolic ref. So a clone whose.git/refs/headswas at mode 000 was refusedsource-default-branch-not-local, "(its branches: none)", with agit branchto run where the cure is achmod.Each such source is now
REFUSED source-unreadable, exit 2. The refusal names the directory, first as a part of the source's refs and then as a whole path, with the operating system's reason and the existingchmod u+rxcure. It comes before anything is cloned or made, with one exception:plan --tracking-branch <b>withrefs/remotes/originunlistable writes the plan first and then refuses. That is because_tracking_branch_notesruns in the report after the write (adopt-project.py:6141), and the rowtest_a_tracking_branch_git_cannot_list_is_not_called_missing[plan]pins it (written=True).ghis never asked. No refusal code, command or flag is new, and the refusal is the existing_source_unreadable's. The named part of the message and theBRANCHES_UNTOLDandORIGIN_BRANCHES_UNTOLDphrases are new words.adopt-project.py:_branch_names(path, prefix, wanted): wherewanted, the branch the caller looks for, is missing from git's list,_refuse_unlisted_refs(path, prefix)is asked before the list is believed. Where it is there, orwantedis None, nothing more is asked._symbolic_ref(path, ref, holding): runsgit symbolic-ref -qitself, asgit_outruns it (the same argv, cwd and environment), so it can read the exit code. Exit 0 gives the name and exit 1 gives None, as before. Any other exit asks_refuse_unlisted_refs(path, holding),holdingbeing the refsrefnames one of, and gives None where git could list each directory._branch_names:_refuse_unlisted_refsfinds the common git directory withgit rev-parse --path-format=absolute --git-common-dir(the formadopt-project.pyalready uses forobjects), then walks each prefix._unlisted_ref_directoryis the walk._listed_nameslists a directory withos.scandir, and gives None where_absentsays nothing is there.BRANCHES_UNTOLDandORIGIN_BRANCHES_UNTOLDare what the refusal says cannot be told._default_branchasks aboutorigin/HEADunderrefs/remotes/origin/, HEAD underrefs/heads/, and its own list with the branch it reads._from_the_remoteasks aboutorigin's list with the branch._tracking_branch_problemasks about both prefixes before it says a tracking branch is missing._cloned_branchasks about HEAD and its own list with HEAD's branch.tests/test_adopt_tracking_branch.py: 35 new rows at the end of the file, in a section of their own (see Tests).Measured first
At origin/main
50869f384(measured at50ce8ab15first, and again at50869f384: the same words and exits, bydiffof the two runs), with git 2.43.0, Python 3.12.3 and uid 1000. The branch has since been rebased twice, ontobd3e9c148and then onto93d293141(which holdsb1f45fa76);git range-diffshows this commit's patch unchanged by each. These probes were not run again on the new base: the 52 rows oftests/test_adopt_tracking_branch.pybuild the same sources and pass at the head (see Tests). Each run used the real tool under a stubgh(exit 97) with local remotes. The plan was written while git could read the source whole and recordedcloned: true, and only then was the directory's mode changed.plan,checkandexecutemain(RV1186's case).git/refs/headsat 000 or--xplan,checkandexecute:source-default-branch-not-local, "... no branchmainof its own (its branches: none)", exit 2source-unreadable, namingrefs/heads, exit 2.git/refsat--xsource-unreadable, namingrefsrelease/v1.git/refs/heads/releaseat 000 or--xrelease/v1of its own (its branches: none)"source-unreadable, namingrefs/heads/releasefeature/xbesidemain.git/refs/heads/featureat--xsource-default-branch-not-local: "its HEAD is onfeature/x, which has no commit yet, so it is no branch"source-unreadable, namingrefs/heads/feature.git/refs/heads/featureat 000planWRITTEN atmain, exit 0, without a word.check:plan-staleandplan-tracking-branch-missing, exit 1.execute:plan-stale, exit 2source-unreadable, namingrefs/heads/featureorigin/HEADnamesmain, which onlyoriginhas; the source's own branch isdevrefs/remotes/originat 000planWRITTEN atdev, exit 0, itsorigin/HEADpassed oversource-unreadable, namingrefs/remotes/originrefs/remotes/originat--xsource-default-branch-not-local, "(its branches:dev)", and to askoriginfor its default branch againsource-unreadable, namingrefs/remotes/origin--tracking-branch devrefs/remotes/originat 000planwritten atdev,checkplan oksource-unreadable, namingrefs/remotes/origin--tracking-branch devrefs/remotes/originat--xplanwritten atdev,checkplan okorigin/HEADis read, anddevis the source's ownTwo of these go beyond the issue, which read them from the code:
refs/remotes/originat 000 was not "askoriginagain":plansilently planned at HEAD's branch.refs/heads/featureat 000 with HEAD onfeature/x:planwrote the plan atmainwithout a word.What git itself does, measured the same way:
for-each-refexits 0 and prints nothing from an unlistable directory.symbolic-ref -qexits 128, "No such ref", whererefs/heads(for HEAD onmain) orrefs/remotesorrefs/remotes/origin(fororigin/HEAD) is at 000..git/refsat 000, git takes the directory for no repository at all.Decisions the Direction left open
--source, or a plan'ssource.local_path, with a part of its repository git reads at mode 000 (the.git, itsHEAD,objects,refsorconfig, the git directory agitdir:line names, or a directory above it) is refusedsource-unreadablenaming that part, nevergit-failedand never cloned again (#1151) #1186 has not landed, so the ask is beside_branch_names, as the Direction says. It merges cleanly with adopt-project.py: a--source, or a plan'ssource.local_path, with a part of its repository git reads at mode 000 (the.git, itsHEAD,objects,refsorconfig, the git directory agitdir:line names, or a directory above it) is refusedsource-unreadablenaming that part, nevergit-failedand never cloned again (#1151) #1186 (see Merges).loose_fill_ref_dir). Each directory above the prefix is listed, because git listsrefsto findheadsin it: with.git/refsat--x,for-each-ref refs/heads/printed nothing. Then the prefix and every directory under it are listed, because a branchfeature/xis kept inrefs/heads/feature. Each entry on the way isstated through the file's_is_a, as git'sget_dtypefollows a link. So a directory atr--, which can be listed but not searched and whose refs git cannot open, is named too. No new bareis_dir,existsoros.pathguard was added.symbolic-ref's exit 128 is asked about too. The issue named only the lists. Measuring showed that git also says "No such ref" of anorigin/HEADit cannot reach, and of a HEAD whose branch sits in an unlistable nested directory. On main both were read as "no symbolic ref". That is how the two silent plans above happened.origin/HEADgit cannot reach is now refused, even where--tracking-branchwould have given the same read. What it names cannot be told, so the source is not read past it. Measured: withrefs/remotes/originat 000 and--tracking-branch dev, main wrote the plan atdevandchecksaidplan ok. Nowplan,checkandexecutearesource-unreadable.git rev-parseand the walk run only where git's answer was none:symbolic-refexited with something other than 0 or 1;_tracking_branch_problemis about to say a branch is missing.A source git reads whole runs nothing new. The row
test_a_source_git_reads_whole_is_asked_nothing_morepins this, and mutant M9 breaks it.packed-refsis in its answer, so nothing more is asked of it.Out of scope
warning: ignoring broken ref refs/heads/mainand exits 0, andsymbolic-ref -q HEADexits 128. Measured with this change, on a clone whose looserefs/heads/mainis at 000:plan --sourceis stillsource-default-branch-not-local, "(its branches: none)", exit 2. Telling an unreadable ref file apart would mean opening each ref, which is a different question from listing a directory. That can be a follow-up._branches_atand_branches_in(adopt-project.py:9907and:10051) still read an unlistable directory as holding nothing. Withrefs/heads/adoptat 000, a staleadopt/three-repo-shapethere no longer givesFINDING adopt-branch-exists: RV1252 measuredcheckexit 0, where the readable source gives exit 1. That is outside adopt-project.py: a source whose.git/refs/headsthis user cannot read is refusedsource-default-branch-not-localwith(its branches: none), a false list, where the refusal should name the directory git could not list #1219 as filed and is a follow-up..as git's loose listing does, so an unreadablerefs/heads/.hiddenis refused by name where git would list the other branches. With the refs packed (git pack-refs --all) andrefs/headsat 000, main and this head both stop atgit-failedfromshow-ref --verify..git/refsat 000. git takes the directory for no repository at all. That case belongs to adopt-project.py: a--source, or a plan'ssource.local_path, with a part of its repository git reads at mode 000 (the.git, itsHEAD,objects,refsorconfig, the git directory agitdir:line names, or a directory above it) is refusedsource-unreadablenaming that part, nevergit-failedand never cloned again (#1151) #1186's_refused_git_path, which asks aboutobjectsandrefs._branches_of's fallback is kept. It readsorigin's branches where the source has fetched any, and the source's own otherwise. So withrefs/remotes/originat--xand the tracking branch among the source's own branches, the tracking branch is checked against the source's own branches and nothing is asked. Asking there would cost every source with no fetched branches a walk. Where the tracking branch is not among the source's own branches, the ask runs and refuses (thetracking_branch_git_cannot_listrows).--sourcehelp, anddocs/cli.mdgenerated from it, describesource-unreadableas "a path this user cannot reach". A directory of the source's refs is one, so neither changed.needs_a_mode, fromtest_adopt_unsearchable_source.py). The in-process rows ask the same questions withos.scandirrefused and run everywhere.Tests
Base: origin/main
93d293141. Head:40b030cbfa6dd897703e5d335e7fd3597ce18f6e. Every run was a single/usr/bin/python3 -m pytest -q -rs, one at a time, with a stubgh(exit 97) first on PATH,GH_TOKEN=bogus, an emptyGH_CONFIG_DIR,GIT_SSH_COMMAND=false,GIT_CONFIG_GLOBAL=/dev/nulland noGIT_ALLOW_PROTOCOL. This bench has git 2.43.0, Python 3.12.3 and nogit-filter-repo, so a throwaway venv holds CI's pin,git-filter-repo==2.47.0(tests.yml), and its script was on PATH for every run: no row skipped for want of it.tests/test_adopt_tracking_branch.py(17 old rows and the 35 new)test_repo_hygiene,test_path_guard_hygiene,test_no_auto_maintenance,test_agents_md_codes,test_cli_reference,test_update_add_codes_documentedtest_adopt_branch_names,test_adopt_git_reads,test_adopt_c_locale_bytes,test_adopt_check_throwaway_repos,test_adopt_root_symlinks,test_non_utf8_git_failures,test_non_utf8_git_reads,test_adopt_unsearchable_source,test_adopt_unenterable_source_and_locale,test_adopt_source_clone_record,test_adopt_temp_work_roots,test_adopt_partial_clone_source,test_adopt_empty_symlinks,test_adopt_ignored_shape_files,test_adopt_symlink_target_bytes,test_adopt_free_plan_hint_root,test_adopt_origin_repository,test_scaffold_leg_path_collisions,test_adopt_tracking_branchtest_adopt_plan,test_adopt_github_exits,test_adopt_e2e,test_adopt_plan_yaml_quoting,test_adopt_split_branch,test_adopt_clone_template,test_adopt_manifest_values,test_adopt_plan_flag_refusals_first,test_adopt_work_dir_refusal,test_next_refspec_branch_length,test_ruleset_hint_tracking_branch,test_windows_paths,test_adopt_next_make_reads_first,test_adopt_make_reads_firsttests/test_shape_doctor.py(adopt changes need the doctor tests)test_adopt_partial_clone_source.pyis in batch A besidetest_adopt_temp_work_roots.py, because it importsgh_clonesfrom it.The skips, by
-rs:tests/test_no_auto_maintenance.py:222, "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one" (the gate sets it to/dev/null).test_adopt_check_throwaway_repos.py:482, a disk that keeps case (this bench's does); three intest_adopt_root_symlinks.py(lines 1960, 2015 and 2596), "the reftable backend needs git 2.45 or later" (this bench has git 2.43).test_adopt_plan.py:567,SHAPE_MEDXEHR_CLONEis unset (a read-only clone of a private repository).tests/test_shape_doctor.py, 3 attests/test_shape_doctor.py:10516: the PowerShell line is printed, and powershell.exe exists, only on a Windows host.CI's
testsjob is the 3.14 run; these ran on 3.12.Mutants
Run on
07089d38a(this commit's patch on origin/main50869f384). Each mutant is one exact replacement inadopt-project.py, checked to match once, then the rows that should catch it were run, then the file was restored and compared byte for byte with the copy before the mutant.adopt-project.pyunder the new test file_branch_namesno longer asks_symbolic_refreads an exit of 128 as "not there"_from_the_remotepasses no wanted branch, so asks nothing_tracking_branch_problemdrops the askstats it_cloned_branchpasses no wanted branch_branch_namesalways askstest_a_source_git_reads_whole_is_asked_nothing_more)_default_branchasks aboutorigin/HEADunder the wrong refs_tracking_branch_problemdoes not ask aboutorigin's refsThe mutants were not run again after the two rebases (onto
bd3e9c148and93d293141):git range-diffshows this commit's patch unchanged by each, andmut.py --checkfinds each mutant's text once in the head'sadopt-project.py.Merges
git merge-tree --write-treeof the head40b030cbfa6dd897703e5d335e7fd3597ce18f6ewith each, the open PRs listed in onegh pr listcall (those touchingadopt-project.pyortests/test_adopt_tracking_branch.py, 24 of 51, heads fetched fromrefs/pull/<n>/headand checked):93d29314: clean (treeefea6bbb6de7). origin/main11560eb9, which came after the run: clean (tree487f90df9c6f)._first_saidspells gh's, so--local-remote-dirno longer reaches the terminal raw there; a NOTE beforeNEXT:says when the source's path in its clone command holds a character a terminal does not show as itself; and rows pin six sites #1177's 148 left to chance (#1195) #122573c9f57e(00450b4ea881), adopt-project.py:checkandexecuteread one list of a plan's problems against its source, in the order a person mends them (another repository, a refused name, a moved source, an unanswered entry), socheck's first finding isexecute's refusal for each pair (#1113) #1210791106c2(f3d856ba800b), adopt-project.py, shape_materialize.py and the assembly-root template: a keptGNUmakefile/is a directory GNU make stops at,python3 scripts/bootstrap.pynamespythonon Windows, and the template says what to run wheremakereads another file #12076fa1edd5(2a30f79a8d56), adopt-project.py:_pin_row_problemsays a pin field whose value is not text as_read_assays what the plan's reader read, "recordscommit:as the number 7", where it spelled it as Python does,commit: 7andTrue, socheckandexecutename it in the words the plan's other refusals use under the sameplan-pin-malformed(#1114) #1198d38e4e36(dd821d395f95), adopt-project.py:_github_namereads no owner over 39 characters or repository over 100, soplan --source <50 b's>/Thingnever asksghfor a name GitHub cannot hold, no remedy offers--org <50 b's>, andcheckandexecutenever clone such a record again (#1129) #1194900f77af(b882688c45c3), adopt-project.py:planruns withGIT_NO_LAZY_FETCH=1, refuses a partial-clone--sourcethat lacks objects assource-partial-clone, and names a partial--pin-sourceit could read only by fetching and the fetch that makes it whole (#1162) #1190e42134e1(3b3c56ea87ef), adopt-project.py: a--source, or a plan'ssource.local_path, with a part of its repository git reads at mode 000 (the.git, itsHEAD,objects,refsorconfig, the git directory agitdir:line names, or a directory above it) is refusedsource-unreadablenaming that part, nevergit-failedand never cloned again (#1151) #118607486a49(a825f3177ab9), adopt-project.py: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) #1178c4beba19(4e3ef8c66e6d), adopt-project.py: the--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) #11775208e874(2cdf2ec1c230), adopt-project.py and repo_shape.py: a name that holds a default-ignorable character, which a terminal draws as nothing, is spelled by its code point inplan's lines and in theunsafe-valuerefusal, so a branchma<U+034F>inno longer prints asmain(#1128) #11718162b874(465eaed53e34), adopt-project.py: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) #117024ee133a(7c7c2fc7f7e3), adopt-project.py: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) #11548a782833(c24d83c98367), adopt-project.py: under a one-byte locale such as Latin-1, a clonecheckmakes 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) #1153ff33ba12(02c9840829f9), adopt-project.py: a--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) #11437af5fabc(feb77ac02789), adopt-project.py: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) #11259549006f(3bbf3a6b4f0f), README.md, adopt-project.py and the handbook: a kept directory entrymakefile/is tested, README saysmakeat the root reads a keptGNUmakefileormakefileinstead, and a docstring says the shape's Makefile alone holdsCONTRACTS_DIR(#1061) #11235c3908f8(b67ca2eac088), adopt-project.py: anorg/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) #1122c01c4d73(501dbaea909f), adopt-project.py: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) #1120c55e55e3(bcb539402f58), adopt-project.py, shape_materialize.py and tests: a named bad link left out of the copy and pin findings takes no other path's finding with it, and the docstrings and the materializer's refusal name the #979 exception and what helps it (#1089) #11182307036a(67ea079413fc), adopt-project.py: under a one-byte locale such as Latin-1,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) #1106504a1dd6(bbeedc637f2a), shape_materialize.py and the pinned AGENTS-shape.md: where the root keeps aGNUmakefileor 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) #1105b28f40de(2aff16d2af42).git rm -fand writes the twins the root keeps back from the index, so on a disk that ignores caseexecutekeeps a set the plan splits across repositories or drops (#1183) #1237; adopt-project.py:checknames the case twins the plan keeps in one repository asplan-case-twinson every disk, a one-blob set as a NOTE in a leg and, in the root, two paths in one folder, andexecuterefuses the finding before any leg (#1161) #1189; 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. Each conflicts with origin/main ALONE, with the same conflict blocks, so this commit adds none of them:git rm -fand writes the twins the root keeps back from the index, so on a disk that ignores caseexecutekeeps a set the plan splits across repositories or drops (#1183) #123763c52718and adopt-project.py:checknames the case twins the plan keeps in one repository asplan-case-twinson every disk, a one-blob set as a NOTE in a leg and, in the root, two paths in one folder, andexecuterefuses the finding before any leg (#1161) #118958aa9d2b:tests/test_adopt_check_throwaway_repos.py, one hunk each; adopt-project.py: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) #1169 changed that file when it landed..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) #11417a2711d0:adopt-project.py(one hunk) anddocs/cli.md(two hunks): its branch is behind main.--source, or a plan'ssource.local_path, with a part of its repository git reads at mode 000 (the.git, itsHEAD,objects,refsorconfig, the git directory agitdir:line names, or a directory above it) is refusedsource-unreadablenaming that part, nevergit-failedand never cloned again (#1151) #118607486a49(the PR whose_refused_git_paththis sits beside): clean.Closes #1219
Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code