Skip to content

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

Draft
brettheap wants to merge 1 commit into
mainfrom
l3-1219-refs-unlistable
Draft

brettheap wants to merge 1 commit into
mainfrom
l3-1219-refs-unlistable

Conversation

@brettheap

@brettheap brettheap commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

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-ref passes over a directory of refs it cannot list without a word, and exits 0. git symbolic-ref -q exits 128, "No such ref", for a ref it cannot reach, where it exits 1 for one that is not there. adopt-project.py read 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/heads was at mode 000 was refused source-default-branch-not-local, "(its branches: none)", with a git branch to run where the cure is a chmod.

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 existing chmod u+rx cure. It comes before anything is cloned or made, with one exception: plan --tracking-branch <b> with refs/remotes/origin unlistable writes the plan first and then refuses. That is because _tracking_branch_notes runs in the report after the write (adopt-project.py:6141), and the row test_a_tracking_branch_git_cannot_list_is_not_called_missing[plan] pins it (written=True). gh is 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 the BRANCHES_UNTOLD and ORIGIN_BRANCHES_UNTOLD phrases are new words.

  • adopt-project.py:
    • _branch_names(path, prefix, wanted): where wanted, 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, or wanted is None, nothing more is asked.
    • _symbolic_ref(path, ref, holding): runs git symbolic-ref -q itself, as git_out runs 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), holding being the refs ref names one of, and gives None where git could list each directory.
    • New, beside _branch_names:
      • _refuse_unlisted_refs finds the common git directory with git rev-parse --path-format=absolute --git-common-dir (the form adopt-project.py already uses for objects), then walks each prefix.
      • _unlisted_ref_directory is the walk.
      • _listed_names lists a directory with os.scandir, and gives None where _absent says nothing is there.
      • BRANCHES_UNTOLD and ORIGIN_BRANCHES_UNTOLD are what the refusal says cannot be told.
    • The callers:
      • _default_branch asks about origin/HEAD under refs/remotes/origin/, HEAD under refs/heads/, and its own list with the branch it reads.
      • _from_the_remote asks about origin's list with the branch.
      • _tracking_branch_problem asks about both prefixes before it says a tracking branch is missing.
      • _cloned_branch asks 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 at 50ce8ab15 first, and again at 50869f384: the same words and exits, by diff of the two runs), with git 2.43.0, Python 3.12.3 and uid 1000. The branch has since been rebased twice, onto bd3e9c148 and then onto 93d293141 (which holds b1f45fa76); git range-diff shows this commit's patch unchanged by each. These probes were not run again on the new base: the 52 rows of tests/test_adopt_tracking_branch.py build the same sources and pass at the head (see Tests). Each run used the real tool under a stub gh (exit 97) with local remotes. The plan was written while git could read the source whole and recorded cloned: true, and only then was the directory's mode changed.

Source Directory at the mode At main Here: plan, check and execute
A clone on main (RV1186's case) .git/refs/heads at 000 or --x plan, check and execute: source-default-branch-not-local, "... no branch main of its own (its branches: none)", exit 2 source-unreadable, naming refs/heads, exit 2
The same clone .git/refs at --x the same source-unreadable, naming refs
A clone on release/v1 .git/refs/heads/release at 000 or --x the same, "no branch release/v1 of its own (its branches: none)" source-unreadable, naming refs/heads/release
No remote, HEAD on feature/x beside main .git/refs/heads/feature at --x source-default-branch-not-local: "its HEAD is on feature/x, which has no commit yet, so it is no branch" source-unreadable, naming refs/heads/feature
The same .git/refs/heads/feature at 000 plan WRITTEN at main, exit 0, without a word. check: plan-stale and plan-tracking-branch-missing, exit 1. execute: plan-stale, exit 2 source-unreadable, naming refs/heads/feature
Fetched (loose) refs: origin/HEAD names main, which only origin has; the source's own branch is dev refs/remotes/origin at 000 plan WRITTEN at dev, exit 0, its origin/HEAD passed over source-unreadable, naming refs/remotes/origin
The same refs/remotes/origin at --x source-default-branch-not-local, "(its branches: dev)", and to ask origin for its default branch again source-unreadable, naming refs/remotes/origin
The same, planned with --tracking-branch dev refs/remotes/origin at 000 plan written at dev, check plan ok source-unreadable, naming refs/remotes/origin
The same, planned with --tracking-branch dev refs/remotes/origin at --x plan written at dev, check plan ok unchanged: origin/HEAD is read, and dev is the source's own

Two of these go beyond the issue, which read them from the code:

  • refs/remotes/origin at 000 was not "ask origin again": plan silently planned at HEAD's branch.
  • A nested refs/heads/feature at 000 with HEAD on feature/x: plan wrote the plan at main without a word.

What git itself does, measured the same way:

  • for-each-ref exits 0 and prints nothing from an unlistable directory.
  • symbolic-ref -q exits 128, "No such ref", where refs/heads (for HEAD on main) or refs/remotes or refs/remotes/origin (for origin/HEAD) is at 000.
  • With .git/refs at 000, git takes the directory for no repository at all.

Decisions the Direction left open

Out of scope

  • A ref FILE at mode 000. This is outside the walk, which asks only about directories. git prints warning: ignoring broken ref refs/heads/main and exits 0, and symbolic-ref -q HEAD exits 128. Measured with this change, on a clone whose loose refs/heads/main is at 000: plan --source is still source-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.
  • The split-branch finding's readers. _branches_at and _branches_in (adopt-project.py:9907 and :10051) still read an unlistable directory as holding nothing. With refs/heads/adopt at 000, a stale adopt/three-repo-shape there no longer gives FINDING adopt-branch-exists: RV1252 measured check exit 0, where the readable source gives exit 1. That is outside adopt-project.py: a source whose .git/refs/heads this user cannot read is refused source-default-branch-not-local with (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.
  • Two edge cases RV1252 measured, left as follow-ups. The walk does not skip names that start with . as git's loose listing does, so an unreadable refs/heads/.hidden is refused by name where git would list the other branches. With the refs packed (git pack-refs --all) and refs/heads at 000, main and this head both stop at git-failed from show-ref --verify.
  • .git/refs at 000. git takes the directory for no repository at all. That case belongs to 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's _refused_git_path, which asks about objects and refs.
  • _branches_of's fallback is kept. It reads origin's branches where the source has fetched any, and the source's own otherwise. So with refs/remotes/origin at --x and 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 (the tracking_branch_git_cannot_list rows).
  • Docs. The --source help, and docs/cli.md generated from it, describe source-unreadable as "a path this user cannot reach". A directory of the source's refs is one, so neither changed.
  • Platforms. The rows that set a real mode are skipped on Windows and as root (needs_a_mode, from test_adopt_unsearchable_source.py). The in-process rows ask the same questions with os.scandir refused 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 stub gh (exit 97) first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null and no GIT_ALLOW_PROTOCOL. This bench has git 2.43.0, Python 3.12.3 and no git-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.

Run Result
tests/test_adopt_tracking_branch.py (17 old rows and the 35 new) 52 passed in 50.04s
The six guards: test_repo_hygiene, test_path_guard_hygiene, test_no_auto_maintenance, test_agents_md_codes, test_cli_reference, test_update_add_codes_documented 356 passed, 2 skipped in 66.67s
Batch A, 19 files: test_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_branch 1146 passed, 4 skipped in 1042.39s
Batch B, 14 files: test_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_first 1151 passed, 1 skipped in 719.52s
tests/test_shape_doctor.py (adopt changes need the doctor tests) 446 passed, 3 skipped in 680.23s

test_adopt_partial_clone_source.py is in batch A beside test_adopt_temp_work_roots.py, because it imports gh_clones from it.

The skips, by -rs:

  • The six guards, 2: 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).
  • Batch A, 4: test_adopt_check_throwaway_repos.py:482, a disk that keeps case (this bench's does); three in test_adopt_root_symlinks.py (lines 1960, 2015 and 2596), "the reftable backend needs git 2.45 or later" (this bench has git 2.43).
  • Batch B, 1: test_adopt_plan.py:567, SHAPE_MEDXEHR_CLONE is unset (a read-only clone of a private repository).
  • tests/test_shape_doctor.py, 3 at tests/test_shape_doctor.py:10516: the PowerShell line is printed, and powershell.exe exists, only on a Windows host.
    CI's tests job is the 3.14 run; these ran on 3.12.

Mutants

Run on 07089d38a (this commit's patch on origin/main 50869f384). Each mutant is one exact replacement in adopt-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.

Mutant What it breaks Result
M0 origin/main's adopt-project.py under the new test file 34 failed, 18 passed (the 17 old rows and the one row that says a clone with no branch of its own is unchanged)
M1 _branch_names no longer asks KILLED, 9 failed
M2 the walk does not descend into directories under the prefix KILLED, 14 failed
M3 the walk starts at the prefix and does not list its ancestors KILLED, 1 failed
M4 _symbolic_ref reads an exit of 128 as "not there" KILLED, 4 failed
M5 _from_the_remote passes no wanted branch, so asks nothing KILLED, 1 failed
M6 _tracking_branch_problem drops the ask KILLED, 2 failed
M7 the walk takes every entry for a directory and never stats it KILLED, 4 failed
M8 _cloned_branch passes no wanted branch KILLED, 1 failed
M9 _branch_names always asks KILLED, 1 failed (test_a_source_git_reads_whole_is_asked_nothing_more)
M10 the two "untold" sentences swapped KILLED, 7 failed
M11 _default_branch asks about origin/HEAD under the wrong refs KILLED, 4 failed
M12 _tracking_branch_problem does not ask about origin's refs KILLED, 2 failed

The mutants were not run again after the two rebases (onto bd3e9c148 and 93d293141): git range-diff shows this commit's patch unchanged by each, and mut.py --check finds each mutant's text once in the head's adopt-project.py.

Merges

git merge-tree --write-tree of the head 40b030cbfa6dd897703e5d335e7fd3597ce18f6e with each, the open PRs listed in one gh pr list call (those touching adopt-project.py or tests/test_adopt_tracking_branch.py, 24 of 51, heads fetched from refs/pull/<n>/head and checked):

Closes #1219

Lane: openreposhape-3 (openRepoShape-3)

🤖 Generated with Claude Code

…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>
@brettheap brettheap added this to the v1.2.1 milestone Nov 8, 2026
@sourcery-ai

sourcery-ai Bot commented Nov 8, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Add detection of unreadable ref directories and use it when branch listings or symbolic-ref lookups could otherwise mistake inaccessible refs for absent branches.

  • adopt-project.py L2988-3000, L3093-3114, L3126-3223 (+5 small edits)

Tests

Document 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.

  • tests/test_adopt_tracking_branch.py L38-44, L49-54, L537-984 (+1 small edits)

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(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 tests is green (still IN_PROGRESS at 15:17:50Z; package, SonarCloud and tests-git-bash are SUCCESS).

MUST

  • M1 (body): a "## Summary by Sourcery" block (10 non-empty lines) follows the robot line. Strip it.

SHOULD

NIT

  • N1: plan --tracking-branch dev (origin/HEAD names main, which only origin has) with an unrelated refs/heads/feature at 000: main read the source at dev; the head refuses, naming refs/heads/feature.
  • N2: the walk does not skip names starting with "." as git's loose ref listing does. Measured: refs/heads/.hidden at 000 is refused by name, where the true answer is source-default-branch-not-local "(its branches: dev)".
  • N3: packed refs with refs/heads at 000: main and head both give git-failed from show-ref --verify ("not a valid ref"). A follow-up beside the ref-file case.
  • N4: "no ... message wording is new" overstates it: the named part and the two UNTOLD phrases are new words.

MEASURED (own probe, uid 1000, git 2.43.0, Python 3.12.3, stub gh; main 105b5da vs head; every mode put back)

  • refs/heads 000 (plan/check/execute): main not-local "(its branches: none)" -> head source-unreadable naming refs/heads + full path, chmod u+rx cure, exit 2.
  • refs/remotes/origin 000 (clone): main plan exit 0 without a word, check "plan ok" -> head refused naming refs/remotes/origin. Fetched refs: main wrote at dev -> head refused.
  • origin/HEAD unreachable (refs/remotes 000): main plan exit 0, check "plan ok" -> head refused naming refs/remotes.
  • refs/heads/feature 000, HEAD on feature/x: main wrote the plan at main, then check plan-stale + plan-tracking-branch-missing -> head refused naming refs/heads/feature.
  • ref file refs/heads/main 000: unchanged, not-local "(its branches: none)", as the body says.
  • Readable control: plan, check and the plan file byte-identical; execute identical but for temp names and the split SHA (template and pin bytes from the tool tree). Git calls main/head: plan 18/18, check 57/57, execute 101/101, same sequence. The extra rev-parse --git-common-dir appears only where git's answer was none.
  • Root: plan as root stops at adopt-work-dir-shared under /workspace, so in-process Source.open as root on the 000 source: both trees read main, and _refuse_unlisted_refs is asked 0 times. Mode rows: 29 rows under needs_a_mode (geteuid()==0 skip); 6 run everywhere.
  • Callers: all 3 _symbolic_ref and all 3 _branch_names callers pass the new argument; none can still turn "unlistable" into "none".

TESTS (local head+main merge, filter-repo 2.47.0 on PATH, -rs)

Not checked: batches A/B, Python 3.14, Windows, reftable, --x/r-- in my own probe (the rows cover them), .lock names.

Lane: openreposhape-3 (openRepoShape-3)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant